mirror of
https://github.com/misskey-dev/misskey.git
synced 2026-07-30 11:55:49 +00:00
Compare commits
1234 Commits
2026.7.0-beta.1
...
room
| Author | SHA1 | Date | |
|---|---|---|---|
| c35a6c01ff | |||
| f75c12fbd3 | |||
| 755a630468 | |||
| c2bb96e180 | |||
| 64d721d24a | |||
| a716d5511a | |||
| cb62a1d4fe | |||
| 6ec6a5d523 | |||
| 3bbe40baec | |||
| 2d7e79f924 | |||
| b3c981d14a | |||
| bcc3bc4fd7 | |||
| 1b878998a0 | |||
| dc773a2709 | |||
| 9feab0b673 | |||
| 6f579b6854 | |||
| c1d60fa5f5 | |||
| d93aede642 | |||
| d600525bb2 | |||
| 6dac6f7928 | |||
| 318af2e1ff | |||
| a44e419d5a | |||
| 3479b957e4 | |||
| 4682180c8b | |||
| 2509a28813 | |||
| a12ee2e5d7 | |||
| 6dffa2f59a | |||
| 86762db14e | |||
| f53b50ee54 | |||
| 58cb722bdf | |||
| ee2d16b237 | |||
| 319ddc674c | |||
| 703aa2360b | |||
| cb58ff32a4 | |||
| 9d4d04b6d2 | |||
| b9ca53785c | |||
| 83f3daccde | |||
| f6979333d4 | |||
| e897255e71 | |||
| daf2a85539 | |||
| 1a59ec20e3 | |||
| 3e31e59bcd | |||
| 982d4905c0 | |||
| 7ecce0901c | |||
| c70fb20b9e | |||
| cde2fe24c4 | |||
| e8951b7aa1 | |||
| d2973ecdca | |||
| cf7a6b6efc | |||
| 7c29a18d61 | |||
| 16ca5a72a6 | |||
| db8ee16e03 | |||
| 521f6006fe | |||
| 19fa9a24ab | |||
| f68da80ebd | |||
| fcb4ced33d | |||
| 85e810533b | |||
| 661838c89e | |||
| 80d14231c5 | |||
| 28153f7b5f | |||
| 6d81c81ab2 | |||
| 6f54416f63 | |||
| a63c8ef939 | |||
| 015868f17f | |||
| dc3fd42d63 | |||
| 34a4b9b74c | |||
| 4a7ff6d7f4 | |||
| 3d87032881 | |||
| dc3f2a0012 | |||
| 79b4a73679 | |||
| 170ee2bfeb | |||
| c9ac4f9b41 | |||
| d5a09b3919 | |||
| 5cb6661484 | |||
| 575390e612 | |||
| c6eb30cfb8 | |||
| 48216d343e | |||
| 82cca1124f | |||
| 2d2aa779ad | |||
| eb7626924e | |||
| 7725110e1c | |||
| 4c3f40a87d | |||
| 5e3977973f | |||
| 98ae62c432 | |||
| 41e730a763 | |||
| b51d5ba0a0 | |||
| 159b1a4484 | |||
| f138a48419 | |||
| 1dcd6c287c | |||
| ccd04fbcd9 | |||
| b2d07ca6e3 | |||
| ab369784fb | |||
| 7157f37011 | |||
| bbd57c751c | |||
| 6fac907867 | |||
| 887f219119 | |||
| b1a1717fdc | |||
| 1903607659 | |||
| 7d750668c0 | |||
| db6708bfbd | |||
| bb06278e97 | |||
| 3fd8becdc5 | |||
| de94c15c8f | |||
| 68719db32f | |||
| 1e4539a168 | |||
| ef6c5dfc95 | |||
| 4fc9e38e0d | |||
| 60f60b6bac | |||
| a7a7b8bc69 | |||
| 253dd052f8 | |||
| c1eb67a4ce | |||
| a26d78d573 | |||
| 9e33507f01 | |||
| bde03b8259 | |||
| 5690d28d4e | |||
| 64a4283c35 | |||
| 9de48030fc | |||
| 1dbf9f9f85 | |||
| 37c3145859 | |||
| 600dae3d68 | |||
| ab9d6947ae | |||
| 56da1e5084 | |||
| 63d314750f | |||
| 1e10b67476 | |||
| cf2139d515 | |||
| 3d2941bd8f | |||
| c06eef6b58 | |||
| fa7a8b1282 | |||
| 319826fe76 | |||
| 54cfa4a13e | |||
| e507d41640 | |||
| d9cac0fa56 | |||
| 2e51cde16e | |||
| 98ac383b44 | |||
| e1290584f2 | |||
| 923d95d078 | |||
| 47a5f0cdd9 | |||
| 961d611cd4 | |||
| 2bc10ccc34 | |||
| 38dae938c0 | |||
| 1f2b635920 | |||
| 39c6baf6f2 | |||
| 79541bcbe3 | |||
| ba66b5d609 | |||
| a2c96c29c7 | |||
| 60db0a6bbd | |||
| 07f17820b3 | |||
| 66cd678cd3 | |||
| 1f604db203 | |||
| 06def2995b | |||
| c9107e5d2b | |||
| cbac208262 | |||
| b7c7323b97 | |||
| 602d034e94 | |||
| 22f78ab175 | |||
| d59afb5a29 | |||
| cc8e6a2e92 | |||
| 004ef368c2 | |||
| ac06c1700a | |||
| 2d0fb674d5 | |||
| 61b8b0aac3 | |||
| bd6e443cec | |||
| 897199a807 | |||
| 1f64579516 | |||
| 84203630a1 | |||
| 561e0c3813 | |||
| 169d655717 | |||
| ade652dd3e | |||
| 138353c84d | |||
| 4b30bfeb3c | |||
| aae2039df2 | |||
| 850e156687 | |||
| 844196cfea | |||
| 1925840f3f | |||
| d5df006463 | |||
| 2593652510 | |||
| 2e22b34c40 | |||
| 8d3c3284b5 | |||
| 7d7a125299 | |||
| aac51a171c | |||
| 908e0022e9 | |||
| b56a2a8bf7 | |||
| 61de0fb279 | |||
| b43ff3d820 | |||
| 23b2f4c7a0 | |||
| 827895b548 | |||
| 8b0dd1ea68 | |||
| ec3517ba58 | |||
| c24515044e | |||
| f55adc19d3 | |||
| 32af098cd9 | |||
| cfea88dce3 | |||
| 3a97e26f80 | |||
| 7bcb1b1732 | |||
| 8684cf14cc | |||
| 686a847697 | |||
| 0294ef74e4 | |||
| 42af31fbbd | |||
| b9ea42531a | |||
| 39c4618a35 | |||
| b025ce0ae6 | |||
| 07e660df01 | |||
| e07eed4a4b | |||
| 19ad95cfa6 | |||
| 8f5f595073 | |||
| 6a536d5c92 | |||
| c1e6e6a871 | |||
| 039bce590f | |||
| 24dac68475 | |||
| 8217a3a42c | |||
| dbcb856638 | |||
| dbbb197816 | |||
| 40263b0af5 | |||
| b978df8990 | |||
| 3a0da02b50 | |||
| af7d1a763a | |||
| a7e16c09dd | |||
| 351872f47b | |||
| e3c6c3025a | |||
| 273bc59cdb | |||
| 7a3e27d653 | |||
| 6a8aacfb95 | |||
| 99659a89b4 | |||
| 4591b940c3 | |||
| f01e9aa2ac | |||
| bba1605801 | |||
| 6d6dbb2584 | |||
| 51a08bbf34 | |||
| 7fa8bcc9fd | |||
| 9d5818f7b5 | |||
| bb028fda00 | |||
| 56ae76e994 | |||
| 69bc12201e | |||
| 313e730fbb | |||
| c14986b43d | |||
| 69c75fc464 | |||
| 1b13c945ff | |||
| 6e763d4daa | |||
| d1e5b04a10 | |||
| 7fec78b70e | |||
| c962cd33b3 | |||
| 814bf357ca | |||
| 222d914544 | |||
| b81b9e1da7 | |||
| 52cb23da63 | |||
| 6b4c2c9141 | |||
| 7af9069962 | |||
| 8f9be8c735 | |||
| cdbf5c843f | |||
| 27fff648b6 | |||
| 5cdc5f53b4 | |||
| c67e231ff5 | |||
| f564b7a29b | |||
| 1eea31aacd | |||
| 5da458ca54 | |||
| c343ccbdff | |||
| f8a981a1fc | |||
| 72df3d29ce | |||
| 75454b8075 | |||
| 559846fd7c | |||
| 8c1fa69800 | |||
| 810d437a2c | |||
| 3fca2cb664 | |||
| e95552b264 | |||
| 781f6135fd | |||
| e90830c738 | |||
| ab32ab6746 | |||
| 86c4f3072f | |||
| 8bb8b2fc97 | |||
| fad53328be | |||
| 0a6bfce548 | |||
| 5998b01ffc | |||
| 9a91170839 | |||
| a0aa64cd9f | |||
| 95864071a0 | |||
| ff9d997cec | |||
| c6d0855a2e | |||
| 624b858758 | |||
| d722b8e9d7 | |||
| fd02ea906f | |||
| aed9a4a1b4 | |||
| ace51f7605 | |||
| c10e1c4b31 | |||
| 4c80e92522 | |||
| 71c3f921cc | |||
| 6bc9eb9d84 | |||
| fbf3c02392 | |||
| 0c8b1055d4 | |||
| 0e7b517554 | |||
| 5cefdd224b | |||
| 1a5a4c834f | |||
| 2df145c458 | |||
| 43958fc70d | |||
| 8f94b04edc | |||
| c7d07d0364 | |||
| 5d974e8242 | |||
| 807ade4c37 | |||
| 2efd52f400 | |||
| f0375aa420 | |||
| 04c2703ace | |||
| 65022ed342 | |||
| 0d025d99f2 | |||
| e68847e563 | |||
| 409e9f744c | |||
| 9f81a88466 | |||
| 564d2446cb | |||
| cb605a3978 | |||
| 4f89010aca | |||
| 8933ac119a | |||
| 982897f1b5 | |||
| dd69662603 | |||
| 9485b3fba9 | |||
| 245f673a78 | |||
| 71bb388b8b | |||
| 39f2614f9a | |||
| 46efad5b12 | |||
| fda845cb63 | |||
| cccc66bbbf | |||
| 32822d6d07 | |||
| 7bca3ca179 | |||
| 6961f277b5 | |||
| eb33c8a93f | |||
| 43232d5baa | |||
| b8f67a3e69 | |||
| 748b571856 | |||
| f0bf3cda75 | |||
| bf28b303d7 | |||
| 1a064ea430 | |||
| 68431f62f1 | |||
| 2c8abf8239 | |||
| 822b567742 | |||
| a89475e820 | |||
| 2d8c6ca585 | |||
| fc6a840132 | |||
| 9a44c69177 | |||
| 0be946a10b | |||
| a201f72c8a | |||
| 83a4281a30 | |||
| 7546bf7dc2 | |||
| 2942234778 | |||
| 540e20864c | |||
| 6ee6894184 | |||
| 2a880164f8 | |||
| c002752d65 | |||
| 05e1a43a1c | |||
| ec79d6bee7 | |||
| a3b6dc0899 | |||
| 2fffa94eb2 | |||
| 0fade25574 | |||
| aa4fa9c54e | |||
| fdba915288 | |||
| 773077470e | |||
| 3eafdbe57a | |||
| 8138fd2c34 | |||
| ce72d10491 | |||
| f8851c0f2e | |||
| d3a7dc0ea5 | |||
| 32bdb77e97 | |||
| 8ac3c77ae1 | |||
| 8611d55ffc | |||
| 14b648faa6 | |||
| c6a755c0a7 | |||
| 1154bb5370 | |||
| 1701e993cf | |||
| 99f998cf60 | |||
| 05a8dcf897 | |||
| 90ea7902eb | |||
| 644c16aee6 | |||
| ce5857b265 | |||
| fbae160442 | |||
| 2daad2223d | |||
| 6cbae336d0 | |||
| 47f9f38a40 | |||
| 73d6764a0b | |||
| c85ee681bb | |||
| 61d5a5620a | |||
| 2873768cd2 | |||
| 4c957ecd9b | |||
| 8c61fc2be0 | |||
| 57057f56f5 | |||
| 985dd72a7f | |||
| dfbe765baa | |||
| 555fa80709 | |||
| 7546e7c800 | |||
| ce36de7e4f | |||
| 1cb96731e6 | |||
| 4bf0ba3716 | |||
| d2e48b707b | |||
| 8e70550c84 | |||
| 199c0f533b | |||
| ad44cc3446 | |||
| 8f73c22df3 | |||
| 20eb342eff | |||
| 5d17b557c1 | |||
| 43cb18b3e9 | |||
| 75dfcf48b6 | |||
| 9e9ae54c26 | |||
| e78694f11b | |||
| 5397beb965 | |||
| 92bc50cb90 | |||
| 45d8b656cb | |||
| a5e0c594b2 | |||
| 646b0ca041 | |||
| de27cc92dc | |||
| 09d5502eb6 | |||
| dc6ac2529f | |||
| a766609963 | |||
| e1aa62d2b7 | |||
| c62d92d88f | |||
| 1a456998bc | |||
| 14fe51c0a4 | |||
| f203ebe4d5 | |||
| de979d3a98 | |||
| 7c2e3adf25 | |||
| e24cf73a83 | |||
| d3eb3867bd | |||
| fcfd3331dd | |||
| 5aa1f0d562 | |||
| 491510a11e | |||
| e0382cdf86 | |||
| 9ae6a9f426 | |||
| a8ca0cf9ed | |||
| 45fc8994f0 | |||
| 85ae7c7efc | |||
| 2867f03f50 | |||
| 1cd75fe5a8 | |||
| 7f2286f1fd | |||
| 840e2c3744 | |||
| af1dbd3a71 | |||
| 82cdaeb56e | |||
| ea732458ab | |||
| b6e92cdef9 | |||
| 11b699a180 | |||
| e90a53a851 | |||
| 1469f890dd | |||
| 6c82e0fef8 | |||
| 6ee52259c4 | |||
| 414b522d0a | |||
| a95f3c9467 | |||
| ecb2ae379e | |||
| 2e34e6f454 | |||
| bee415625a | |||
| d0d3aef76c | |||
| 93a46ae2d6 | |||
| 8b5d006248 | |||
| bcc965a19c | |||
| 2c5ce5bfae | |||
| 170980a0a6 | |||
| 216fb56a4b | |||
| 8defcd9f9f | |||
| d38f04c97f | |||
| ecedd71192 | |||
| b582895ac6 | |||
| 267dc5fb64 | |||
| ab02a99a0e | |||
| 03481322a9 | |||
| fb130466ae | |||
| 43f20e44c3 | |||
| 2966d2a862 | |||
| 8f23991723 | |||
| bfdda83a6d | |||
| ddce5ff526 | |||
| 8dd62e57e2 | |||
| 0b17663eeb | |||
| 41c5e7242d | |||
| ef8f593de8 | |||
| 410cc8ac50 | |||
| a905eeef03 | |||
| ebb74eff92 | |||
| b69efeeb79 | |||
| 1a57b8177b | |||
| 0e300a9795 | |||
| 13d35f8124 | |||
| b53e4f6742 | |||
| 9d102c2a70 | |||
| 84f389aebd | |||
| 8072dbef8b | |||
| 8195f5257f | |||
| b8f8ac031c | |||
| 57f1adb402 | |||
| 48ce2e09ab | |||
| dca5450340 | |||
| 202c9e8f25 | |||
| 8e1e69e60e | |||
| f3d0edf546 | |||
| 08dfd23c19 | |||
| b231357ae8 | |||
| 102cf03213 | |||
| d1da15128e | |||
| 0d96f418ab | |||
| b879f2f1e3 | |||
| fb77eb4349 | |||
| 79ad7e274c | |||
| 4890673013 | |||
| 1f204f572f | |||
| 9976588025 | |||
| 414a28fb19 | |||
| 3605ffdafc | |||
| 179c9fc70a | |||
| f3a7f10319 | |||
| 17333fd7e5 | |||
| 14f4d2c228 | |||
| 18b4210eef | |||
| 54c2c4dd53 | |||
| 42da479026 | |||
| adc487ef78 | |||
| b08b3a2500 | |||
| 4324b6def2 | |||
| 21fe0f5e67 | |||
| 3ede04c563 | |||
| b750d69065 | |||
| 0c4b36e2d1 | |||
| 5cb9474494 | |||
| 3be075d281 | |||
| d7c94fbf86 | |||
| c6d7aa7be8 | |||
| d56c6dfe57 | |||
| f9be5d8c47 | |||
| ac023668a7 | |||
| 59c9b86842 | |||
| c4adcde114 | |||
| db1b5e9ce9 | |||
| a1cb4b8304 | |||
| fd04c5f2fc | |||
| 7be7465703 | |||
| 626ae675bc | |||
| 2a1cd5c197 | |||
| a9dd5fd5bf | |||
| beb5d1dec5 | |||
| d451ce8c36 | |||
| f0f78a11cb | |||
| 69dd2675fa | |||
| eb7691e3ba | |||
| db90e4ebc0 | |||
| 29491997ea | |||
| 015e6d1c81 | |||
| 49ee15dd9a | |||
| e6b5758d54 | |||
| 210368d597 | |||
| 203f29afb9 | |||
| fa0eac34c2 | |||
| d4fa5cf7ca | |||
| 479e9af17e | |||
| f03af71dc0 | |||
| 6d94f00ecf | |||
| 9e848f3135 | |||
| b16b158372 | |||
| 39525c66c2 | |||
| d3dc9bc86c | |||
| 444d862eac | |||
| 74e9851511 | |||
| a8586fe224 | |||
| d132fdfc04 | |||
| 3ba902c2b6 | |||
| 50f7c74259 | |||
| 9b7c908c68 | |||
| e7290c0486 | |||
| db22eddd1e | |||
| a8cea0622d | |||
| 5bf1b5569f | |||
| d1cb2c5bc7 | |||
| a49697042a | |||
| 4656d93358 | |||
| 2e5a02a85a | |||
| 8421ec75da | |||
| dcd2160294 | |||
| 3833469955 | |||
| 0adfbc8d51 | |||
| a931079896 | |||
| 18c08f52f1 | |||
| 8975449538 | |||
| 4e149a642d | |||
| 09d133242d | |||
| a8db20259b | |||
| d1eda166de | |||
| 7df4b729e9 | |||
| 3ed6148f6a | |||
| ebe5739ce3 | |||
| 9bc404a8f5 | |||
| 90ff3d79d1 | |||
| 6fb49ab88d | |||
| 722d09b1ae | |||
| 6d6ae6728c | |||
| ce2e74f3ca | |||
| b34e957c25 | |||
| 157b4673fd | |||
| ac1a19e95c | |||
| 5639324077 | |||
| a471fe16fa | |||
| 6cf90fd714 | |||
| c7c785ad2a | |||
| bde64b5b1f | |||
| 30f7727e33 | |||
| 5d36da17fe | |||
| 7bcda08339 | |||
| a15575078f | |||
| 56d813a184 | |||
| 8f5c09daa1 | |||
| c2d5a33400 | |||
| 6f3f4e7ef1 | |||
| 49f21d7423 | |||
| 92a6086e21 | |||
| 9d1c2d52d2 | |||
| f97a6c6d55 | |||
| 0f69a284c6 | |||
| 1427d887dd | |||
| a4c9aff8a9 | |||
| 5db8ccec74 | |||
| 70ebc0d32c | |||
| d885627350 | |||
| 4a77db7866 | |||
| e50d4fa8ab | |||
| 27578f2688 | |||
| fc97ba41af | |||
| a3610ae6c4 | |||
| fc615daad3 | |||
| 0321edb1ac | |||
| 8593737886 | |||
| 35ad1d758e | |||
| 110e5daa6f | |||
| 5de191f01a | |||
| 80c2b1fa65 | |||
| c8441da835 | |||
| fa2b1d6096 | |||
| 18a4da4ad7 | |||
| 52e9395fab | |||
| d05d7938a4 | |||
| 7846e8efb8 | |||
| e09f832fad | |||
| 515f6d9790 | |||
| 657159da45 | |||
| e88188cd6d | |||
| dcb834ed41 | |||
| 5a7960d0a9 | |||
| 11e55d8fe8 | |||
| 836de1bb28 | |||
| 58e617af6d | |||
| f44d566933 | |||
| 72fbc4bc9c | |||
| 746c16aecc | |||
| 7bef2cd8e0 | |||
| aed73eb074 | |||
| a756ca6ffb | |||
| f19040888a | |||
| 634cdf5e1e | |||
| c09d445215 | |||
| 1d71c0c6dd | |||
| 130a43f39a | |||
| 9c25c44a8a | |||
| 76ce6c84c0 | |||
| 24caff71e1 | |||
| 036b8ea320 | |||
| f98394fc60 | |||
| f52ac6351d | |||
| eb1357026d | |||
| 1b5be37f9b | |||
| 42c659c580 | |||
| ec32cad19f | |||
| 0910c47612 | |||
| 88e7303779 | |||
| 3a5532211b | |||
| 5b945278f9 | |||
| 0db2e5a42f | |||
| 8e1c5673b8 | |||
| a763c396bd | |||
| f5dae1d4c8 | |||
| 17697ba6ec | |||
| a77987ab28 | |||
| 47d9e92776 | |||
| af928ffe93 | |||
| 4adca586ed | |||
| e703705d60 | |||
| be434949a4 | |||
| bbff43e9e6 | |||
| 5c28ee0536 | |||
| 7e41d17c6a | |||
| 1cd6d01fdd | |||
| 3263f4bcc0 | |||
| 58feedb53d | |||
| e62c85a971 | |||
| 585d727297 | |||
| abd6c85b41 | |||
| 9551a3d01a | |||
| d281a81200 | |||
| 82741c2d61 | |||
| b00880c21f | |||
| df5d5d23cc | |||
| 35f6cac9f6 | |||
| 7ddcbf5e94 | |||
| 634bae3c49 | |||
| 0b50aa9d13 | |||
| e594ad9c6f | |||
| d105c707ec | |||
| 93f24c5b8f | |||
| 7c170a21e5 | |||
| 1de4440dbd | |||
| e6ce36178c | |||
| 8a6e925297 | |||
| eeda7e7002 | |||
| abfa67965e | |||
| 2c6560cc71 | |||
| 4984146f6e | |||
| c36dfc6643 | |||
| 9184f0d7b9 | |||
| 4c659c3129 | |||
| 10926e5525 | |||
| 8d3a5a6503 | |||
| 924c517bb5 | |||
| 54c339d89c | |||
| ce98d4244b | |||
| 4e4b56699b | |||
| fab7667b0a | |||
| 942e32f4de | |||
| 3897b044fe | |||
| c47a0c33cf | |||
| 2b849685a1 | |||
| bad29c7604 | |||
| b7b24a2140 | |||
| 20d9a03bb3 | |||
| 09d58a3ecf | |||
| 94e8050455 | |||
| 2f039ce2e9 | |||
| 61fd35bc97 | |||
| 2d36ccf1b2 | |||
| 174221fdc4 | |||
| 08a0f03a45 | |||
| de795a48e8 | |||
| af8a0bdf12 | |||
| b734ab3419 | |||
| 595e66c423 | |||
| 7b0e839661 | |||
| 6f79420fdc | |||
| b41bad4188 | |||
| 2f34f1c6f1 | |||
| 7b80da7737 | |||
| e65e3b4569 | |||
| 0ef489513a | |||
| a92cae8e09 | |||
| 2619509d69 | |||
| da8945dc23 | |||
| 64995bebc5 | |||
| c2dde53c1c | |||
| efaf7bdd95 | |||
| 3dcbfc0168 | |||
| e96e88b1ce | |||
| 493a2eb50c | |||
| fc4d769e1e | |||
| 0f0bc9b54f | |||
| ead90471c4 | |||
| 461e083454 | |||
| 79fe0fbd05 | |||
| db9d0090b7 | |||
| 1f81960640 | |||
| 5d389732d9 | |||
| b300f9620c | |||
| ddafd9e517 | |||
| 2b9f593e03 | |||
| 5f3f3d715a | |||
| de62fa3b59 | |||
| 32cba5b979 | |||
| ab90824b9c | |||
| ebec026508 | |||
| 8ddb2cbe75 | |||
| 97439d7718 | |||
| 60a2cd9306 | |||
| 62be4a258e | |||
| ae4a174de5 | |||
| 1e15503000 | |||
| 61ac82d297 | |||
| f18b3467d9 | |||
| dec440b6cc | |||
| 26ac4f7732 | |||
| b7b3c07a96 | |||
| 2040827615 | |||
| 2b456fec47 | |||
| b21ad59db3 | |||
| f04799a4f5 | |||
| 28dec6b0a3 | |||
| 714bff0835 | |||
| 772608ae99 | |||
| fe3b3704ba | |||
| d6caef7ee7 | |||
| 83a15f74ef | |||
| 27addb49cf | |||
| 6aa741f8d4 | |||
| e224bdf5e4 | |||
| 9fe2044f53 | |||
| b1aef2d308 | |||
| 79a063f692 | |||
| c3c36b06c2 | |||
| 358f0c0a6f | |||
| 6041db87e8 | |||
| ff4af812b5 | |||
| c2428ca3cc | |||
| e402057d3b | |||
| 3811de2283 | |||
| 421d466921 | |||
| a211d0df51 | |||
| c0690c9b80 | |||
| 8b9164a8c3 | |||
| 5df01bae9a | |||
| f8e093466d | |||
| 664ca528fe | |||
| aaab1e7260 | |||
| a85f05ca29 | |||
| 3253d30073 | |||
| 02d365e27b | |||
| a4be9b2078 | |||
| 47cd092380 | |||
| d1555d5423 | |||
| acd9b94b49 | |||
| 4b135bccd8 | |||
| 109fdd2ff3 | |||
| aa5275137e | |||
| 669286c1d8 | |||
| 623b4f087f | |||
| 02c6e1b876 | |||
| 6cca5706f7 | |||
| ebdf627b19 | |||
| ae463cde5e | |||
| 6350890e9f | |||
| 77fc803612 | |||
| e87752a07c | |||
| a7c8a3d6d1 | |||
| 2175a3a18f | |||
| 3d48146b92 | |||
| 4f9aded205 | |||
| e634fb1456 | |||
| 2b0d0d4533 | |||
| 4ac9da7f1f | |||
| 08e0ecf99b | |||
| 007a2481ef | |||
| 61eea5799b | |||
| 1fe9117944 | |||
| 4a16a71fa2 | |||
| 85701fcb6d | |||
| cd918817d9 | |||
| 24c0504cb0 | |||
| 750a48df62 | |||
| ead79ab275 | |||
| 124079f80a | |||
| ac2c6b93ce | |||
| b0d4ab371b | |||
| a3c3f7ba91 | |||
| 12e1b86b53 | |||
| 7fd9ac1cc8 | |||
| da8a7abcde | |||
| 6275196101 | |||
| 7eb8723082 | |||
| d730c26fcc | |||
| 56c2e1c989 | |||
| 77acce78dd | |||
| db5f64b097 | |||
| ddfd9f46f3 | |||
| 4254268f8d | |||
| 5104bafe95 | |||
| c426f95bee | |||
| c9ae842258 | |||
| 6a7e05d00a | |||
| af5bdb4296 | |||
| cf46a4af1e | |||
| 83daf43f49 | |||
| 3db0b8a1fd | |||
| cf9349f29c | |||
| 7e0b5ff8be | |||
| 28030ea3fa | |||
| 357aeff407 | |||
| 2f80442b99 | |||
| 3bad686c71 | |||
| 240c055b45 | |||
| 02eb8cfe1c | |||
| 87e2a046b7 | |||
| 4ce42a02c1 | |||
| f61b2504cc | |||
| 1b119c49a1 | |||
| 0ae3eb0721 | |||
| 205d2c3343 | |||
| b6e269d140 | |||
| b9335bc314 | |||
| ae92f75345 | |||
| 2ff307fe42 | |||
| 60bdd30681 | |||
| c877210828 | |||
| e85d1c6139 | |||
| 2094e82a30 | |||
| f86c5fb3b2 | |||
| 6b95d07930 | |||
| 886f64055d | |||
| 4740c76128 | |||
| baad4ae929 | |||
| e989c4b1a5 | |||
| 8f133d3fed | |||
| f26ebef565 | |||
| 7f46bd4928 | |||
| abe22da9ed | |||
| ebc9a60237 | |||
| 272c267ea0 | |||
| 21e6ac6678 | |||
| 2685e254f1 | |||
| 7d5c2052eb | |||
| e8d43032cc | |||
| 4ad5234325 | |||
| 6582087b2f | |||
| dc689a8c22 | |||
| fa8bdf55be | |||
| 87828dc0ad | |||
| 9fe161ec7c | |||
| 938dc5ce40 | |||
| 5049857e81 | |||
| 58cf1414fc | |||
| 6563eb9b8f | |||
| 69ac19d018 | |||
| eeae06014a | |||
| d808d20f8e | |||
| d775fa6360 | |||
| 5f4914e6dc | |||
| 8427685f7b | |||
| 7dd05a3d60 | |||
| 55747bdb99 | |||
| 909c0ae156 | |||
| 4c0b78a1ac | |||
| 62a9795685 | |||
| 04ff23c44f | |||
| ed119dfeb8 | |||
| c341ad21db | |||
| 11119ab046 | |||
| dc59fd4edb | |||
| 887f548985 | |||
| a627b58e85 | |||
| 57fbebaea5 | |||
| 6386ebe18e | |||
| 60bf7c6b1f | |||
| 6fe0e97ac6 | |||
| bf72fb0d9a | |||
| 85af12d35a | |||
| 7970fed6e1 | |||
| 03c0b48e1e | |||
| 45c851bb5b | |||
| 9b3424c5d3 | |||
| 9d9b47daca | |||
| 1d8f03e199 | |||
| 5e1c0d1064 | |||
| a6b7150371 | |||
| 7701bca55b | |||
| 547da71743 | |||
| 25ed41ba64 | |||
| 5e4d128f68 | |||
| 8ec7da0cf4 | |||
| 970efb3440 | |||
| 58acbac7ef | |||
| 5736b43149 | |||
| 682c5417cb | |||
| 127600a5d4 | |||
| 61386f699e | |||
| 3283ec410a | |||
| 6fd8c8c908 | |||
| 5b6fa78748 | |||
| 64fd4b7c0a | |||
| 3356cf36d3 | |||
| e2e4e83e6f | |||
| 8030e4f3fa | |||
| a00d80e30c | |||
| 82373f24d9 | |||
| fb0c089a16 | |||
| c04c6502be | |||
| 6b4310c91d | |||
| 662de635aa | |||
| af8d14a139 | |||
| afd731797e | |||
| d51d1191c5 | |||
| f4a060b9a8 | |||
| f6677aa02c | |||
| 0d966d8ded | |||
| a54a8a10ad | |||
| 602c8d8be1 | |||
| bdbbe83421 | |||
| 50575a272c | |||
| 3b80a96412 | |||
| fa979f1fed | |||
| 38bf5fc0bf | |||
| 78911b24de | |||
| 83b5305671 | |||
| 07d57b0edd | |||
| bd0e6f3268 | |||
| 0c808aa23d | |||
| 68eebb9d76 | |||
| b318cff137 | |||
| 614e8a7254 | |||
| 93f62f6054 | |||
| ca49618fdb | |||
| fdd360f490 | |||
| df5df25c80 | |||
| c454b8922b | |||
| ba4d495b42 | |||
| 4ed1d56f03 | |||
| 49823f9ec3 | |||
| 85dea8b49d | |||
| f2be8a2169 | |||
| 7121cd1ea9 | |||
| e79532e50a | |||
| 17f97bab7b | |||
| f13ada97a7 | |||
| 0db754bdd6 | |||
| 74dfbb7a74 | |||
| 2a583509ab | |||
| 4813ea5afc | |||
| 65b6821d4f | |||
| 8a165321b7 | |||
| b89fc36cd0 | |||
| f5ebbbca50 | |||
| 81414a18d3 | |||
| 6bf4feaef5 | |||
| 4efe4fb519 | |||
| 3873eb0cd7 | |||
| df092dd120 | |||
| 654c2c5b05 | |||
| f367bc37f8 | |||
| e8d8242c09 | |||
| 2ecaccedab | |||
| 4a7614f903 | |||
| d3da77c307 | |||
| 1c610ce825 | |||
| d5a5b04468 | |||
| b91409f5c6 | |||
| 74c67b843e | |||
| 913e35442c | |||
| 7a939dc5b3 | |||
| 71179b8b24 | |||
| c5a9c08814 | |||
| ea8df304c9 | |||
| a01bbf828d | |||
| d2807e974c | |||
| d6f41f9c51 | |||
| b2f2e9e75d | |||
| ee55a0a6cc | |||
| df184c8fdf | |||
| 4dd3bfc208 | |||
| c677dd6566 | |||
| de53f475c5 | |||
| b2eed6b82a | |||
| 6eefe6899c | |||
| 8619d0156e | |||
| 9bc0d7b361 | |||
| 9ffd8d777e | |||
| c558f93a0e | |||
| 5458ee016d | |||
| 5886260e0b | |||
| 42c7a483a4 | |||
| 7f5858a66f | |||
| 4965429069 | |||
| 58ec8af8cd | |||
| 1af8584aca | |||
| 564098a631 | |||
| 6fceb31c44 | |||
| 2a1a27f8c7 | |||
| 0e5c8496ce | |||
| 7ff95b8f8a | |||
| 295c91c245 | |||
| 7322697707 | |||
| ed4e3a51fd | |||
| bdc34305be | |||
| 354504b534 | |||
| f311105b54 | |||
| 2984b0000b | |||
| 98aadf8dcc | |||
| 1d3ddd279b | |||
| 349ee141bb | |||
| 9070159db7 | |||
| 0a4b81b0cc | |||
| 2de50f893f | |||
| 0157dd6b41 | |||
| 208c300460 | |||
| 46791a3bf2 | |||
| 16b54e9615 | |||
| 78b689f41c | |||
| 7b04d5d434 | |||
| c22345e3e0 | |||
| a90c179998 | |||
| 8eebeab692 | |||
| 66e0eeedfb | |||
| 3874f7abe9 | |||
| 98f74b0c7a | |||
| dbffa5520c | |||
| e336cbad62 | |||
| 2fab946b7a | |||
| 239df4694c | |||
| 1c9a324f3a | |||
| dbdb7ec324 | |||
| d43b3be6f0 | |||
| 34b46baaea | |||
| dc8dda3aac | |||
| 1b2717e256 | |||
| 9d723aaaa6 | |||
| 78bfcd71af | |||
| 5175a1e193 | |||
| 2438447ad3 | |||
| 01385575fd | |||
| 3f0c4b0577 | |||
| 36438e85d1 | |||
| 545009078a | |||
| 5910ec68e3 | |||
| 3e7166bd2c | |||
| dbfd1a751c | |||
| dda26f7f48 | |||
| 54b30d1138 | |||
| d8dc66781f | |||
| 4d37ada54d | |||
| a8456a45ab | |||
| 08667b4d35 | |||
| 03f20814c9 | |||
| 2672ae4463 | |||
| de4c1b3b66 | |||
| ec82773ff7 | |||
| cdb8d86fbf | |||
| 20dc48f221 | |||
| 0729e209c5 | |||
| c5eaf0f7af | |||
| dcae3ccaaa | |||
| fcc36759f7 | |||
| 689c24c776 | |||
| 055121d698 | |||
| 402dd538bf | |||
| 8bdf773a2b | |||
| 460f79d5cf | |||
| 998f85b260 | |||
| a0356d8d4d | |||
| d68655f5c2 | |||
| bba7076eca | |||
| aae03a914d | |||
| cdc9b47b78 | |||
| 41d40f53cf | |||
| 17a3bdb5eb | |||
| dadc5295fa | |||
| 679c75006a | |||
| cd9612e664 | |||
| d01b3036d6 | |||
| 376bb328df | |||
| 6a08231591 | |||
| 411c4ef3ae | |||
| 6f32e09db5 | |||
| 86f6498ddd | |||
| 5619cbb0da | |||
| 9475e6151f | |||
| af560802b3 | |||
| 3375220aee | |||
| ce7af6a308 | |||
| d446e00964 | |||
| 90fa65c96e | |||
| 3717962757 | |||
| 8bd2003a38 | |||
| 07909ab228 | |||
| 503a02ac42 | |||
| 8a0ba3a18a | |||
| 7b7767942f | |||
| 8c28c7c253 | |||
| 0d8a6e8136 | |||
| 6a4a09c8cf | |||
| 06adb3e045 | |||
| c12f330432 | |||
| a45611171a | |||
| f58de15d45 | |||
| aa6c9be133 | |||
| 2841f67166 | |||
| b1bb07542a | |||
| eb0544e083 | |||
| d490891acc | |||
| 4da92509cb | |||
| f85223c064 | |||
| 0397fccdb3 | |||
| c93758b554 | |||
| be67e75ef9 | |||
| d8d4b230b0 | |||
| 0996c2d9b2 | |||
| 0bcc5a3695 | |||
| e70743bf40 | |||
| 9ecb3d6a5a | |||
| 9f0fbb8531 | |||
| 52a1b30503 | |||
| 1dec481a7e | |||
| ad48f43524 | |||
| 7efa04d561 | |||
| bf7f771760 | |||
| 3acf6db835 | |||
| aafcffd1ad | |||
| 82f68f1e93 | |||
| 3a02ae8b28 | |||
| 3022313fac | |||
| 22d5c27ca7 | |||
| 8665923337 | |||
| efd101d0a0 | |||
| 36f17a156f | |||
| 2e84d2864c | |||
| 909f78b33c | |||
| cb6c790d6c | |||
| b790608f52 | |||
| 13e3bdc90b | |||
| 2a1a03ef9d | |||
| fb25331661 | |||
| 51d2b0d6a5 | |||
| de1a3e3765 | |||
| 68d28eb4ac | |||
| ad150f4718 | |||
| 91e3249b23 | |||
| 24a7131b0b | |||
| a0e318b43f | |||
| e41e700f2d | |||
| 6c64e75412 | |||
| 0c5c0ce67e | |||
| 6ac091096b | |||
| b05010bdc4 | |||
| d033704f12 | |||
| 367119a5a2 | |||
| d15c971125 | |||
| 4d532199b4 | |||
| cd15906c29 | |||
| 4ee5c73bca | |||
| cb1d9c38df | |||
| bce3411cef | |||
| a721a94902 | |||
| 3173290abb | |||
| 491b40ed80 | |||
| ab1362264a | |||
| 09993a8ac8 | |||
| 8619367ac0 | |||
| 148c853000 | |||
| 14efbe6584 | |||
| 9f054bb97b | |||
| 6f07445185 | |||
| 80b5c6cd35 | |||
| 7ab225662c | |||
| 85dc9c738b | |||
| a09bf963f4 | |||
| 242fd56aec | |||
| a42fdee480 |
@@ -15,3 +15,20 @@ reviews:
|
||||
- "dependabot[bot]"
|
||||
- "renovate[bot]"
|
||||
- "github-actions[bot]"
|
||||
path_instructions:
|
||||
- path: "**/*"
|
||||
instructions: |
|
||||
【レビュー対象外】
|
||||
- フォーマット違反、型エラー、SPDXヘッダーの記載漏れ等、静的解析で検出可能な問題については、別Workflow(CI/CD)でチェックされるため、レビュー対象外として無視してください。
|
||||
|
||||
【特別な対応を要するレビュー】
|
||||
- プルリクエストで変更されていない部分において、プルリクエストのタイトルや説明文に照らしてスコープ外であるが修正すべき点を見つけた場合は、このプルリクエストで修正させるのではなく、別のプルリクエストを開いてそれを修正するように指示してください。\
|
||||
なお、当該プルリクエストで行が追加/削除されたことによりスコープ外の問題が起こりうる場合は、例外的にこのプルリクエストで修正させてください。
|
||||
|
||||
【注力してほしい観点】
|
||||
- ビジネスロジックの不備、セキュリティ、パフォーマンス、設計パターンの適切性などに焦点を当ててレビューしてください。
|
||||
- プルリクエストのタイトルや説明文に照らしてスコープ外の変更が含まれていないかをレビューしてください。なお、明らかにスコープ外であると確信できるもの(例: 全く関係ないファイルを編集している等)のみを対象とし、スコープ外かどうかの判断に困る場合は指摘しないでください。\
|
||||
スコープ外の変更が含まれていた場合は指摘し、その部分について、このプルリクエストに含めずに別のプルリクエストを開いて変更するように指示してください。
|
||||
path_filters:
|
||||
- "!CHANGELOG.md"
|
||||
- "!**/__snapshots__/**"
|
||||
|
||||
@@ -173,8 +173,31 @@ id: 'aidx'
|
||||
|
||||
#sentryForBackend:
|
||||
# enableNodeProfiling: true
|
||||
# # Specify Sentry integration names to disable individual auto-instrumentation.
|
||||
# # The names are integration .name values, not factory function names.
|
||||
# # To check enabled names, set `options.debug: true` and see
|
||||
# # "Integration installed: <name>" in the Sentry logs.
|
||||
# #
|
||||
# # As of 2026-07-07 / @sentry/node 10.62.0, useful names for Misskey include:
|
||||
# # Postgres ... DB queries (when pg is externalized)
|
||||
# # Redis ... ioredis commands
|
||||
# # Fastify ... inbound HTTP routes
|
||||
# # Http ... inbound/outbound HTTP; disabling this can also affect request isolation
|
||||
# # NodeFetch ... fetch/undici requests
|
||||
# disabledIntegrations: ['Postgres']
|
||||
# options:
|
||||
# dsn: 'https://examplePublicKey@o0.ingest.sentry.io/0'
|
||||
# # By default, Misskey prevents Sentry trace headers (`sentry-trace` and
|
||||
# # `baggage`) from being sent to remote ActivityPub/Webhook/etc. hosts.
|
||||
# # To intentionally propagate distributed traces to trusted internal services,
|
||||
# # list only those internal URL patterns here.
|
||||
# # Avoid broad patterns that match remote servers unless you intentionally
|
||||
# # want to restore Sentry's legacy behavior of propagating traces to all
|
||||
# # outbound HTTP requests.
|
||||
# #tracePropagationTargets: []
|
||||
# # Internal allowlist example:
|
||||
# #tracePropagationTargets:
|
||||
# # - 'internal-service.example'
|
||||
|
||||
#sentryForFrontend:
|
||||
# vueIntegration:
|
||||
@@ -241,7 +264,7 @@ proxyBypassHosts:
|
||||
|
||||
# Log settings
|
||||
# logging:
|
||||
# # ログの出力形式。「json」で1行JSON、未指定時は「pretty」です。
|
||||
# # Log output format. "json" outputs one-line JSON; defaults to "pretty".
|
||||
# format: pretty
|
||||
# # Minimum level for all loggers. Defaults to info in production and debug otherwise.
|
||||
# level: info
|
||||
@@ -251,6 +274,19 @@ proxyBypassHosts:
|
||||
# queue: error
|
||||
# queue.deliver: debug
|
||||
# db.sql: off
|
||||
# # HTTP status classes to record in the access log. No output when unspecified.
|
||||
# access:
|
||||
# statusClasses:
|
||||
# - '2xx'
|
||||
# - '3xx'
|
||||
# - '4xx'
|
||||
# - '5xx'
|
||||
# # Enable body logging explicitly only during development.
|
||||
# bodies:
|
||||
# request: false
|
||||
# response: false
|
||||
# # 16 KiB by default, up to 128 KiB.
|
||||
# maxBytes: 16384
|
||||
# sql:
|
||||
# # Outputs query parameters during SQL execution to the log.
|
||||
# # default: false
|
||||
|
||||
+14
-58
@@ -329,63 +329,6 @@ id: 'aidx'
|
||||
# #tracePropagationTargets:
|
||||
# # - 'internal-service.example'
|
||||
|
||||
# ┌─────────┐
|
||||
#───┘ Tracing └────────────────────────────────────────────────
|
||||
# OpenTelemetry distributed tracing (Traces only, opt-in).
|
||||
# Existing API and Queue spans are exported to an OTLP http/protobuf endpoint.
|
||||
#
|
||||
# Standard OTEL_* environment variables are honored for values omitted below:
|
||||
# OTEL_EXPORTER_OTLP_TRACES_ENDPOINT, OTEL_EXPORTER_OTLP_ENDPOINT,
|
||||
# OTEL_EXPORTER_OTLP_HEADERS, OTEL_TRACES_SAMPLER, OTEL_TRACES_SAMPLER_ARG, etc.
|
||||
#
|
||||
# When this is enabled together with sentryForBackend, Misskey shares Sentry's
|
||||
# TracerProvider and adds an OTLP SpanProcessor to it. In that mode:
|
||||
# - sampleRate below is ignored; sampling follows sentryForBackend.options
|
||||
# tracesSampleRate / tracesSampler.
|
||||
# - resourceAttributes below is ignored; set OTEL_SERVICE_NAME and
|
||||
# OTEL_RESOURCE_ATTRIBUTES in the environment if you need to override them.
|
||||
# - spans produced by Sentry integrations are exported to both Sentry and OTLP.
|
||||
# - sentry-trace / baggage propagation to outbound remote requests is disabled
|
||||
# by default while OTel is enabled. Set propagateTraceToRemote: true only if
|
||||
# you intentionally want Sentry trace propagation for your deployment.
|
||||
|
||||
#otelForBackend:
|
||||
# endpoint: 'http://localhost:4318/v1/traces'
|
||||
# #headers:
|
||||
# # authorization: 'Bearer xxxxx'
|
||||
# #sampleRate: 1.0
|
||||
# # Record PostgreSQL query spans. Disabled by default to avoid instrumentation
|
||||
# # overhead when database-level diagnostics are not needed.
|
||||
# #capturePgSpans: false
|
||||
# # Include raw SQL text in PostgreSQL spans. Requires capturePgSpans and is
|
||||
# # disabled by default because non-parameterized queries can expose values to
|
||||
# # the OTLP Collector.
|
||||
# #capturePgStatement: false
|
||||
# # Include PostgreSQL connection-pool spans with an existing parent span.
|
||||
# # Requires capturePgSpans and is disabled by default to avoid noisy spans
|
||||
# # from connection churn.
|
||||
# #capturePgConnectionSpans: false
|
||||
# # Record Redis command spans. Disabled by default because subscribing to the
|
||||
# # ioredis diagnostics channel adds work to every Redis command, including
|
||||
# # commands without a parent span. Enable for development diagnostics or when
|
||||
# # the added overhead is acceptable in production.
|
||||
# #captureRedisCommandSpans: false
|
||||
# # Include Redis startup/reconnect spans. Disabled by default to avoid noisy
|
||||
# # connection churn; these spans are recorded even without an HTTP or job
|
||||
# # parent span.
|
||||
# #captureRedisConnectionSpans: false
|
||||
# # Record Redis commands without an HTTP or job parent span. Disabled by
|
||||
# # default because queue polling and background work can produce many root
|
||||
# # traces; only applies when captureRedisCommandSpans is enabled.
|
||||
# #captureRedisRootSpans: false
|
||||
# #resourceAttributes:
|
||||
# # deployment.environment: 'production'
|
||||
# #propagateTraceToRemote: false
|
||||
# # Queue worker spans are independent traces linked to their enqueuer by default.
|
||||
# # Set to 'parent' to make them child spans instead. This can create very large
|
||||
# # traces for high-fan-out queues such as federation delivery.
|
||||
# #jobTraceContextMode: 'link'
|
||||
|
||||
#sentryForFrontend:
|
||||
# vueIntegration:
|
||||
# tracingOptions:
|
||||
@@ -474,7 +417,7 @@ proxyBypassHosts:
|
||||
|
||||
# Log settings
|
||||
# logging:
|
||||
# # ログの出力形式。「json」で1行JSON、未指定時は「pretty」です。
|
||||
# # Log output format. "json" outputs one-line JSON; defaults to "pretty".
|
||||
# format: pretty
|
||||
# # Minimum level for all loggers. Defaults to info in production and debug otherwise.
|
||||
# level: info
|
||||
@@ -484,6 +427,19 @@ proxyBypassHosts:
|
||||
# queue: error
|
||||
# queue.deliver: debug
|
||||
# db.sql: off
|
||||
# # HTTP status classes to record in the access log. No output when unspecified.
|
||||
# access:
|
||||
# statusClasses:
|
||||
# - '2xx'
|
||||
# - '3xx'
|
||||
# - '4xx'
|
||||
# - '5xx'
|
||||
# # Enable body logging explicitly only during development.
|
||||
# bodies:
|
||||
# request: false
|
||||
# response: false
|
||||
# # 16 KiB by default, up to 128 KiB.
|
||||
# maxBytes: 16384
|
||||
# sql:
|
||||
# # Outputs query parameters during SQL execution to the log.
|
||||
# # default: false
|
||||
|
||||
@@ -1,239 +0,0 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { createRequire } from 'node:module';
|
||||
import { copyFile, rm, writeFile } from 'node:fs/promises';
|
||||
import { join, resolve } from 'node:path';
|
||||
import * as util from './utility.mts';
|
||||
import * as heapSnapshotUtil from './heap-snapshot-util.mts';
|
||||
import type { MemorySample } from '../../packages/backend/scripts/measure-memory.mts';
|
||||
|
||||
const phases = ['afterGc'] as const;
|
||||
|
||||
export type MemoryReport = {
|
||||
timestamp: string;
|
||||
sampleCount: number;
|
||||
aggregation: string;
|
||||
summary: Record<typeof phases[number], {
|
||||
memoryUsage: Record<string, number>;
|
||||
heapSnapshot?: heapSnapshotUtil.HeapSnapshotData;
|
||||
}>;
|
||||
samples: (MemorySample & {
|
||||
round: number;
|
||||
})[];
|
||||
};
|
||||
|
||||
const [baseDirArg, headDirArg, baseOutputArg, headOutputArg] = process.argv.slice(2);
|
||||
|
||||
const HEAP_SNAPSHOT_BREAKDOWN_TOP_N = util.readIntegerEnv('MK_MEMORY_HEAP_SNAPSHOT_BREAKDOWN_TOP_N', heapSnapshotUtil.defaultHeapSnapshotBreakdownTopN, 1);
|
||||
const heapSnapshotLabels = ['base', 'head'] as const;
|
||||
const HEAP_SNAPSHOT_WORK_DIRS = {
|
||||
base: resolve('base-heap-snapshots'),
|
||||
head: resolve('head-heap-snapshots'),
|
||||
};
|
||||
const HEAP_SNAPSHOT_OUTPUT_PATHS = {
|
||||
base: resolve('base-heap-snapshot.heapsnapshot'),
|
||||
head: resolve('head-heap-snapshot.heapsnapshot'),
|
||||
};
|
||||
// Use the head checkout's measurement harness for both targets so only the built backend differs.
|
||||
const MEASURE_MEMORY_SCRIPT = resolve(import.meta.dirname, '../../packages/backend/scripts/measure-memory.mts');
|
||||
|
||||
async function resetState(repoDir: string) {
|
||||
const require = createRequire(join(repoDir, 'packages/backend/package.json'));
|
||||
const pg = require('pg');
|
||||
const Redis = require('ioredis');
|
||||
|
||||
const postgres = new pg.Client({
|
||||
host: '127.0.0.1',
|
||||
port: 54312,
|
||||
database: 'postgres',
|
||||
user: 'postgres',
|
||||
});
|
||||
|
||||
await postgres.connect();
|
||||
try {
|
||||
await postgres.query('DROP DATABASE IF EXISTS "test-misskey" WITH (FORCE)');
|
||||
await postgres.query('CREATE DATABASE "test-misskey"');
|
||||
} finally {
|
||||
await postgres.end();
|
||||
}
|
||||
|
||||
const redis = new Redis({ host: '127.0.0.1', port: 56312 });
|
||||
try {
|
||||
await redis.flushall();
|
||||
} finally {
|
||||
redis.disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
function summarizeSamples(samples: MemoryReport['samples']) {
|
||||
const summary = {} as MemoryReport['summary'];
|
||||
|
||||
for (const phase of phases) {
|
||||
summary[phase] = {
|
||||
memoryUsage: {},
|
||||
};
|
||||
|
||||
const metricKeys = new Set<string>();
|
||||
for (const sample of samples) {
|
||||
for (const key of Object.keys(sample.phases[phase].memoryUsage)) {
|
||||
metricKeys.add(key);
|
||||
}
|
||||
}
|
||||
|
||||
for (const key of metricKeys) {
|
||||
const values = samples.map(sample => sample.phases[phase].memoryUsage[key]);
|
||||
summary[phase].memoryUsage[key] = util.median(values);
|
||||
}
|
||||
|
||||
const heapSnapshot = heapSnapshotUtil.summarizeHeapSnapshotDataSamples(
|
||||
samples,
|
||||
sample => sample.phases[phase].heapSnapshot,
|
||||
{ breakdownTopN: HEAP_SNAPSHOT_BREAKDOWN_TOP_N },
|
||||
);
|
||||
if (heapSnapshot != null) summary[phase].heapSnapshot = heapSnapshot;
|
||||
}
|
||||
|
||||
return summary;
|
||||
}
|
||||
|
||||
async function genSample(label: string, repoDir: string, round: number, options: { heapSnapshotSavePath?: string } = {}) {
|
||||
process.stderr.write(`[${label}] Resetting database and Redis\n`);
|
||||
await resetState(repoDir);
|
||||
|
||||
process.stderr.write(`[${label}] Running migrations\n`);
|
||||
await util.run('pnpm', ['--filter', 'backend', 'migrate'], {
|
||||
cwd: repoDir,
|
||||
env: process.env,
|
||||
logStdout: true,
|
||||
});
|
||||
|
||||
process.stderr.write(`[${label}] Measuring memory\n`);
|
||||
const measureEnv = {
|
||||
...process.env,
|
||||
MK_MEMORY_BACKEND_DIR: resolve(repoDir, 'packages/backend'),
|
||||
} as NodeJS.ProcessEnv;
|
||||
if (round <= 0) measureEnv.MK_MEMORY_HEAP_SNAPSHOT = '0';
|
||||
if (options.heapSnapshotSavePath != null) measureEnv.MK_MEMORY_HEAP_SNAPSHOT_SAVE_PATH = options.heapSnapshotSavePath;
|
||||
|
||||
const stdout = await util.run('node', [MEASURE_MEMORY_SCRIPT], {
|
||||
cwd: repoDir,
|
||||
env: measureEnv,
|
||||
});
|
||||
|
||||
return JSON.parse(stdout) as MemorySample;
|
||||
}
|
||||
|
||||
function heapSnapshotPath(label: typeof heapSnapshotLabels[number], round: number) {
|
||||
return join(HEAP_SNAPSHOT_WORK_DIRS[label], `round-${round}.heapsnapshot`);
|
||||
}
|
||||
|
||||
function selectRepresentativeHeapSnapshotRound(samples: MemoryReport['samples'], summary: MemoryReport['summary']) {
|
||||
const medianTotal = summary.afterGc.heapSnapshot?.categories?.total;
|
||||
if (medianTotal == null || !Number.isFinite(medianTotal)) return null;
|
||||
|
||||
let selected: { round: number; distance: number } | null = null;
|
||||
for (const sample of samples) {
|
||||
const total = sample.phases.afterGc.heapSnapshot?.categories?.total;
|
||||
if (total == null || !Number.isFinite(total)) continue;
|
||||
|
||||
const distance = Math.abs(total - medianTotal);
|
||||
if (selected == null || distance < selected.distance || (distance === selected.distance && sample.round < selected.round)) {
|
||||
selected = {
|
||||
round: sample.round,
|
||||
distance,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return selected?.round ?? null;
|
||||
}
|
||||
|
||||
async function saveRepresentativeHeapSnapshot(label: typeof heapSnapshotLabels[number], samples: MemoryReport['samples'], summary: MemoryReport['summary']) {
|
||||
const round = selectRepresentativeHeapSnapshotRound(samples, summary);
|
||||
if (round == null) return;
|
||||
|
||||
await copyFile(heapSnapshotPath(label, round), HEAP_SNAPSHOT_OUTPUT_PATHS[label]);
|
||||
process.stderr.write(`Selected ${label} heap snapshot round ${round} for artifact\n`);
|
||||
await rm(HEAP_SNAPSHOT_WORK_DIRS[label], { recursive: true, force: true });
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const baseDir = resolve(baseDirArg);
|
||||
const headDir = resolve(headDirArg);
|
||||
const baseOutput = resolve(baseOutputArg);
|
||||
const headOutput = resolve(headOutputArg);
|
||||
const rounds = util.readIntegerEnv('MK_MEMORY_COMPARE_ROUNDS', 5, 1);
|
||||
const warmupRounds = util.readIntegerEnv('MK_MEMORY_COMPARE_WARMUP_ROUNDS', 1, 0);
|
||||
const startedAt = new Date().toISOString();
|
||||
|
||||
for (const label of heapSnapshotLabels) {
|
||||
await rm(HEAP_SNAPSHOT_WORK_DIRS[label], { recursive: true, force: true });
|
||||
await rm(HEAP_SNAPSHOT_OUTPUT_PATHS[label], { force: true });
|
||||
}
|
||||
|
||||
const reports = {
|
||||
base: {
|
||||
dir: baseDir,
|
||||
samples: [] as MemoryReport['samples'],
|
||||
},
|
||||
head: {
|
||||
dir: headDir,
|
||||
samples: [] as MemoryReport['samples'],
|
||||
},
|
||||
};
|
||||
|
||||
for (let round = 1; round <= warmupRounds; round++) {
|
||||
process.stderr.write(`Starting warmup round ${round}/${warmupRounds}\n`);
|
||||
for (const label of heapSnapshotLabels) {
|
||||
await genSample(label, reports[label].dir, -round);
|
||||
}
|
||||
}
|
||||
|
||||
for (let round = 1; round <= rounds; round++) {
|
||||
const order = round % 2 === 1 ? ['base', 'head'] as const : ['head', 'base'] as const;
|
||||
process.stderr.write(`Starting measurement round ${round}/${rounds}: ${order.join(' -> ')}\n`);
|
||||
|
||||
for (const label of order) {
|
||||
const options = { heapSnapshotSavePath: heapSnapshotPath(label, round) };
|
||||
const sample = await genSample(label, reports[label].dir, round, options);
|
||||
reports[label].samples.push({
|
||||
...sample,
|
||||
round,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const summaries = {
|
||||
base: summarizeSamples(reports.base.samples),
|
||||
head: summarizeSamples(reports.head.samples),
|
||||
};
|
||||
for (const label of heapSnapshotLabels) {
|
||||
await saveRepresentativeHeapSnapshot(label, reports[label].samples, summaries[label]);
|
||||
}
|
||||
|
||||
for (const label of heapSnapshotLabels) {
|
||||
const report = {
|
||||
timestamp: new Date().toISOString(),
|
||||
sampleCount: reports[label].samples.length,
|
||||
aggregation: 'median',
|
||||
comparison: {
|
||||
strategy: 'interleaved-pairs',
|
||||
rounds,
|
||||
warmupRounds,
|
||||
startedAt,
|
||||
},
|
||||
summary: summaries[label],
|
||||
samples: reports[label].samples,
|
||||
};
|
||||
|
||||
await writeFile(label === 'base' ? baseOutput : headOutput, `${JSON.stringify(report, null, 2)}\n`);
|
||||
}
|
||||
}
|
||||
|
||||
main().catch(err => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -1,183 +0,0 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { readFile, writeFile } from 'node:fs/promises';
|
||||
import * as util from './utility.mts';
|
||||
import * as heapSnapshotUtil from './heap-snapshot-util.mts';
|
||||
import type { MemoryReport } from './backend-diagnostics.inspect.mts';
|
||||
|
||||
const [baseFile, headFile, outputFile] = process.argv.slice(2);
|
||||
|
||||
const memoryReportPhases = [
|
||||
{
|
||||
key: 'afterGc',
|
||||
title: 'After GC',
|
||||
},
|
||||
] as const;
|
||||
|
||||
const memoryMetrics = [
|
||||
'HeapUsed',
|
||||
'Pss',
|
||||
'USS',
|
||||
'External',
|
||||
] as const;
|
||||
|
||||
function formatMemoryMb(valueKiB: number | null | undefined) {
|
||||
if (valueKiB == null) return '-';
|
||||
return `${util.formatNumber(valueKiB / 1000)} MB`;
|
||||
}
|
||||
|
||||
function formatMemoryMetricName(metric: typeof memoryMetrics[number]) {
|
||||
return metric === 'Pss' ? 'PSS' : metric;
|
||||
}
|
||||
|
||||
function getMemoryValueFromSample(sample: MemoryReport['samples'][number], phase: typeof memoryReportPhases[number]['key'], metric: typeof memoryMetrics[number]) {
|
||||
const memoryUsage = sample.phases[phase].memoryUsage;
|
||||
if (metric !== 'USS') return memoryUsage[metric];
|
||||
return memoryUsage.Private_Clean + memoryUsage.Private_Dirty;
|
||||
}
|
||||
|
||||
function getSampleValues(report: MemoryReport, phase: typeof memoryReportPhases[number]['key'], metric: typeof memoryMetrics[number]) {
|
||||
return report.samples.map(sample => getMemoryValueFromSample(sample, phase, metric));
|
||||
}
|
||||
|
||||
function getMemoryValue(report: MemoryReport, phase: typeof memoryReportPhases[number]['key'], metric: typeof memoryMetrics[number]) {
|
||||
if (metric !== 'USS') return report.summary[phase].memoryUsage[metric];
|
||||
return util.median(getSampleValues(report, phase, metric));
|
||||
}
|
||||
|
||||
function getSampleSpread(report: MemoryReport, phase: typeof memoryReportPhases[number]['key'], metric: typeof memoryMetrics[number]) {
|
||||
const values = getSampleValues(report, phase, metric);
|
||||
if (values.length < 2) return null;
|
||||
|
||||
const center = util.median(values);
|
||||
return util.median(values.map(value => Math.abs(value - center)));
|
||||
}
|
||||
|
||||
function renderMainTableForPhase(base: MemoryReport, head: MemoryReport, phase: typeof memoryReportPhases[number]['key']) {
|
||||
const lines = [
|
||||
'| Metric | Base | Head | Δ median | Δ MAD | Δ min | Δ max |',
|
||||
'| --- | ---: | ---: | ---: | ---: | ---: | ---: |',
|
||||
];
|
||||
|
||||
function formatDeltaMemory(deltaKiB: number) {
|
||||
return util.formatColoredDelta(deltaKiB, v => formatMemoryMb(v), 100); // 0.1 MB threshold
|
||||
}
|
||||
|
||||
for (const metric of memoryMetrics) {
|
||||
const baseValue = getMemoryValue(base, phase, metric);
|
||||
const headValue = getMemoryValue(head, phase, metric);
|
||||
|
||||
const baseSpread = getSampleSpread(base, phase, metric);
|
||||
const headSpread = getSampleSpread(head, phase, metric);
|
||||
const summary = util.pairedDeltaSummary(base.samples, head.samples, (sample) => getMemoryValueFromSample(sample, phase, metric));
|
||||
const percent = summary.median * 100 / baseValue;
|
||||
const deltaMedian = `${formatDeltaMemory(summary.median)}<br>${util.formatDeltaPercent(percent, 0.1).replaceAll('\\%', '\\\\%')}`;
|
||||
|
||||
lines.push(`| **${formatMemoryMetricName(metric)}** | ${formatMemoryMb(baseValue)} <br> ± ${formatMemoryMb(baseSpread)} | ${formatMemoryMb(headValue)} <br> ± ${formatMemoryMb(headSpread)} | ${deltaMedian} | ${formatMemoryMb(summary.mad)} | ${formatDeltaMemory(summary.min)} | ${formatDeltaMemory(summary.max)} |`);
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function renderHeapSnapshotSection(base: MemoryReport, head: MemoryReport) {
|
||||
const baseHeapSnapshotReport = {
|
||||
summary: base.summary.afterGc.heapSnapshot!,
|
||||
samples: base.samples.map(sample => ({
|
||||
round: sample.round,
|
||||
data: sample.phases.afterGc.heapSnapshot!,
|
||||
})),
|
||||
};
|
||||
|
||||
const headHeapSnapshotReport = {
|
||||
summary: head.summary.afterGc.heapSnapshot!,
|
||||
samples: head.samples.map(sample => ({
|
||||
round: sample.round,
|
||||
data: sample.phases.afterGc.heapSnapshot!,
|
||||
})),
|
||||
};
|
||||
|
||||
const table = heapSnapshotUtil.renderHeapSnapshotTable(baseHeapSnapshotReport, headHeapSnapshotReport);
|
||||
if (table == null) return null;
|
||||
|
||||
const lines = [
|
||||
'### V8 Heap Snapshot Statistics',
|
||||
'',
|
||||
table,
|
||||
'',
|
||||
];
|
||||
|
||||
for (const graph of [
|
||||
//heapSnapshotUtil.renderHeapSnapshotSankey(baseHeapSnapshotReport, 'Base'),
|
||||
//heapSnapshotUtil.renderHeapSnapshotSankey(headHeapSnapshotReport, 'Head'),
|
||||
]) {
|
||||
if (graph == null) continue;
|
||||
lines.push(graph);
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
const base = JSON.parse(await readFile(baseFile, 'utf8')) as MemoryReport;
|
||||
const head = JSON.parse(await readFile(headFile, 'utf8')) as MemoryReport;
|
||||
|
||||
const lines = [
|
||||
'## ⚙️ Backend Diagnostics Report',
|
||||
'',
|
||||
];
|
||||
|
||||
//const summary = measurementSummary(base, head);
|
||||
//if (summary != null) {
|
||||
// lines.push(summary);
|
||||
// lines.push('');
|
||||
//}
|
||||
|
||||
for (const phase of memoryReportPhases) {
|
||||
lines.push(`### Memory: ${phase.title}`);
|
||||
lines.push(renderMainTableForPhase(base, head, phase.key));
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
const heapSnapshotSection = renderHeapSnapshotSection(base, head);
|
||||
if (heapSnapshotSection != null) {
|
||||
lines.push(heapSnapshotSection);
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
const baseHeapSnapshotArtifactUrl = process.env.MK_MEMORY_HEAP_SNAPSHOT_ARTIFACT_URL_BASE!.trim();
|
||||
const headHeapSnapshotArtifactUrl = process.env.MK_MEMORY_HEAP_SNAPSHOT_ARTIFACT_URL_HEAD!.trim();
|
||||
lines.push(`Download representative heap snapshot: [base](${baseHeapSnapshotArtifactUrl}) / [head](${headHeapSnapshotArtifactUrl})`);
|
||||
lines.push('');
|
||||
|
||||
function getDiffPercent(base: MemoryReport, head: MemoryReport, phase: typeof memoryReportPhases[number]['key'], metric: typeof memoryMetrics[number]) {
|
||||
const baseValue = getMemoryValue(base, phase, metric);
|
||||
const headValue = getMemoryValue(head, phase, metric);
|
||||
return ((headValue - baseValue) * 100) / baseValue;
|
||||
}
|
||||
|
||||
function isBeyondSampleNoise(base: MemoryReport, head: MemoryReport, phase: typeof memoryReportPhases[number]['key'], metric: typeof memoryMetrics[number]) {
|
||||
const baseValue = getMemoryValue(base, phase, metric);
|
||||
const headValue = getMemoryValue(head, phase, metric);
|
||||
|
||||
const delta = headValue - baseValue;
|
||||
if (delta <= 0) return false;
|
||||
|
||||
const baseSpread = getSampleSpread(base, phase, metric);
|
||||
const headSpread = getSampleSpread(head, phase, metric);
|
||||
if (baseSpread == null || headSpread == null) return true;
|
||||
|
||||
const combinedSpread = Math.hypot(baseSpread, headSpread);
|
||||
return delta > combinedSpread * 3;
|
||||
}
|
||||
|
||||
const warningMetric = 'Pss';
|
||||
const warningDiffPercent = getDiffPercent(base, head, 'afterGc', warningMetric);
|
||||
if (warningDiffPercent > 5 && isBeyondSampleNoise(base, head, 'afterGc', warningMetric)) {
|
||||
lines.push(`⚠️ **Warning**: Memory usage (${formatMemoryMetricName(warningMetric)}) has increased by more than 5% and exceeds the observed sample noise. Please verify this is not an unintended change.`);
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
await writeFile(outputFile, `${lines.join('\n')}\n`);
|
||||
@@ -1,570 +0,0 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { createRequire } from 'node:module';
|
||||
import { writeFile } from 'node:fs/promises';
|
||||
import type { Browser, BrowserContext, CDPSession, Page } from 'playwright';
|
||||
import type { HeapSnapshotData } from './heap-snapshot-util.mts';
|
||||
import * as util from './utility.mts';
|
||||
|
||||
export type NetworkRequest = {
|
||||
requestId: string;
|
||||
url: string;
|
||||
method: string;
|
||||
resourceType: string;
|
||||
startedAt: number;
|
||||
documentUrl?: string;
|
||||
requestHeaders?: Record<string, string>;
|
||||
requestBody?: string;
|
||||
hasRequestBody: boolean;
|
||||
status?: number;
|
||||
statusText?: string;
|
||||
mimeType?: string;
|
||||
responseHeaders?: Record<string, string>;
|
||||
protocol?: string;
|
||||
remoteIPAddress?: string;
|
||||
remotePort?: number;
|
||||
encodedDataLength: number;
|
||||
decodedBodyLength: number;
|
||||
fromDiskCache: boolean;
|
||||
fromServiceWorker: boolean;
|
||||
finished: boolean;
|
||||
failed: boolean;
|
||||
errorText?: string;
|
||||
};
|
||||
|
||||
export type WebSocketConnection = {
|
||||
requestId: string;
|
||||
url: string;
|
||||
createdAt: number;
|
||||
handshakeRequestHeaders?: Record<string, string>;
|
||||
handshakeResponseStatus?: number;
|
||||
handshakeResponseStatusText?: string;
|
||||
handshakeResponseHeaders?: Record<string, string>;
|
||||
closedAt?: number;
|
||||
sentFrameCount: number;
|
||||
receivedFrameCount: number;
|
||||
sentBytes: number;
|
||||
receivedBytes: number;
|
||||
errorCount: number;
|
||||
};
|
||||
|
||||
export type NetworkSummary = {
|
||||
requestCount: number;
|
||||
webSocketConnectionCount: number;
|
||||
webSocketSentBytes: number;
|
||||
webSocketReceivedBytes: number;
|
||||
finishedRequestCount: number;
|
||||
failedRequestCount: number;
|
||||
cachedRequestCount: number;
|
||||
serviceWorkerRequestCount: number;
|
||||
totalEncodedBytes: number;
|
||||
totalDecodedBodyBytes: number;
|
||||
sameOriginEncodedBytes: number;
|
||||
thirdPartyEncodedBytes: number;
|
||||
byResourceType: Record<string, {
|
||||
requests: number;
|
||||
encodedBytes: number;
|
||||
decodedBodyBytes: number;
|
||||
}>;
|
||||
largestRequests: {
|
||||
url: string;
|
||||
method: string;
|
||||
resourceType: string;
|
||||
status?: number;
|
||||
encodedBytes: number;
|
||||
decodedBodyBytes: number;
|
||||
}[];
|
||||
failedRequests: {
|
||||
url: string;
|
||||
method: string;
|
||||
resourceType: string;
|
||||
errorText?: string;
|
||||
status?: number;
|
||||
}[];
|
||||
};
|
||||
|
||||
export type TabMemory = {
|
||||
totalBytes: number;
|
||||
};
|
||||
|
||||
export type BrowserDiagnostics = {
|
||||
pageErrorCount: number;
|
||||
console: Record<'log' | 'warning' | 'error' | 'info', number>;
|
||||
};
|
||||
|
||||
export function summarizeBrowserDiagnostics(samples: BrowserDiagnostics[]): BrowserDiagnostics {
|
||||
const median = (select: (sample: BrowserDiagnostics) => number) => util.median(samples.map(select));
|
||||
|
||||
return {
|
||||
pageErrorCount: median(sample => sample.pageErrorCount),
|
||||
console: {
|
||||
log: median(sample => sample.console.log),
|
||||
warning: median(sample => sample.console.warning),
|
||||
error: median(sample => sample.console.error),
|
||||
info: median(sample => sample.console.info),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export type BrowserMeasurement = {
|
||||
label: string;
|
||||
timestamp: string;
|
||||
url: string;
|
||||
scenario: string;
|
||||
diagnostics: BrowserDiagnostics;
|
||||
durationMs: number;
|
||||
network: NetworkSummary;
|
||||
performance: {
|
||||
cdpMetrics: Record<string, number>;
|
||||
runtimeHeap?: {
|
||||
usedSize: number;
|
||||
totalSize: number;
|
||||
};
|
||||
tabMemory: TabMemory;
|
||||
webVitals: {
|
||||
firstPaintMs?: number;
|
||||
firstContentfulPaintMs?: number;
|
||||
domContentLoadedEventEndMs?: number;
|
||||
loadEventEndMs?: number;
|
||||
longTaskCount: number;
|
||||
longTaskDurationMs: number;
|
||||
maxLongTaskDurationMs: number;
|
||||
resourceEntryCount: number;
|
||||
domElements: number;
|
||||
};
|
||||
};
|
||||
heapSnapshot: HeapSnapshotData;
|
||||
};
|
||||
|
||||
type PlaywrightModule = typeof import('playwright');
|
||||
|
||||
const requireFromFrontend = createRequire(new URL('../../packages/frontend/package.json', import.meta.url));
|
||||
|
||||
function loadPlaywright(): PlaywrightModule {
|
||||
return requireFromFrontend('playwright') as PlaywrightModule;
|
||||
}
|
||||
|
||||
function normalizeHeaders(headers: Record<string, unknown> | undefined) {
|
||||
if (headers == null) return undefined;
|
||||
const normalized = {} as Record<string, string>;
|
||||
for (const [key, value] of Object.entries(headers)) {
|
||||
normalized[key] = String(value);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function webSocketFramePayloadBytes(frame: { opcode?: number; payloadData?: string } | undefined) {
|
||||
if (frame?.payloadData == null) return 0;
|
||||
if (frame.opcode === 1) return Buffer.byteLength(frame.payloadData, 'utf8');
|
||||
return Buffer.byteLength(frame.payloadData, 'base64');
|
||||
}
|
||||
|
||||
type PlaywrightBrowserOptions = {
|
||||
scenarioTimeoutMs: number;
|
||||
baseUrl: string;
|
||||
};
|
||||
|
||||
export class HeadlessChromeController {
|
||||
public networkRequests: NetworkRequest[] = [];
|
||||
public webSocketConnections: WebSocketConnection[] = [];
|
||||
private readonly diagnostics = {
|
||||
pageErrorCount: 0,
|
||||
console: {} as Record<string, number | undefined>,
|
||||
};
|
||||
private readonly browser: Browser;
|
||||
private readonly context: BrowserContext;
|
||||
public readonly page: Page;
|
||||
private readonly cdp: CDPSession;
|
||||
private pendingNetworkDetailReads: Promise<void>[] = [];
|
||||
|
||||
private constructor(
|
||||
browser: Browser,
|
||||
context: BrowserContext,
|
||||
page: Page,
|
||||
cdp: CDPSession,
|
||||
options: PlaywrightBrowserOptions,
|
||||
) {
|
||||
this.browser = browser;
|
||||
this.context = context;
|
||||
this.page = page;
|
||||
this.cdp = cdp;
|
||||
this.page.setDefaultTimeout(options.scenarioTimeoutMs);
|
||||
this.page.setDefaultNavigationTimeout(options.scenarioTimeoutMs);
|
||||
this.page.on('pageerror', () => {
|
||||
this.diagnostics.pageErrorCount++;
|
||||
});
|
||||
this.page.on('console', message => {
|
||||
if (this.diagnostics.console[message.type()] == null) {
|
||||
this.diagnostics.console[message.type()] = 1;
|
||||
} else {
|
||||
this.diagnostics.console[message.type()]++;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
static async create(label: string, options: PlaywrightBrowserOptions): Promise<HeadlessChromeController> {
|
||||
process.stderr.write(`[${label}] Launching Playwright Chromium\n`);
|
||||
const { chromium } = loadPlaywright();
|
||||
const browser = await chromium.launch({
|
||||
channel: 'chromium',
|
||||
headless: true,
|
||||
args: [
|
||||
'--disable-gpu',
|
||||
'--disable-dev-shm-usage',
|
||||
'--disable-background-networking',
|
||||
'--disable-default-apps',
|
||||
'--disable-extensions',
|
||||
'--disable-sync',
|
||||
'--metrics-recording-only',
|
||||
'--no-first-run',
|
||||
'--no-default-browser-check',
|
||||
'--no-sandbox',
|
||||
],
|
||||
});
|
||||
|
||||
try {
|
||||
const context = await browser.newContext({
|
||||
baseURL: options.baseUrl,
|
||||
locale: 'en-US',
|
||||
});
|
||||
await context.addInitScript(() => {
|
||||
// @ts-expect-error Test-only runtime hint consumed by Misskey frontend code.
|
||||
window.isPlaywright = true;
|
||||
});
|
||||
|
||||
const page = await context.newPage();
|
||||
const cdp = await context.newCDPSession(page);
|
||||
return new HeadlessChromeController(browser, context, page, cdp, options);
|
||||
} catch (error) {
|
||||
await browser.close().catch(() => undefined);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
static async with<T>(label: string, options: PlaywrightBrowserOptions, callback: (browser: HeadlessChromeController) => T | Promise<T>): Promise<T> {
|
||||
const browser = await HeadlessChromeController.create(label, options);
|
||||
try {
|
||||
return await callback(browser);
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
}
|
||||
|
||||
public async enableNetworkTracking() {
|
||||
const requests = new Map<string, NetworkRequest>();
|
||||
const webSockets = new Map<string, WebSocketConnection>();
|
||||
|
||||
const readRequestBody = (row: NetworkRequest) => {
|
||||
if (!row.hasRequestBody || row.requestBody != null) return;
|
||||
const pending = this.cdp.send<{ postData: string }>('Network.getRequestPostData', {
|
||||
requestId: row.requestId,
|
||||
}).then(result => {
|
||||
row.requestBody = result.postData;
|
||||
}).catch(() => {
|
||||
// Some requests expose hasPostData but no longer have retrievable body data.
|
||||
});
|
||||
this.pendingNetworkDetailReads.push(pending);
|
||||
};
|
||||
|
||||
this.cdp.on('Network.requestWillBeSent', params => {
|
||||
if (params.request?.url == null) return;
|
||||
const row: NetworkRequest = {
|
||||
requestId: params.requestId,
|
||||
url: params.request.url,
|
||||
method: params.request.method ?? 'GET',
|
||||
resourceType: params.type ?? 'Other',
|
||||
startedAt: params.timestamp ?? 0,
|
||||
documentUrl: params.documentURL,
|
||||
requestHeaders: normalizeHeaders(params.request.headers),
|
||||
requestBody: typeof params.request.postData === 'string' ? params.request.postData : undefined,
|
||||
hasRequestBody: params.request.hasPostData === true || typeof params.request.postData === 'string',
|
||||
encodedDataLength: 0,
|
||||
decodedBodyLength: 0,
|
||||
fromDiskCache: false,
|
||||
fromServiceWorker: false,
|
||||
finished: false,
|
||||
failed: false,
|
||||
};
|
||||
requests.set(params.requestId, row);
|
||||
this.networkRequests.push(row);
|
||||
});
|
||||
|
||||
this.cdp.on('Network.webSocketCreated', params => {
|
||||
if (params.requestId == null || params.url == null) return;
|
||||
const row: WebSocketConnection = {
|
||||
requestId: params.requestId,
|
||||
url: params.url,
|
||||
createdAt: params.timestamp ?? 0,
|
||||
sentFrameCount: 0,
|
||||
receivedFrameCount: 0,
|
||||
sentBytes: 0,
|
||||
receivedBytes: 0,
|
||||
errorCount: 0,
|
||||
};
|
||||
webSockets.set(params.requestId, row);
|
||||
this.webSocketConnections.push(row);
|
||||
});
|
||||
|
||||
this.cdp.on('Network.webSocketWillSendHandshakeRequest', params => {
|
||||
const row = webSockets.get(params.requestId);
|
||||
if (row == null) return;
|
||||
row.handshakeRequestHeaders = normalizeHeaders(params.request?.headers);
|
||||
});
|
||||
|
||||
this.cdp.on('Network.webSocketHandshakeResponseReceived', params => {
|
||||
const row = webSockets.get(params.requestId);
|
||||
if (row == null) return;
|
||||
row.handshakeResponseStatus = params.response?.status;
|
||||
row.handshakeResponseStatusText = params.response?.statusText;
|
||||
row.handshakeResponseHeaders = normalizeHeaders(params.response?.headers);
|
||||
});
|
||||
|
||||
this.cdp.on('Network.webSocketFrameSent', params => {
|
||||
const row = webSockets.get(params.requestId);
|
||||
if (row == null) return;
|
||||
row.sentFrameCount += 1;
|
||||
row.sentBytes += webSocketFramePayloadBytes(params.response);
|
||||
});
|
||||
|
||||
this.cdp.on('Network.webSocketFrameReceived', params => {
|
||||
const row = webSockets.get(params.requestId);
|
||||
if (row == null) return;
|
||||
row.receivedFrameCount += 1;
|
||||
row.receivedBytes += webSocketFramePayloadBytes(params.response);
|
||||
});
|
||||
|
||||
this.cdp.on('Network.webSocketFrameError', params => {
|
||||
const row = webSockets.get(params.requestId);
|
||||
if (row == null) return;
|
||||
row.errorCount += 1;
|
||||
});
|
||||
|
||||
this.cdp.on('Network.webSocketClosed', params => {
|
||||
const row = webSockets.get(params.requestId);
|
||||
if (row == null) return;
|
||||
row.closedAt = params.timestamp ?? 0;
|
||||
});
|
||||
|
||||
this.cdp.on('Network.responseReceived', params => {
|
||||
const row = requests.get(params.requestId);
|
||||
if (row == null) return;
|
||||
row.status = params.response?.status;
|
||||
row.statusText = params.response?.statusText;
|
||||
row.mimeType = params.response?.mimeType;
|
||||
row.responseHeaders = normalizeHeaders(params.response?.headers);
|
||||
row.protocol = params.response?.protocol;
|
||||
row.remoteIPAddress = params.response?.remoteIPAddress;
|
||||
row.remotePort = params.response?.remotePort;
|
||||
row.requestHeaders ??= normalizeHeaders(params.response?.requestHeaders);
|
||||
row.fromDiskCache = params.response?.fromDiskCache === true;
|
||||
row.fromServiceWorker = params.response?.fromServiceWorker === true;
|
||||
});
|
||||
|
||||
this.cdp.on('Network.dataReceived', params => {
|
||||
const row = requests.get(params.requestId);
|
||||
if (row == null) return;
|
||||
row.decodedBodyLength += params.dataLength ?? 0;
|
||||
row.encodedDataLength += params.encodedDataLength ?? 0;
|
||||
});
|
||||
|
||||
this.cdp.on('Network.loadingFinished', params => {
|
||||
const row = requests.get(params.requestId);
|
||||
if (row == null) return;
|
||||
row.finished = true;
|
||||
row.encodedDataLength = Math.max(row.encodedDataLength, params.encodedDataLength ?? 0);
|
||||
readRequestBody(row);
|
||||
});
|
||||
|
||||
this.cdp.on('Network.loadingFailed', params => {
|
||||
const row = requests.get(params.requestId);
|
||||
if (row == null) return;
|
||||
row.failed = true;
|
||||
row.finished = true;
|
||||
row.errorText = params.errorText;
|
||||
readRequestBody(row);
|
||||
});
|
||||
|
||||
await this.cdp.send('Network.enable');
|
||||
await this.cdp.send('Network.setCacheDisabled', { cacheDisabled: true });
|
||||
await this.cdp.send('Network.setBypassServiceWorker', { bypass: true });
|
||||
await this.cdp.send('Page.enable');
|
||||
await this.cdp.send('Runtime.enable');
|
||||
await this.cdp.send('Performance.enable');
|
||||
}
|
||||
|
||||
public async waitForNetworkDetails() {
|
||||
let settledCount = 0;
|
||||
while (settledCount < this.pendingNetworkDetailReads.length) {
|
||||
const pending = this.pendingNetworkDetailReads.slice(settledCount);
|
||||
settledCount = this.pendingNetworkDetailReads.length;
|
||||
await Promise.allSettled(pending);
|
||||
}
|
||||
}
|
||||
|
||||
public async evaluate<T>(expression: string, timeoutMs = 30_000): Promise<T> {
|
||||
return await Promise.race([
|
||||
this.page.evaluate(expression),
|
||||
new Promise<never>((_, reject) => setTimeout(() => reject(new Error(`Playwright evaluate timed out after ${timeoutMs}ms`)), timeoutMs).unref()),
|
||||
]) as T;
|
||||
}
|
||||
|
||||
public collectDiagnostics(): BrowserDiagnostics {
|
||||
return {
|
||||
pageErrorCount: this.diagnostics.pageErrorCount,
|
||||
console: {
|
||||
log: this.diagnostics.console.log ?? 0,
|
||||
warning: this.diagnostics.console.warning ?? 0,
|
||||
error: this.diagnostics.console.error ?? 0,
|
||||
info: this.diagnostics.console.info ?? 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
public async collectPerformance(): Promise<BrowserMeasurement['performance']> {
|
||||
const cdpMetricsResult = await this.cdp.send<{ metrics: { name: string; value: number }[] }>('Performance.getMetrics');
|
||||
const cdpMetrics = Object.fromEntries(cdpMetricsResult.metrics.map(metric => [metric.name, metric.value]));
|
||||
const runtimeHeap = await this.cdp.send<{ usedSize: number; totalSize: number }>('Runtime.getHeapUsage').catch(() => undefined);
|
||||
const tabMemory = await this.collectTabMemory();
|
||||
const webVitals = await this.evaluate<BrowserMeasurement['performance']['webVitals']>(`(() => {
|
||||
const navigation = performance.getEntriesByType('navigation')[0];
|
||||
const paintEntries = Object.fromEntries(performance.getEntriesByType('paint').map(entry => [entry.name, entry.startTime]));
|
||||
const longTasks = performance.getEntriesByType('longtask');
|
||||
const resourceEntries = performance.getEntriesByType('resource');
|
||||
return {
|
||||
firstPaintMs: paintEntries['first-paint'],
|
||||
firstContentfulPaintMs: paintEntries['first-contentful-paint'],
|
||||
domContentLoadedEventEndMs: navigation?.domContentLoadedEventEnd,
|
||||
loadEventEndMs: navigation?.loadEventEnd,
|
||||
longTaskCount: longTasks.length,
|
||||
longTaskDurationMs: longTasks.reduce((sum, entry) => sum + entry.duration, 0),
|
||||
maxLongTaskDurationMs: longTasks.reduce((max, entry) => Math.max(max, entry.duration), 0),
|
||||
resourceEntryCount: resourceEntries.length,
|
||||
domElements: document.getElementsByTagName('*').length,
|
||||
};
|
||||
})()`);
|
||||
|
||||
return {
|
||||
cdpMetrics,
|
||||
runtimeHeap,
|
||||
tabMemory,
|
||||
webVitals,
|
||||
};
|
||||
}
|
||||
|
||||
public async collectTabMemory(): Promise<TabMemory> {
|
||||
const userAgentSpecificMemory = await this.evaluate<{ bytes?: number }>(`(async () => {
|
||||
const measureMemory = performance.measureUserAgentSpecificMemory;
|
||||
if (typeof measureMemory !== 'function') return {};
|
||||
const result = await measureMemory.call(performance);
|
||||
return { bytes: result.bytes };
|
||||
})()`, 60_000);
|
||||
|
||||
const userAgentSpecificBytes = userAgentSpecificMemory?.bytes;
|
||||
if (!Number.isFinite(userAgentSpecificBytes)) {
|
||||
throw new Error('performance.measureUserAgentSpecificMemory() did not return finite bytes');
|
||||
}
|
||||
|
||||
return {
|
||||
totalBytes: userAgentSpecificBytes as number,
|
||||
};
|
||||
}
|
||||
|
||||
public async takeHeapSnapshot(savePath?: string) {
|
||||
const chunks: string[] = [];
|
||||
this.cdp.on('HeapProfiler.addHeapSnapshotChunk', params => {
|
||||
chunks.push(params.chunk);
|
||||
});
|
||||
|
||||
await this.cdp.send('HeapProfiler.enable');
|
||||
await this.cdp.send('HeapProfiler.collectGarbage');
|
||||
await this.cdp.send('HeapProfiler.takeHeapSnapshot', { reportProgress: false });
|
||||
|
||||
const content = chunks.join('');
|
||||
if (savePath != null) {
|
||||
await writeFile(savePath, content);
|
||||
}
|
||||
|
||||
return JSON.parse(content);
|
||||
}
|
||||
|
||||
public async close() {
|
||||
await this.cdp.detach().catch(() => undefined);
|
||||
await this.context.close().catch(() => undefined);
|
||||
await this.browser.close().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
function isMeasurableRequest(row: NetworkRequest) {
|
||||
return !row.url.startsWith('data:') && !row.url.startsWith('blob:') && !row.url.startsWith('devtools:');
|
||||
}
|
||||
|
||||
export function summarizeNetwork(requestRows: NetworkRequest[], baseUrl: string, webSocketRows?: WebSocketConnection[]): NetworkSummary {
|
||||
const origin = new URL(baseUrl).origin;
|
||||
const rows = requestRows.filter(isMeasurableRequest);
|
||||
const byResourceType = {} as NetworkSummary['byResourceType'];
|
||||
|
||||
for (const row of rows) {
|
||||
const summary = byResourceType[row.resourceType] ?? {
|
||||
requests: 0,
|
||||
encodedBytes: 0,
|
||||
decodedBodyBytes: 0,
|
||||
};
|
||||
summary.requests += 1;
|
||||
summary.encodedBytes += row.encodedDataLength;
|
||||
summary.decodedBodyBytes += row.decodedBodyLength;
|
||||
byResourceType[row.resourceType] = summary;
|
||||
}
|
||||
|
||||
function isSameOrigin(url: string) {
|
||||
try {
|
||||
return new URL(url).origin === origin;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
requestCount: rows.length,
|
||||
webSocketConnectionCount: webSocketRows == null
|
||||
? rows.filter(row => row.resourceType === 'WebSocket').length
|
||||
: webSocketRows.length,
|
||||
webSocketSentBytes: webSocketRows?.reduce((sum, row) => sum + row.sentBytes, 0) ?? 0,
|
||||
webSocketReceivedBytes: webSocketRows?.reduce((sum, row) => sum + row.receivedBytes, 0) ?? 0,
|
||||
finishedRequestCount: rows.filter(row => row.finished).length,
|
||||
failedRequestCount: rows.filter(row => row.failed).length,
|
||||
cachedRequestCount: rows.filter(row => row.fromDiskCache).length,
|
||||
serviceWorkerRequestCount: rows.filter(row => row.fromServiceWorker).length,
|
||||
totalEncodedBytes: rows.reduce((sum, row) => sum + row.encodedDataLength, 0),
|
||||
totalDecodedBodyBytes: rows.reduce((sum, row) => sum + row.decodedBodyLength, 0),
|
||||
sameOriginEncodedBytes: rows
|
||||
.filter(row => isSameOrigin(row.url))
|
||||
.reduce((sum, row) => sum + row.encodedDataLength, 0),
|
||||
thirdPartyEncodedBytes: rows
|
||||
.filter(row => !isSameOrigin(row.url))
|
||||
.reduce((sum, row) => sum + row.encodedDataLength, 0),
|
||||
byResourceType,
|
||||
largestRequests: rows
|
||||
.toSorted((a, b) => b.encodedDataLength - a.encodedDataLength)
|
||||
.slice(0, 15)
|
||||
.map(row => ({
|
||||
url: row.url,
|
||||
method: row.method,
|
||||
resourceType: row.resourceType,
|
||||
status: row.status,
|
||||
encodedBytes: row.encodedDataLength,
|
||||
decodedBodyBytes: row.decodedBodyLength,
|
||||
})),
|
||||
failedRequests: rows
|
||||
.filter(row => row.failed)
|
||||
.map(row => ({
|
||||
url: row.url,
|
||||
method: row.method,
|
||||
resourceType: row.resourceType,
|
||||
errorText: row.errorText,
|
||||
status: row.status,
|
||||
})),
|
||||
};
|
||||
}
|
||||
@@ -1,279 +0,0 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { copyFile, mkdir, rm, writeFile } from 'node:fs/promises';
|
||||
import { join, resolve } from 'node:path';
|
||||
import * as util from './utility.mts';
|
||||
import * as heapSnapshotUtil from './heap-snapshot-util.mts';
|
||||
import { HeadlessChromeController, summarizeBrowserDiagnostics, summarizeNetwork } from './chrome.mts';
|
||||
import type { BrowserMeasurement, NetworkRequest, NetworkSummary } from './chrome.mts';
|
||||
import { closeUserSetupDialog, postNote, signupThroughUi, visitHome } from '../../packages/frontend/test/e2e/shared.ts';
|
||||
|
||||
const [baseDirArg, headDirArg, baseOutputArg, headOutputArg] = process.argv.slice(2);
|
||||
|
||||
const baseUrl = process.env.FRONTEND_BROWSER_METRICS_URL ?? 'http://127.0.0.1:61812';
|
||||
const sampleCount = util.readIntegerEnv('FRONTEND_BROWSER_METRICS_SAMPLE_COUNT', 5, 1);
|
||||
const heapSnapshotBreakdownTopN = util.readIntegerEnv('FRONTEND_BROWSER_HEAP_SNAPSHOT_BREAKDOWN_TOP_N', heapSnapshotUtil.defaultHeapSnapshotBreakdownTopN, 1);
|
||||
const heapSnapshotWorkDirs = {
|
||||
base: resolve('frontend-browser-base-heap-snapshots'),
|
||||
head: resolve('frontend-browser-head-heap-snapshots'),
|
||||
} as const;
|
||||
const heapSnapshotOutputPaths = {
|
||||
base: resolve('base-heap-snapshot.heapsnapshot'),
|
||||
head: resolve('head-heap-snapshot.heapsnapshot'),
|
||||
} as const;
|
||||
|
||||
type BrowserMeasurementSample = BrowserMeasurement & {
|
||||
round: number;
|
||||
networkRequests: NetworkRequest[];
|
||||
};
|
||||
|
||||
type BrowserMetricsReport = {
|
||||
label: string;
|
||||
timestamp: string;
|
||||
url: string;
|
||||
scenario: string;
|
||||
sampleCount: number;
|
||||
aggregation: 'median';
|
||||
summary: BrowserMeasurement;
|
||||
samples: BrowserMeasurementSample[];
|
||||
};
|
||||
|
||||
async function runSignupAndPostScenario(chrome: HeadlessChromeController) {
|
||||
const page = chrome.page;
|
||||
const noteText = `Frontend browser metrics ${Date.now()}`;
|
||||
|
||||
await visitHome(page, baseUrl);
|
||||
await signupThroughUi(page, { username: 'alice', password: 'password' });
|
||||
await closeUserSetupDialog(page);
|
||||
await postNote(page, noteText, 10_000);
|
||||
|
||||
await util.sleep(1000);
|
||||
}
|
||||
|
||||
function finiteMedian(values: (number | null | undefined)[], defaultValue = 0) {
|
||||
const finiteValues = values.filter(value => Number.isFinite(value)) as number[];
|
||||
if (finiteValues.length === 0) return defaultValue;
|
||||
return util.median(finiteValues);
|
||||
}
|
||||
|
||||
function selectRepresentativeSample(samples: BrowserMeasurementSample[], getValue: (sample: BrowserMeasurementSample) => number) {
|
||||
const medianValue = finiteMedian(samples.map(getValue));
|
||||
let selected: { sample: BrowserMeasurementSample; distance: number } | null = null;
|
||||
|
||||
for (const sample of samples) {
|
||||
const value = getValue(sample);
|
||||
if (!Number.isFinite(value)) continue;
|
||||
const distance = Math.abs(value - medianValue);
|
||||
if (selected == null || distance < selected.distance || (distance === selected.distance && sample.round < selected.sample.round)) {
|
||||
selected = {
|
||||
sample,
|
||||
distance,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return selected?.sample ?? samples[0];
|
||||
}
|
||||
|
||||
function summarizeResourceType(samples: BrowserMeasurementSample[], resourceType: string) {
|
||||
return {
|
||||
requests: finiteMedian(samples.map(sample => sample.network.byResourceType[resourceType]?.requests)),
|
||||
encodedBytes: finiteMedian(samples.map(sample => sample.network.byResourceType[resourceType]?.encodedBytes)),
|
||||
decodedBodyBytes: finiteMedian(samples.map(sample => sample.network.byResourceType[resourceType]?.decodedBodyBytes)),
|
||||
};
|
||||
}
|
||||
|
||||
function summarizeNetworkSamples(samples: BrowserMeasurementSample[]): NetworkSummary {
|
||||
const resourceTypes = new Set<string>();
|
||||
for (const sample of samples) {
|
||||
for (const resourceType of Object.keys(sample.network.byResourceType)) {
|
||||
resourceTypes.add(resourceType);
|
||||
}
|
||||
}
|
||||
|
||||
const representative = selectRepresentativeSample(samples, sample => sample.network.totalEncodedBytes);
|
||||
const byResourceType = {} as NetworkSummary['byResourceType'];
|
||||
for (const resourceType of resourceTypes) {
|
||||
byResourceType[resourceType] = summarizeResourceType(samples, resourceType);
|
||||
}
|
||||
|
||||
return {
|
||||
requestCount: finiteMedian(samples.map(sample => sample.network.requestCount)),
|
||||
webSocketConnectionCount: finiteMedian(samples.map(sample => sample.network.webSocketConnectionCount)),
|
||||
webSocketSentBytes: finiteMedian(samples.map(sample => sample.network.webSocketSentBytes)),
|
||||
webSocketReceivedBytes: finiteMedian(samples.map(sample => sample.network.webSocketReceivedBytes)),
|
||||
finishedRequestCount: finiteMedian(samples.map(sample => sample.network.finishedRequestCount)),
|
||||
failedRequestCount: finiteMedian(samples.map(sample => sample.network.failedRequestCount)),
|
||||
cachedRequestCount: finiteMedian(samples.map(sample => sample.network.cachedRequestCount)),
|
||||
serviceWorkerRequestCount: finiteMedian(samples.map(sample => sample.network.serviceWorkerRequestCount)),
|
||||
totalEncodedBytes: finiteMedian(samples.map(sample => sample.network.totalEncodedBytes)),
|
||||
totalDecodedBodyBytes: finiteMedian(samples.map(sample => sample.network.totalDecodedBodyBytes)),
|
||||
sameOriginEncodedBytes: finiteMedian(samples.map(sample => sample.network.sameOriginEncodedBytes)),
|
||||
thirdPartyEncodedBytes: finiteMedian(samples.map(sample => sample.network.thirdPartyEncodedBytes)),
|
||||
byResourceType,
|
||||
largestRequests: representative.network.largestRequests,
|
||||
failedRequests: representative.network.failedRequests,
|
||||
};
|
||||
}
|
||||
|
||||
function summarizePerformanceSamples(samples: BrowserMeasurementSample[]): BrowserMeasurement['performance'] {
|
||||
const cdpMetricKeys = new Set<string>();
|
||||
for (const sample of samples) {
|
||||
for (const key of Object.keys(sample.performance.cdpMetrics)) {
|
||||
cdpMetricKeys.add(key);
|
||||
}
|
||||
}
|
||||
|
||||
const cdpMetrics = {} as Record<string, number>;
|
||||
for (const key of cdpMetricKeys) {
|
||||
cdpMetrics[key] = finiteMedian(samples.map(sample => sample.performance.cdpMetrics[key]));
|
||||
}
|
||||
|
||||
const webVitalKeys = [
|
||||
'firstPaintMs',
|
||||
'firstContentfulPaintMs',
|
||||
'domContentLoadedEventEndMs',
|
||||
'loadEventEndMs',
|
||||
'longTaskCount',
|
||||
'longTaskDurationMs',
|
||||
'maxLongTaskDurationMs',
|
||||
'resourceEntryCount',
|
||||
'domElements',
|
||||
] as const satisfies (keyof BrowserMeasurement['performance']['webVitals'])[];
|
||||
|
||||
const webVitals = {} as BrowserMeasurement['performance']['webVitals'];
|
||||
for (const key of webVitalKeys) {
|
||||
webVitals[key] = finiteMedian(samples.map(sample => sample.performance.webVitals[key]));
|
||||
}
|
||||
|
||||
return {
|
||||
cdpMetrics,
|
||||
runtimeHeap: {
|
||||
usedSize: finiteMedian(samples.map(sample => sample.performance.runtimeHeap?.usedSize)),
|
||||
totalSize: finiteMedian(samples.map(sample => sample.performance.runtimeHeap?.totalSize)),
|
||||
},
|
||||
tabMemory: {
|
||||
totalBytes: finiteMedian(samples.map(sample => sample.performance.tabMemory.totalBytes)),
|
||||
},
|
||||
webVitals,
|
||||
};
|
||||
}
|
||||
|
||||
function summarizeHeapSnapshotSamples(samples: BrowserMeasurementSample[]) {
|
||||
const summary = heapSnapshotUtil.summarizeHeapSnapshotDataSamples(
|
||||
samples,
|
||||
sample => sample.heapSnapshot,
|
||||
{ breakdownTopN: heapSnapshotBreakdownTopN },
|
||||
);
|
||||
if (summary == null) throw new Error('No heap snapshot samples');
|
||||
return summary;
|
||||
}
|
||||
|
||||
function summarizeSamples(label: 'base' | 'head', samples: BrowserMeasurementSample[]): BrowserMetricsReport {
|
||||
if (samples.length === 0) throw new Error(`No browser metric samples for ${label}`);
|
||||
const representative = selectRepresentativeSample(samples, sample => sample.network.totalEncodedBytes);
|
||||
const summary: BrowserMeasurement = {
|
||||
label,
|
||||
timestamp: new Date().toISOString(),
|
||||
url: baseUrl,
|
||||
scenario: representative.scenario,
|
||||
diagnostics: summarizeBrowserDiagnostics(samples.map(sample => sample.diagnostics)),
|
||||
durationMs: finiteMedian(samples.map(sample => sample.durationMs)),
|
||||
network: summarizeNetworkSamples(samples),
|
||||
performance: summarizePerformanceSamples(samples),
|
||||
heapSnapshot: summarizeHeapSnapshotSamples(samples),
|
||||
};
|
||||
|
||||
return {
|
||||
label,
|
||||
timestamp: new Date().toISOString(),
|
||||
url: baseUrl,
|
||||
scenario: representative.scenario,
|
||||
sampleCount: samples.length,
|
||||
aggregation: 'median',
|
||||
summary,
|
||||
samples,
|
||||
};
|
||||
}
|
||||
|
||||
async function measureSample(label: 'base' | 'head', round: number, heapSnapshotSavePath?: string) {
|
||||
await util.prepareInstance(baseUrl);
|
||||
|
||||
return await HeadlessChromeController.with(label, { scenarioTimeoutMs: 120000, baseUrl }, async chrome => {
|
||||
await chrome.enableNetworkTracking();
|
||||
|
||||
const startedAt = Date.now();
|
||||
await runSignupAndPostScenario(chrome);
|
||||
const durationMs = Date.now() - startedAt;
|
||||
await chrome.waitForNetworkDetails();
|
||||
const performance = await chrome.collectPerformance();
|
||||
const heapSnapshotRaw = await chrome.takeHeapSnapshot(heapSnapshotSavePath);
|
||||
const heapSnapshot = heapSnapshotUtil.analyzeHeapSnapshot(heapSnapshotRaw, { breakdownTopN: heapSnapshotBreakdownTopN });
|
||||
const measurement: BrowserMeasurementSample = {
|
||||
label,
|
||||
round,
|
||||
timestamp: new Date().toISOString(),
|
||||
url: baseUrl,
|
||||
scenario: 'fresh browser signup, first timeline note, after the note becomes visible',
|
||||
diagnostics: chrome.collectDiagnostics(),
|
||||
durationMs,
|
||||
network: summarizeNetwork(chrome.networkRequests, baseUrl, chrome.webSocketConnections),
|
||||
networkRequests: chrome.networkRequests,
|
||||
performance,
|
||||
heapSnapshot,
|
||||
};
|
||||
|
||||
return measurement;
|
||||
});
|
||||
}
|
||||
|
||||
function heapSnapshotPath(label: 'base' | 'head', round: number) {
|
||||
return join(heapSnapshotWorkDirs[label], `round-${round}.heapsnapshot`);
|
||||
}
|
||||
|
||||
async function saveRepresentativeHeapSnapshot(label: 'base' | 'head', report: BrowserMetricsReport) {
|
||||
const representative = selectRepresentativeSample(report.samples, sample => sample.heapSnapshot.categories.total);
|
||||
await copyFile(heapSnapshotPath(label, representative.round), heapSnapshotOutputPaths[label]);
|
||||
process.stderr.write(`[${label}] Selected round ${representative.round} heap snapshot for artifact\n`);
|
||||
await rm(heapSnapshotWorkDirs[label], { recursive: true, force: true });
|
||||
}
|
||||
|
||||
async function genReport(label: 'base' | 'head', repoDir: string, outputPath: string) {
|
||||
let server: ReturnType<typeof util.startServer> | null = null;
|
||||
|
||||
try {
|
||||
server = util.startServer(label, repoDir);
|
||||
await util.waitForServer(baseUrl, server);
|
||||
|
||||
await rm(heapSnapshotWorkDirs[label], { recursive: true, force: true });
|
||||
await mkdir(heapSnapshotWorkDirs[label], { recursive: true });
|
||||
|
||||
const samples: BrowserMeasurementSample[] = [];
|
||||
for (let round = 1; round <= sampleCount; round++) {
|
||||
process.stderr.write(`[${label}] Measuring browser metrics sample ${round}/${sampleCount}\n`);
|
||||
samples.push(await measureSample(
|
||||
label,
|
||||
round,
|
||||
heapSnapshotPath(label, round),
|
||||
));
|
||||
}
|
||||
|
||||
const report = summarizeSamples(label, samples);
|
||||
await writeFile(outputPath, JSON.stringify(report, null, '\t'));
|
||||
process.stderr.write(`[${label}] Wrote browser metrics report to ${outputPath}\n`);
|
||||
|
||||
await saveRepresentativeHeapSnapshot(label, report);
|
||||
} finally {
|
||||
if (server != null) await util.stopServer(server);
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
await genReport('base', resolve(baseDirArg), resolve(baseOutputArg));
|
||||
await genReport('head', resolve(headDirArg), resolve(headOutputArg));
|
||||
}
|
||||
|
||||
await main();
|
||||
@@ -1,446 +0,0 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { readFile, writeFile } from 'node:fs/promises';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
import * as util from './utility.mts';
|
||||
import type { BrowserMeasurementSample, BrowserMetricsReport } from './frontend-browser-diagnostics.render-md.mts';
|
||||
import type { NetworkRequest } from './chrome.mts';
|
||||
|
||||
type DiffDirection = 'added' | 'removed';
|
||||
|
||||
type RequestDiff = {
|
||||
direction: DiffDirection;
|
||||
round: number;
|
||||
baseCount: number;
|
||||
headCount: number;
|
||||
request: NetworkRequest;
|
||||
};
|
||||
|
||||
function escapeHtml(value: unknown) {
|
||||
return String(value ?? '')
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll("'", ''');
|
||||
}
|
||||
|
||||
function escapeAttribute(value: unknown) {
|
||||
return escapeHtml(value);
|
||||
}
|
||||
|
||||
function isHttpRequest(request: NetworkRequest) {
|
||||
try {
|
||||
const { protocol } = new URL(request.url);
|
||||
return protocol === 'http:' || protocol === 'https:';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function requestKey(request: NetworkRequest) {
|
||||
return [
|
||||
request.method,
|
||||
request.resourceType,
|
||||
request.url,
|
||||
].join('\u0000');
|
||||
}
|
||||
|
||||
function groupRequests(requests: NetworkRequest[] | undefined) {
|
||||
const grouped = new Map<string, NetworkRequest[]>();
|
||||
for (const request of requests ?? []) {
|
||||
if (!isHttpRequest(request)) continue;
|
||||
const key = requestKey(request);
|
||||
const rows = grouped.get(key) ?? [];
|
||||
rows.push(request);
|
||||
grouped.set(key, rows);
|
||||
}
|
||||
return grouped;
|
||||
}
|
||||
|
||||
function byRound(samples: BrowserMeasurementSample[]) {
|
||||
return new Map(samples.map(sample => [sample.round, sample]));
|
||||
}
|
||||
|
||||
function diffRound(round: number, baseSample: BrowserMeasurementSample | undefined, headSample: BrowserMeasurementSample | undefined) {
|
||||
const baseRequests = groupRequests(baseSample?.networkRequests);
|
||||
const headRequests = groupRequests(headSample?.networkRequests);
|
||||
const keys = [...new Set([
|
||||
...baseRequests.keys(),
|
||||
...headRequests.keys(),
|
||||
])].toSorted();
|
||||
const diffs: RequestDiff[] = [];
|
||||
|
||||
for (const key of keys) {
|
||||
const baseRows = baseRequests.get(key) ?? [];
|
||||
const headRows = headRequests.get(key) ?? [];
|
||||
if (headRows.length > baseRows.length) {
|
||||
for (const request of headRows.slice(baseRows.length)) {
|
||||
diffs.push({
|
||||
direction: 'added',
|
||||
round,
|
||||
baseCount: baseRows.length,
|
||||
headCount: headRows.length,
|
||||
request,
|
||||
});
|
||||
}
|
||||
} else if (baseRows.length > headRows.length) {
|
||||
for (const request of baseRows.slice(headRows.length)) {
|
||||
diffs.push({
|
||||
direction: 'removed',
|
||||
round,
|
||||
baseCount: baseRows.length,
|
||||
headCount: headRows.length,
|
||||
request,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return diffs;
|
||||
}
|
||||
|
||||
function diffReports(base: BrowserMetricsReport, head: BrowserMetricsReport) {
|
||||
const baseSamples = byRound(base.samples);
|
||||
const headSamples = byRound(head.samples);
|
||||
const rounds = [...new Set([
|
||||
...baseSamples.keys(),
|
||||
...headSamples.keys(),
|
||||
])].toSorted((a, b) => a - b);
|
||||
return rounds.flatMap(round => diffRound(round, baseSamples.get(round), headSamples.get(round)));
|
||||
}
|
||||
|
||||
function formatMaybeJson(value: string | undefined) {
|
||||
if (value == null || value === '') return null;
|
||||
try {
|
||||
return JSON.stringify(JSON.parse(value), null, '\t');
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
function formatHeaders(headers: Record<string, string> | undefined) {
|
||||
if (headers == null || Object.keys(headers).length === 0) return null;
|
||||
return JSON.stringify(headers, null, '\t');
|
||||
}
|
||||
|
||||
function countBy<T extends string>(diffs: RequestDiff[], getKey: (diff: RequestDiff) => T) {
|
||||
const counts = new Map<T, number>();
|
||||
for (const diff of diffs) {
|
||||
counts.set(getKey(diff), (counts.get(getKey(diff)) ?? 0) + 1);
|
||||
}
|
||||
return [...counts].toSorted((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]));
|
||||
}
|
||||
|
||||
function renderSummary(base: BrowserMetricsReport, head: BrowserMetricsReport, diffs: RequestDiff[]) {
|
||||
const added = diffs.filter(diff => diff.direction === 'added').length;
|
||||
const removed = diffs.filter(diff => diff.direction === 'removed').length;
|
||||
const typeRows = countBy(diffs, diff => diff.request.resourceType).map(([type, count]) => `
|
||||
<tr>
|
||||
<td>${escapeHtml(type)}</td>
|
||||
<td class="num">${util.formatNumber(count)}</td>
|
||||
</tr>`).join('');
|
||||
|
||||
return `
|
||||
<section class="summary">
|
||||
<div>
|
||||
<span class="label">Base samples</span>
|
||||
<strong>${util.formatNumber(base.sampleCount)}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span class="label">Head samples</span>
|
||||
<strong>${util.formatNumber(head.sampleCount)}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span class="label">Added in Head</span>
|
||||
<strong class="added-text">${util.formatNumber(added)}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span class="label">Removed in Head</span>
|
||||
<strong class="removed-text">${util.formatNumber(removed)}</strong>
|
||||
</div>
|
||||
</section>
|
||||
${typeRows === '' ? '' : `
|
||||
<section>
|
||||
<h2>Diffs by Resource Type</h2>
|
||||
<table>
|
||||
<thead><tr><th>Type</th><th>Diff requests</th></tr></thead>
|
||||
<tbody>${typeRows}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>`}`;
|
||||
}
|
||||
|
||||
function renderDetails(title: string, content: string | null, open = false) {
|
||||
if (content == null || content === '') return '';
|
||||
return `
|
||||
<details${open ? ' open' : ''}>
|
||||
<summary>${escapeHtml(title)}</summary>
|
||||
<pre>${escapeHtml(content)}</pre>
|
||||
</details>`;
|
||||
}
|
||||
|
||||
function renderRequest(diff: RequestDiff) {
|
||||
const { request } = diff;
|
||||
const requestBody = formatMaybeJson(request.requestBody);
|
||||
const requestHeaders = formatHeaders(request.requestHeaders);
|
||||
const responseHeaders = formatHeaders(request.responseHeaders);
|
||||
const bodyNote = requestBody == null && request.hasRequestBody === true
|
||||
? '<p class="empty">Request body was present but could not be retrieved from CDP.</p>'
|
||||
: '';
|
||||
|
||||
return `
|
||||
<article class="request ${diff.direction}">
|
||||
<header>
|
||||
<span class="badge">${diff.direction === 'added' ? 'Added in Head' : 'Removed in Head'}</span>
|
||||
<span class="method">${escapeHtml(request.method)}</span>
|
||||
<span class="type">${escapeHtml(request.resourceType)}</span>
|
||||
<span class="status">${escapeHtml(request.status ?? '-')}</span>
|
||||
</header>
|
||||
<a class="url" href="${escapeAttribute(request.url)}">${escapeHtml(request.url)}</a>
|
||||
<dl>
|
||||
<div><dt>Round</dt><dd>${util.formatNumber(diff.round)}</dd></div>
|
||||
<div><dt>Base count</dt><dd>${util.formatNumber(diff.baseCount)}</dd></div>
|
||||
<div><dt>Head count</dt><dd>${util.formatNumber(diff.headCount)}</dd></div>
|
||||
<div><dt>Encoded</dt><dd>${util.formatBytes(request.encodedDataLength ?? 0)}</dd></div>
|
||||
<div><dt>Decoded body</dt><dd>${util.formatBytes(request.decodedBodyLength ?? 0)}</dd></div>
|
||||
<div><dt>MIME</dt><dd>${escapeHtml(request.mimeType ?? '-')}</dd></div>
|
||||
<div><dt>Protocol</dt><dd>${escapeHtml(request.protocol ?? '-')}</dd></div>
|
||||
<div><dt>Remote</dt><dd>${escapeHtml(request.remoteIPAddress == null ? '-' : `${request.remoteIPAddress}:${request.remotePort ?? ''}`)}</dd></div>
|
||||
<div><dt>Failed</dt><dd>${request.failed ? escapeHtml(request.errorText ?? 'yes') : 'no'}</dd></div>
|
||||
</dl>
|
||||
${bodyNote}
|
||||
${renderDetails('Request body', requestBody, requestBody != null)}
|
||||
${renderDetails('Request headers', requestHeaders)}
|
||||
${renderDetails('Response headers', responseHeaders)}
|
||||
</article>`;
|
||||
}
|
||||
|
||||
function renderRound(round: number, diffs: RequestDiff[]) {
|
||||
const added = diffs.filter(diff => diff.direction === 'added').length;
|
||||
const removed = diffs.filter(diff => diff.direction === 'removed').length;
|
||||
return `
|
||||
<section>
|
||||
<h2>Round ${util.formatNumber(round)}</h2>
|
||||
<p>${util.formatNumber(added)} added, ${util.formatNumber(removed)} removed</p>
|
||||
<div class="requests">
|
||||
${diffs.map(renderRequest).join('\n')}
|
||||
</div>
|
||||
</section>`;
|
||||
}
|
||||
|
||||
function renderHtml(base: BrowserMetricsReport, head: BrowserMetricsReport) {
|
||||
const diffs = diffReports(base, head);
|
||||
const rounds = [...new Set(diffs.map(diff => diff.round))].toSorted((a, b) => a - b);
|
||||
const generatedAt = new Date().toISOString();
|
||||
const content = diffs.length === 0
|
||||
? '<section><p>No added or removed HTTP(S) requests were found in paired samples.</p></section>'
|
||||
: rounds.map(round => renderRound(round, diffs.filter(diff => diff.round === round))).join('\n');
|
||||
|
||||
return `<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Frontend Browser Network Request Diff</title>
|
||||
<style>
|
||||
:root {
|
||||
color-scheme: light dark;
|
||||
--bg: #f7f7f8;
|
||||
--fg: #202124;
|
||||
--muted: #5f6368;
|
||||
--card: #ffffff;
|
||||
--border: #dfe1e5;
|
||||
--added: #137333;
|
||||
--added-bg: #e6f4ea;
|
||||
--removed: #a50e0e;
|
||||
--removed-bg: #fce8e6;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--bg: #111315;
|
||||
--fg: #e8eaed;
|
||||
--muted: #bdc1c6;
|
||||
--card: #1b1d20;
|
||||
--border: #3c4043;
|
||||
--added-bg: #17351f;
|
||||
--removed-bg: #3c1f1d;
|
||||
}
|
||||
}
|
||||
body {
|
||||
margin: 0;
|
||||
font: 14px/1.5 system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--fg);
|
||||
}
|
||||
main {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 24px;
|
||||
}
|
||||
h1 {
|
||||
font-size: 24px;
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
h2 {
|
||||
font-size: 18px;
|
||||
margin: 32px 0 8px;
|
||||
}
|
||||
.meta {
|
||||
color: var(--muted);
|
||||
margin: 0 0 24px;
|
||||
}
|
||||
.summary {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
|
||||
gap: 12px;
|
||||
margin: 24px 0;
|
||||
}
|
||||
.summary > div, .request, table {
|
||||
background: var(--card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
}
|
||||
.summary > div {
|
||||
padding: 14px;
|
||||
}
|
||||
.label {
|
||||
display: block;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
.summary strong {
|
||||
display: block;
|
||||
font-size: 24px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
.added-text {
|
||||
color: var(--added);
|
||||
}
|
||||
.removed-text {
|
||||
color: var(--removed);
|
||||
}
|
||||
table {
|
||||
border-collapse: collapse;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
th, td {
|
||||
border-bottom: 1px solid var(--border);
|
||||
padding: 8px 10px;
|
||||
text-align: left;
|
||||
}
|
||||
th {
|
||||
color: var(--muted);
|
||||
font-weight: 600;
|
||||
}
|
||||
.num {
|
||||
text-align: right;
|
||||
}
|
||||
.requests {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
.request {
|
||||
padding: 14px;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.request.added {
|
||||
border-left: 4px solid var(--added);
|
||||
}
|
||||
.request.removed {
|
||||
border-left: 4px solid var(--removed);
|
||||
}
|
||||
.request header {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.badge, .method, .type, .status {
|
||||
border-radius: 999px;
|
||||
padding: 2px 8px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.added .badge {
|
||||
background: var(--added-bg);
|
||||
color: var(--added);
|
||||
}
|
||||
.removed .badge {
|
||||
background: var(--removed-bg);
|
||||
color: var(--removed);
|
||||
}
|
||||
.method, .type, .status {
|
||||
background: color-mix(in srgb, var(--muted) 14%, transparent);
|
||||
color: var(--fg);
|
||||
}
|
||||
.url {
|
||||
display: block;
|
||||
margin: 8px 0 12px;
|
||||
color: inherit;
|
||||
font-family: ui-monospace, SFMono-Regular, Consolas, "Liberation Mono", monospace;
|
||||
}
|
||||
dl {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
||||
gap: 8px 16px;
|
||||
margin: 0 0 12px;
|
||||
}
|
||||
dl div {
|
||||
min-width: 0;
|
||||
}
|
||||
dt {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
dd {
|
||||
margin: 0;
|
||||
}
|
||||
details {
|
||||
margin-top: 8px;
|
||||
}
|
||||
summary {
|
||||
cursor: pointer;
|
||||
color: var(--muted);
|
||||
}
|
||||
pre {
|
||||
white-space: pre-wrap;
|
||||
overflow-x: auto;
|
||||
background: color-mix(in srgb, var(--muted) 10%, transparent);
|
||||
border-radius: 6px;
|
||||
padding: 10px;
|
||||
}
|
||||
.empty {
|
||||
color: var(--muted);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<h1>Frontend Browser Network Request Diff</h1>
|
||||
<p class="meta">Generated at ${escapeHtml(generatedAt)}. Requests are compared per paired round by method, resource type, and exact URL. Bodies are shown for added/removed request instances when CDP exposes them.</p>
|
||||
${renderSummary(base, head, diffs)}
|
||||
${content}
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const [baseFile, headFile, outputFile] = process.argv.slice(2);
|
||||
|
||||
const base = JSON.parse(await readFile(baseFile, 'utf8')) as BrowserMetricsReport;
|
||||
const head = JSON.parse(await readFile(headFile, 'utf8')) as BrowserMetricsReport;
|
||||
await writeFile(outputFile, renderHtml(base, head));
|
||||
}
|
||||
|
||||
// 直接実行されたときだけ呼ぶための判定(テストなどでこのファイル内の関数をimportするだけのとき用)
|
||||
if (process.argv[1] != null && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
||||
await main();
|
||||
}
|
||||
@@ -1,297 +0,0 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { readFile, writeFile } from 'node:fs/promises';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
import * as util from './utility.mts';
|
||||
import * as heapSnapshotUtil from './heap-snapshot-util.mts';
|
||||
import type { HeapSnapshotData, HeapSnapshotReport } from './heap-snapshot-util.mts';
|
||||
import type { BrowserDiagnostics, NetworkRequest } from './chrome.mts';
|
||||
|
||||
export type BrowserMeasurement = {
|
||||
label: string;
|
||||
timestamp: string;
|
||||
url: string;
|
||||
scenario: string;
|
||||
diagnostics: BrowserDiagnostics;
|
||||
durationMs: number;
|
||||
network: {
|
||||
requestCount: number;
|
||||
webSocketConnectionCount: number;
|
||||
webSocketSentBytes: number;
|
||||
webSocketReceivedBytes: number;
|
||||
finishedRequestCount: number;
|
||||
failedRequestCount: number;
|
||||
cachedRequestCount: number;
|
||||
serviceWorkerRequestCount: number;
|
||||
totalEncodedBytes: number;
|
||||
totalDecodedBodyBytes: number;
|
||||
sameOriginEncodedBytes: number;
|
||||
thirdPartyEncodedBytes: number;
|
||||
byResourceType: Record<string, {
|
||||
requests: number;
|
||||
encodedBytes: number;
|
||||
decodedBodyBytes: number;
|
||||
}>;
|
||||
largestRequests: {
|
||||
url: string;
|
||||
method: string;
|
||||
resourceType: string;
|
||||
status?: number;
|
||||
encodedBytes: number;
|
||||
decodedBodyBytes: number;
|
||||
}[];
|
||||
failedRequests: {
|
||||
url: string;
|
||||
method: string;
|
||||
resourceType: string;
|
||||
errorText?: string;
|
||||
status?: number;
|
||||
}[];
|
||||
};
|
||||
performance: {
|
||||
cdpMetrics: Record<string, number>;
|
||||
runtimeHeap?: {
|
||||
usedSize: number;
|
||||
totalSize: number;
|
||||
};
|
||||
tabMemory: {
|
||||
totalBytes: number;
|
||||
};
|
||||
webVitals: {
|
||||
firstPaintMs?: number;
|
||||
firstContentfulPaintMs?: number;
|
||||
domContentLoadedEventEndMs?: number;
|
||||
loadEventEndMs?: number;
|
||||
longTaskCount: number;
|
||||
longTaskDurationMs: number;
|
||||
maxLongTaskDurationMs: number;
|
||||
resourceEntryCount: number;
|
||||
domElements: number;
|
||||
};
|
||||
};
|
||||
heapSnapshot: HeapSnapshotData;
|
||||
};
|
||||
|
||||
export type BrowserMeasurementSample = BrowserMeasurement & {
|
||||
round: number;
|
||||
networkRequests?: NetworkRequest[];
|
||||
};
|
||||
|
||||
export type BrowserMetricsReport = {
|
||||
label: string;
|
||||
timestamp: string;
|
||||
url: string;
|
||||
scenario: string;
|
||||
sampleCount: number;
|
||||
aggregation: 'median';
|
||||
summary: BrowserMeasurement;
|
||||
samples: BrowserMeasurementSample[];
|
||||
};
|
||||
|
||||
function formatValueWithSpread(report: BrowserMetricsReport, value: number, getSampleValue: (sample: BrowserMeasurementSample) => number | null | undefined, formatter: (value: number) => string) {
|
||||
const values = report.samples.map(sample => getSampleValue(sample)).filter(v => Number.isFinite(v)) as number[];
|
||||
const spread = values.length < 2 ? null : util.median(values.map(value => Math.abs(value - util.median(values))));
|
||||
if (spread == null) return formatter(value);
|
||||
return `${formatter(value)}<br>± ${formatter(spread)}`;
|
||||
}
|
||||
|
||||
function renderMetricRow(
|
||||
label: string,
|
||||
base: BrowserMetricsReport,
|
||||
head: BrowserMetricsReport,
|
||||
getSummaryValue: (summary: BrowserMeasurement) => number,
|
||||
getSampleValue: (sample: BrowserMeasurementSample) => number,
|
||||
formatter: (value: number) => string,
|
||||
significantThreshold = 0,
|
||||
skipIfNotSignificant = true
|
||||
) {
|
||||
const baseValue = getSummaryValue(base.summary);
|
||||
const headValue = getSummaryValue(head.summary);
|
||||
if (baseValue == null || headValue == null || !Number.isFinite(baseValue) || !Number.isFinite(headValue)) return null;
|
||||
|
||||
const summary = util.pairedDeltaSummary(base.samples, head.samples, sample => getSampleValue(sample));
|
||||
// 有意な閾値に満たない場合はそもそもrowとして出力しない
|
||||
if (skipIfNotSignificant && (Math.abs(summary.median) < significantThreshold)) return null;
|
||||
|
||||
const percent = baseValue === 0 ? null : summary.median * 100 / baseValue;
|
||||
//const deltaMedian = `${util.formatColoredDelta(summary.median, formatter, colorThreshold)}<br>${percent == null ? '-' : util.formatDeltaPercent(percent, 0.1).replaceAll('\\%', '\\\\%')}`;
|
||||
const deltaMedian = util.formatColoredDelta(summary.median, formatter, significantThreshold);
|
||||
|
||||
//return `| **${label}** | ${formatValueWithSpread(base, baseValue, getSampleValue, formatter)} | ${formatValueWithSpread(head, headValue, getSampleValue, formatter)} | ${deltaMedian} | ${summary == null ? '-' : formatter(summary.mad)} | ${summary == null ? '-' : util.formatColoredDelta(summary.min, formatter)} | ${summary == null ? '-' : util.formatColoredDelta(summary.max, formatter)} |`;
|
||||
return `| **${label}** | ${formatter(baseValue)} | ${formatter(headValue)} | ${deltaMedian} | ${summary == null ? '-' : formatter(summary.mad)} | ${summary == null ? '-' : util.formatColoredDelta(summary.min, formatter, significantThreshold)} | ${summary == null ? '-' : util.formatColoredDelta(summary.max, formatter, significantThreshold)} |`;
|
||||
}
|
||||
|
||||
function resourceTypeBytes(report: BrowserMeasurement, resourceTypes: string[]) {
|
||||
return resourceTypes.reduce((sum, resourceType) => sum + (report.network.byResourceType[resourceType]?.encodedBytes ?? 0), 0);
|
||||
}
|
||||
|
||||
function resourceTypeSampleBytes(sample: BrowserMeasurementSample, resourceTypes: string[]) {
|
||||
return resourceTypeBytes(sample, resourceTypes);
|
||||
}
|
||||
|
||||
function renderSummaryTable(base: BrowserMetricsReport, head: BrowserMetricsReport, all = false) {
|
||||
//function getMetric(report: BrowserMeasurement, key: string) {
|
||||
// return report.performance.cdpMetrics[key];
|
||||
//}
|
||||
|
||||
const rows = [
|
||||
//metricRow('Scenario duration', base, head, summary => summary.durationMs, sample => sample.durationMs, util.formatMs),
|
||||
renderMetricRow('Requests', base, head, summary => summary.network.requestCount, sample => sample.network.requestCount, util.formatNumber, 1, !all),
|
||||
//metricRow('Failed requests', base, head, summary => summary.network.failedRequestCount, sample => sample.network.failedRequestCount, util.formatNumber),
|
||||
renderMetricRow('Encoded network', base, head, summary => summary.network.totalEncodedBytes, sample => sample.network.totalEncodedBytes, util.formatBytes, 10000, !all),
|
||||
renderMetricRow('Decoded body', base, head, summary => summary.network.totalDecodedBodyBytes, sample => sample.network.totalDecodedBodyBytes, util.formatBytes, 10000, !all),
|
||||
renderMetricRow('Same-origin encoded', base, head, summary => summary.network.sameOriginEncodedBytes, sample => sample.network.sameOriginEncodedBytes, util.formatBytes, 10000, !all),
|
||||
renderMetricRow('Third-party encoded', base, head, summary => summary.network.thirdPartyEncodedBytes, sample => sample.network.thirdPartyEncodedBytes, util.formatBytes, 10000, !all),
|
||||
renderMetricRow('Script encoded', base, head, summary => resourceTypeBytes(summary, ['Script']), sample => resourceTypeSampleBytes(sample, ['Script']), util.formatBytes, 10000, !all),
|
||||
renderMetricRow('Stylesheet encoded', base, head, summary => resourceTypeBytes(summary, ['Stylesheet']), sample => resourceTypeSampleBytes(sample, ['Stylesheet']), util.formatBytes, 10000, !all),
|
||||
renderMetricRow('Fetch/XHR encoded', base, head, summary => resourceTypeBytes(summary, ['Fetch', 'XHR']), sample => resourceTypeSampleBytes(sample, ['Fetch', 'XHR']), util.formatBytes, 10000, !all),
|
||||
renderMetricRow('Image encoded', base, head, summary => resourceTypeBytes(summary, ['Image']), sample => resourceTypeSampleBytes(sample, ['Image']), util.formatBytes, 10000, !all),
|
||||
renderMetricRow('Font encoded', base, head, summary => resourceTypeBytes(summary, ['Font']), sample => resourceTypeSampleBytes(sample, ['Font']), util.formatBytes, 10000, !all),
|
||||
//metricRow('First contentful paint', base, head, summary => summary.performance.webVitals.firstContentfulPaintMs, sample => sample.performance.webVitals.firstContentfulPaintMs, util.formatMs),
|
||||
//metricRow('Load event end', base, head, summary => summary.performance.webVitals.loadEventEndMs, sample => sample.performance.webVitals.loadEventEndMs, util.formatMs),
|
||||
//metricRow('Long tasks', base, head, summary => summary.performance.webVitals.longTaskCount, sample => sample.performance.webVitals.longTaskCount, util.formatNumber),
|
||||
//metricRow('Long task duration', base, head, summary => summary.performance.webVitals.longTaskDurationMs, sample => sample.performance.webVitals.longTaskDurationMs, util.formatMs),
|
||||
//metricRow('Max long task', base, head, summary => summary.performance.webVitals.maxLongTaskDurationMs, sample => sample.performance.webVitals.maxLongTaskDurationMs, util.formatMs),
|
||||
//metricRow('JS heap used', base, head, summary => summary.performance.runtimeHeap?.usedSize ?? getMetric(summary, 'JSHeapUsedSize'), sample => sample.performance.runtimeHeap?.usedSize ?? getMetric(sample, 'JSHeapUsedSize'), util.formatBytes),
|
||||
//metricRow('JS heap total', base, head, summary => summary.performance.runtimeHeap?.totalSize ?? getMetric(summary, 'JSHeapTotalSize'), sample => sample.performance.runtimeHeap?.totalSize ?? getMetric(sample, 'JSHeapTotalSize'), util.formatBytes),
|
||||
//metricRow('V8 heap snapshot total', base, head, summary => summary.heapSnapshot.categories.total, sample => sample.heapSnapshot.categories.total, util.formatBytes, 10000),
|
||||
//metricRow('DOM elements', base, head, summary => summary.performance.webVitals.domElements, sample => sample.performance.webVitals.domElements, util.formatNumber),
|
||||
//metricRow('CDP nodes', base, head, summary => getMetric(summary, 'Nodes'), sample => getMetric(sample, 'Nodes'), util.formatNumber),
|
||||
//metricRow('JS event listeners', base, head, summary => getMetric(summary, 'JSEventListeners'), sample => getMetric(sample, 'JSEventListeners'), util.formatNumber),
|
||||
//metricRow('Layout count', base, head, summary => getMetric(summary, 'LayoutCount'), sample => getMetric(sample, 'LayoutCount'), util.formatNumber),
|
||||
//metricRow('Recalc style count', base, head, summary => getMetric(summary, 'RecalcStyleCount'), sample => getMetric(sample, 'RecalcStyleCount'), util.formatNumber),
|
||||
//metricRow('Script duration', base, head, summary => getMetric(summary, 'ScriptDuration'), sample => getMetric(sample, 'ScriptDuration'), util.formatSecondsAsMs),
|
||||
//metricRow('Task duration', base, head, summary => getMetric(summary, 'TaskDuration'), sample => getMetric(sample, 'TaskDuration'), util.formatSecondsAsMs),
|
||||
renderMetricRow('WebSocket connections', base, head, summary => summary.network.webSocketConnectionCount, sample => sample.network.webSocketConnectionCount, util.formatNumber, 1, !all),
|
||||
renderMetricRow('WebSocket sent', base, head, summary => summary.network.webSocketSentBytes, sample => sample.network.webSocketSentBytes, util.formatBytes, 10000, !all),
|
||||
renderMetricRow('WebSocket received', base, head, summary => summary.network.webSocketReceivedBytes, sample => sample.network.webSocketReceivedBytes, util.formatBytes, 10000, !all),
|
||||
renderMetricRow('Page errors', base, head, summary => summary.diagnostics.pageErrorCount, sample => sample.diagnostics.pageErrorCount, util.formatNumber, 1, !all),
|
||||
renderMetricRow('Console log', base, head, summary => summary.diagnostics.console.log, sample => sample.diagnostics.console.log, util.formatNumber, 1, !all),
|
||||
renderMetricRow('Console warnings', base, head, summary => summary.diagnostics.console.warning, sample => sample.diagnostics.console.warning, util.formatNumber, 1, !all),
|
||||
renderMetricRow('Console errors', base, head, summary => summary.diagnostics.console.error, sample => sample.diagnostics.console.error, util.formatNumber, 1, !all),
|
||||
renderMetricRow('Console info', base, head, summary => summary.diagnostics.console.info, sample => sample.diagnostics.console.info, util.formatNumber, 1, !all),
|
||||
renderMetricRow('Page-attributed memory', base, head, summary => summary.performance.tabMemory.totalBytes, sample => sample.performance.tabMemory.totalBytes, util.formatBytes, 10000, !all),
|
||||
].filter(row => row != null);
|
||||
|
||||
return [
|
||||
'| Metric | Base | Head | Δ median | Δ MAD | Δ min | Δ max |',
|
||||
'| --- | ---: | ---: | ---: | ---: | ---: | ---: |',
|
||||
...rows,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function renderResourceTypeTable(base: BrowserMetricsReport, head: BrowserMetricsReport) {
|
||||
const preferredOrder = ['Document', 'Script', 'Stylesheet', 'Fetch', 'XHR', 'Image', 'Font', 'Media', 'WebSocket', 'EventSource', 'Other'];
|
||||
const keys = [...new Set([
|
||||
...preferredOrder,
|
||||
...Object.keys(base.summary.network.byResourceType),
|
||||
...Object.keys(head.summary.network.byResourceType),
|
||||
])].filter(key => base.summary.network.byResourceType[key] != null || head.summary.network.byResourceType[key] != null);
|
||||
|
||||
const lines = [
|
||||
'<table>',
|
||||
'<thead>',
|
||||
'<tr>',
|
||||
'<th rowspan="2">Type</th>',
|
||||
'<th colspan="3">Requests</th>',
|
||||
'<th colspan="3">Encoded bytes</th>',
|
||||
'</tr>',
|
||||
'<tr>',
|
||||
'<th>Base</th>',
|
||||
'<th>Head</th>',
|
||||
'<th>Δ</th>',
|
||||
'<th>Base</th>',
|
||||
'<th>Head</th>',
|
||||
'<th>Δ</th>',
|
||||
'</tr>',
|
||||
'</thead>',
|
||||
'<tbody>',
|
||||
];
|
||||
|
||||
for (const key of keys) {
|
||||
const baseRow = base.summary.network.byResourceType[key] ?? { requests: 0, encodedBytes: 0 };
|
||||
const headRow = head.summary.network.byResourceType[key] ?? { requests: 0, encodedBytes: 0 };
|
||||
lines.push('<tr>');
|
||||
lines.push(`<td><b>${key}</b></td>`);
|
||||
lines.push(`<td align="right">${util.formatNumber(baseRow.requests)}</td>`);
|
||||
lines.push(`<td align="right">${util.formatNumber(headRow.requests)}</td>`);
|
||||
lines.push(`<td align="right">${util.formatColoredDelta(headRow.requests - baseRow.requests, util.formatNumber)}</td>`);
|
||||
lines.push(`<td align="right">${util.formatBytes(baseRow.encodedBytes)}</td>`);
|
||||
lines.push(`<td align="right">${util.formatBytes(headRow.encodedBytes)}</td>`);
|
||||
lines.push(`<td align="right">${util.formatColoredDelta(headRow.encodedBytes - baseRow.encodedBytes, util.formatBytes)}</td>`);
|
||||
lines.push('</tr>');
|
||||
}
|
||||
|
||||
lines.push('</tbody>');
|
||||
lines.push('</table>');
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function toHeapSnapshotReport(report: BrowserMetricsReport): HeapSnapshotReport {
|
||||
return {
|
||||
summary: report.summary.heapSnapshot,
|
||||
samples: report.samples.map(sample => ({
|
||||
round: sample.round,
|
||||
data: sample.heapSnapshot,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
function renderMd(base: BrowserMetricsReport, head: BrowserMetricsReport, options: {
|
||||
baseHeapSnapshotUrl: string;
|
||||
headHeapSnapshotUrl: string;
|
||||
detailedHtmlUrl?: string;
|
||||
}) {
|
||||
const detailedHtmlUrl = options.detailedHtmlUrl;
|
||||
const heapSnapshotTable = heapSnapshotUtil.renderHeapSnapshotTable(toHeapSnapshotReport(base), toHeapSnapshotReport(head));
|
||||
const lines = [
|
||||
'## 🖥 Frontend Browser Diagnostics Report',
|
||||
'',
|
||||
renderSummaryTable(base, head),
|
||||
'',
|
||||
'<i>Only metrics showing significant changes are displayed.</i>',
|
||||
'',
|
||||
detailedHtmlUrl == null || detailedHtmlUrl === '' ? null : `[View details](${detailedHtmlUrl})`,
|
||||
detailedHtmlUrl == null || detailedHtmlUrl === '' ? null : '',
|
||||
'<details>',
|
||||
'<summary>Requests by resource type</summary>',
|
||||
'',
|
||||
renderResourceTypeTable(base, head),
|
||||
'',
|
||||
'</details>',
|
||||
'',
|
||||
'<details>',
|
||||
'<summary>V8 heap snapshot statistics</summary>',
|
||||
'',
|
||||
heapSnapshotTable ?? '_No V8 heap snapshot data._',
|
||||
'',
|
||||
//heapSnapshotUtil.renderHeapSnapshotSankey(toHeapSnapshotReport(head), 'Head'),
|
||||
//'',
|
||||
`Download representative heap snapshot: [base](${options.baseHeapSnapshotUrl}) / [head](${options.headHeapSnapshotUrl})`,
|
||||
'</details>',
|
||||
'',
|
||||
];
|
||||
|
||||
return lines.filter(line => line != null).join('\n').trimEnd() + '\n';
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const [baseFile, headFile, outputFile] = process.argv.slice(2);
|
||||
|
||||
const base = JSON.parse(await readFile(baseFile, 'utf8')) as BrowserMetricsReport;
|
||||
const head = JSON.parse(await readFile(headFile, 'utf8')) as BrowserMetricsReport;
|
||||
await writeFile(outputFile, renderMd(base, head, {
|
||||
baseHeapSnapshotUrl: process.env.FRONTEND_BROWSER_BASE_HEAP_SNAPSHOT_ARTIFACT_URL!,
|
||||
headHeapSnapshotUrl: process.env.FRONTEND_BROWSER_HEAD_HEAP_SNAPSHOT_ARTIFACT_URL!,
|
||||
detailedHtmlUrl: process.env.FRONTEND_BROWSER_DETAILED_HTML_ARTIFACT_URL,
|
||||
}));
|
||||
}
|
||||
|
||||
// 直接実行されたときだけ呼ぶための判定(テストなどでこのファイル内の関数をimportするだけのとき用)
|
||||
if (process.argv[1] != null && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
||||
await main();
|
||||
}
|
||||
@@ -1,527 +0,0 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { promises as fs } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import * as util from './utility.mts';
|
||||
|
||||
const locale = 'ja-JP';
|
||||
|
||||
type Manifest = Record<string, { file?: string; src?: string; name?: string; isEntry?: boolean; imports?: string[] }>;
|
||||
|
||||
const stableNamedChunks = new Set(['vue', 'i18n']);
|
||||
|
||||
type FileEntry = {
|
||||
comparisonKey: string | null;
|
||||
displayName: string;
|
||||
file: string;
|
||||
manifestKeys: string[];
|
||||
size: number;
|
||||
};
|
||||
|
||||
type CollectedReport = {
|
||||
manifest: Manifest;
|
||||
chunks: FileEntry[];
|
||||
comparableChunks: Record<string, FileEntry>;
|
||||
chunksByManifestKey: Record<string, FileEntry>;
|
||||
startupFiles: string[];
|
||||
};
|
||||
|
||||
function entryDisplayName(entry: FileEntry) {
|
||||
if (!entry) return '';
|
||||
return entry.displayName || entry.file;
|
||||
}
|
||||
|
||||
function findEntryKey(manifest: Manifest) {
|
||||
const entries = Object.entries(manifest);
|
||||
return entries.find(([key, chunk]) => key === 'src/_boot_.ts' || chunk.src === 'src/_boot_.ts')?.[0]
|
||||
?? entries.find(([, chunk]) => chunk.name === 'entry' && chunk.isEntry)?.[0]
|
||||
?? entries.find(([, chunk]) => chunk.isEntry)?.[0]
|
||||
?? null;
|
||||
}
|
||||
|
||||
function stableChunkKey(chunk: Manifest[string]) {
|
||||
if (chunk.src != null) return `src:${util.normalizePath(chunk.src)}`;
|
||||
if (chunk.name != null && stableNamedChunks.has(chunk.name)) return `named:${chunk.name}`;
|
||||
return null;
|
||||
}
|
||||
|
||||
function collectStartupManifestKeys(manifest: Manifest) {
|
||||
const entryKey = findEntryKey(manifest);
|
||||
const keys = new Set<string>();
|
||||
if (entryKey == null) throw new Error('Unable to find frontend startup entry in Vite manifest.');
|
||||
|
||||
function visit(key: string, importedBy?: string) {
|
||||
if (keys.has(key)) return;
|
||||
const chunk = manifest[key];
|
||||
const importContext = importedBy == null ? '' : ` imported by "${importedBy}"`;
|
||||
if (chunk == null) throw new Error(`Startup manifest key "${key}"${importContext} is missing.`);
|
||||
if (chunk.file == null || chunk.file.length === 0) throw new Error(`Startup manifest key "${key}"${importContext} has no output file.`);
|
||||
if (!chunk.file.endsWith('.js')) throw new Error(`Startup manifest key "${key}"${importContext} resolves to non-JavaScript output "${chunk.file}".`);
|
||||
keys.add(key);
|
||||
for (const importKey of chunk.imports ?? []) visit(importKey, key);
|
||||
}
|
||||
|
||||
visit(entryKey);
|
||||
return keys;
|
||||
}
|
||||
|
||||
async function resolveBuiltFile(outDir: string, file: string) {
|
||||
if (file.startsWith('scripts/')) {
|
||||
const localizedFile = file.slice('scripts/'.length);
|
||||
const localizedPath = path.join(outDir, locale, localizedFile);
|
||||
if (await util.fileExists(localizedPath)) {
|
||||
return {
|
||||
absolutePath: localizedPath,
|
||||
relativePath: `${locale}/${localizedFile}`,
|
||||
};
|
||||
}
|
||||
|
||||
throw new Error(`Expected ${locale} localized chunk for ${file}, but ${localizedPath} was not found.`);
|
||||
}
|
||||
return {
|
||||
absolutePath: path.join(outDir, file),
|
||||
relativePath: file,
|
||||
};
|
||||
}
|
||||
|
||||
async function collectReport(repoDir: string): Promise<CollectedReport> {
|
||||
const outDir = path.join(repoDir, 'built/_frontend_vite_');
|
||||
const manifestPath = path.join(outDir, 'manifest.json');
|
||||
const manifest = JSON.parse(await fs.readFile(manifestPath, 'utf8')) as Manifest;
|
||||
const chunksByFile = new Map<string, FileEntry>();
|
||||
const comparableChunks = new Map<string, FileEntry>();
|
||||
const chunksByManifestKey = new Map<string, FileEntry>();
|
||||
|
||||
for (const [manifestKey, chunk] of Object.entries(manifest)) {
|
||||
if (!chunk.file?.endsWith('.js')) continue;
|
||||
const builtFile = await resolveBuiltFile(outDir, chunk.file);
|
||||
const comparisonKey = stableChunkKey(chunk);
|
||||
let entry = chunksByFile.get(builtFile.relativePath);
|
||||
if (entry == null) {
|
||||
entry = {
|
||||
comparisonKey,
|
||||
displayName: chunk.src ?? chunk.name ?? manifestKey,
|
||||
file: builtFile.relativePath,
|
||||
manifestKeys: [manifestKey],
|
||||
size: await util.fileSize(builtFile.absolutePath),
|
||||
};
|
||||
chunksByFile.set(entry.file, entry);
|
||||
} else if (entry.comparisonKey !== comparisonKey) {
|
||||
throw new Error(`Conflicting identities for ${entry.file}`);
|
||||
} else {
|
||||
entry.manifestKeys.push(manifestKey);
|
||||
}
|
||||
chunksByManifestKey.set(manifestKey, entry);
|
||||
if (comparisonKey != null) {
|
||||
const existing = comparableChunks.get(comparisonKey);
|
||||
if (existing != null && existing.file !== entry.file) {
|
||||
throw new Error(`Duplicate stable chunk key "${comparisonKey}": ${existing.file}, ${entry.file}`);
|
||||
}
|
||||
comparableChunks.set(comparisonKey, entry);
|
||||
}
|
||||
}
|
||||
|
||||
const localeDir = path.join(outDir, locale);
|
||||
if (await util.fileExists(localeDir)) {
|
||||
for await (const fullPath of util.traverseDirectory(localeDir)) {
|
||||
if (!fullPath.endsWith('.js')) continue;
|
||||
const relativePath = util.normalizePath(path.relative(outDir, fullPath));
|
||||
if (chunksByFile.has(relativePath)) continue;
|
||||
chunksByFile.set(relativePath, {
|
||||
comparisonKey: null,
|
||||
displayName: relativePath,
|
||||
file: relativePath,
|
||||
manifestKeys: [],
|
||||
size: await util.fileSize(fullPath),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const startupFiles = new Set<string>();
|
||||
for (const manifestKey of collectStartupManifestKeys(manifest)) {
|
||||
const entry = chunksByManifestKey.get(manifestKey);
|
||||
if (entry != null) startupFiles.add(entry.file);
|
||||
}
|
||||
|
||||
return {
|
||||
manifest,
|
||||
chunks: [...chunksByFile.values()],
|
||||
comparableChunks: Object.fromEntries(comparableChunks),
|
||||
chunksByManifestKey: Object.fromEntries(chunksByManifestKey),
|
||||
startupFiles: [...startupFiles],
|
||||
};
|
||||
}
|
||||
|
||||
type VisualizerReport = {
|
||||
nodeParts?: Record<string, {
|
||||
renderedLength: number;
|
||||
gzipLength: number;
|
||||
brotliLength: number;
|
||||
}>;
|
||||
nodeMetas?: Record<string, {
|
||||
id: string;
|
||||
isEntry?: boolean;
|
||||
isExternal?: boolean;
|
||||
importedBy?: string[];
|
||||
imported?: { id: string; dynamic?: boolean }[];
|
||||
moduleParts?: Record<string, string>;
|
||||
renderedLength: number;
|
||||
gzipLength: number;
|
||||
brotliLength: number;
|
||||
}>;
|
||||
options?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
|
||||
function collectVisualizerReport(data: VisualizerReport) {
|
||||
const nodeParts = data.nodeParts ?? {};
|
||||
const nodeMetas = Object.values(data.nodeMetas ?? {});
|
||||
const moduleRows = [];
|
||||
const bundleMap = new Map();
|
||||
|
||||
for (const meta of nodeMetas) {
|
||||
const row = {
|
||||
id: meta.id,
|
||||
bundles: 0,
|
||||
renderedLength: 0,
|
||||
gzipLength: 0,
|
||||
brotliLength: 0,
|
||||
importedByCount: meta.importedBy?.length ?? 0,
|
||||
importedCount: meta.imported?.length ?? 0,
|
||||
};
|
||||
|
||||
for (const [bundleId, partUid] of Object.entries(meta.moduleParts ?? {})) {
|
||||
const part = nodeParts[partUid];
|
||||
if (part == null) continue;
|
||||
|
||||
row.bundles += 1;
|
||||
row.renderedLength += part.renderedLength;
|
||||
row.gzipLength += part.gzipLength;
|
||||
row.brotliLength += part.brotliLength;
|
||||
|
||||
const bundle = bundleMap.get(bundleId) ?? {
|
||||
id: bundleId,
|
||||
modules: 0,
|
||||
renderedLength: 0,
|
||||
gzipLength: 0,
|
||||
brotliLength: 0,
|
||||
};
|
||||
bundle.modules += 1;
|
||||
bundle.renderedLength += part.renderedLength;
|
||||
bundle.gzipLength += part.gzipLength;
|
||||
bundle.brotliLength += part.brotliLength;
|
||||
bundleMap.set(bundleId, bundle);
|
||||
}
|
||||
|
||||
if (row.bundles > 0) {
|
||||
moduleRows.push(row);
|
||||
}
|
||||
}
|
||||
|
||||
let staticImports = 0;
|
||||
let dynamicImports = 0;
|
||||
for (const meta of nodeMetas) {
|
||||
for (const imported of meta.imported ?? []) {
|
||||
if (imported.dynamic) {
|
||||
dynamicImports += 1;
|
||||
} else {
|
||||
staticImports += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const bundleRows = [...bundleMap.values()].sort((a, b) => b.renderedLength - a.renderedLength);
|
||||
const hotModules = [...moduleRows].sort((a, b) => b.renderedLength - a.renderedLength);
|
||||
const totalRendered = moduleRows.reduce((sum, row) => sum + row.renderedLength, 0);
|
||||
const totalGzip = moduleRows.reduce((sum, row) => sum + row.gzipLength, 0);
|
||||
const totalBrotli = moduleRows.reduce((sum, row) => sum + row.brotliLength, 0);
|
||||
|
||||
return {
|
||||
options: data.options ?? {},
|
||||
summary: {
|
||||
bundles: bundleRows.length,
|
||||
modules: moduleRows.length,
|
||||
entries: nodeMetas.filter((meta) => meta.isEntry).length,
|
||||
externals: nodeMetas.filter((meta) => meta.isExternal).length,
|
||||
staticImports,
|
||||
dynamicImports,
|
||||
},
|
||||
metrics: {
|
||||
renderedLength: totalRendered,
|
||||
gzipLength: totalGzip,
|
||||
brotliLength: totalBrotli,
|
||||
},
|
||||
hotModules,
|
||||
};
|
||||
}
|
||||
|
||||
function renderVisualizerSummaryTable(before: ReturnType<typeof collectVisualizerReport>, after: ReturnType<typeof collectVisualizerReport>) {
|
||||
const summary = [
|
||||
'bundles',
|
||||
'modules',
|
||||
'entries',
|
||||
//'externals',
|
||||
'staticImports',
|
||||
'dynamicImports',
|
||||
] as const;
|
||||
|
||||
const metrics = [
|
||||
'renderedLength',
|
||||
'gzipLength',
|
||||
'brotliLength',
|
||||
] as const;
|
||||
|
||||
return [
|
||||
`<table>`,
|
||||
`<thead>`,
|
||||
`<tr>`,
|
||||
`<th rowspan="2"></th>`,
|
||||
`<th rowspan="2">Bundles</th>`,
|
||||
`<th rowspan="2">Modules</th>`,
|
||||
`<th rowspan="2">Entries</th>`,
|
||||
`<th colspan="2">Imports</th>`,
|
||||
`<th colspan="3">Size</th>`,
|
||||
`</tr>`,
|
||||
`<tr>`,
|
||||
`<th>Static</th>`,
|
||||
`<th>Dynamic</th>`,
|
||||
`<th>Rendered</th>`,
|
||||
`<th>Gzip</th>`,
|
||||
`<th>Brotli</th>`,
|
||||
`</tr>`,
|
||||
`</thead>`,
|
||||
`<tbody>`,
|
||||
`<tr>`,
|
||||
`<th><b>Before</b></th>`,
|
||||
...summary.map((key) => `<td>${util.formatNumber(before.summary[key])}</td>`),
|
||||
...metrics.map((key) => `<td>${util.formatBytes(before.metrics[key])}</td>`),
|
||||
`</tr>`,
|
||||
`<tr>`,
|
||||
`<th><b>After</b></th>`,
|
||||
...summary.map((key) => `<td>${util.formatNumber(after.summary[key])}</td>`),
|
||||
...metrics.map((key) => `<td>${util.formatBytes(after.metrics[key])}</td>`),
|
||||
`</tr>`,
|
||||
`<tr><td></td><td></td><td></td><td></td><td></td><td></td><td></td><td></td><td></td></tr>`,
|
||||
`<tr>`,
|
||||
`<th><b>Δ</b></th>`,
|
||||
...summary.map((key) => `<td>${util.calcAndFormatDeltaNumber(before.summary[key], after.summary[key], 0)}</td>`),
|
||||
...metrics.map((key) => `<td>${util.calcAndFormatDeltaBytes(before.metrics[key], after.metrics[key], 1000)}</td>`),
|
||||
`</tr>`,
|
||||
`<tr>`,
|
||||
`<th><b>Δ (%)</b></th>`,
|
||||
...summary.map((key) => `<td>${util.calcAndFormatDeltaPercent(before.summary[key], after.summary[key], 0.1)}</td>`),
|
||||
...metrics.map((key) => `<td>${util.calcAndFormatDeltaPercent(before.metrics[key], after.metrics[key], 0.1)}</td>`),
|
||||
`</tr>`,
|
||||
`</tbody>`,
|
||||
`</table>`,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function getChunkComparisonRows(keys: string[], before: Record<string, FileEntry>, after: Record<string, FileEntry>) {
|
||||
return keys.map(key => {
|
||||
const beforeEntry = before[key];
|
||||
const afterEntry = after[key];
|
||||
const beforeSize = beforeEntry?.size ?? 0;
|
||||
const afterSize = afterEntry?.size ?? 0;
|
||||
return {
|
||||
key,
|
||||
name: entryDisplayName(beforeEntry ?? afterEntry),
|
||||
beforeFile: beforeEntry?.file,
|
||||
afterFile: afterEntry?.file,
|
||||
beforeSize,
|
||||
afterSize,
|
||||
changeType: beforeEntry == null ? 'added' : afterEntry == null ? 'removed' : beforeSize !== afterSize ? 'updated' : 'unchanged',
|
||||
sortSize: Math.max(beforeSize, afterSize),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
type ChunkComparisonRow = ReturnType<typeof getChunkComparisonRows>[number];
|
||||
|
||||
type ChunkAggregate = {
|
||||
beforeSize: number;
|
||||
afterSize: number;
|
||||
beforeCount: number;
|
||||
afterCount: number;
|
||||
};
|
||||
|
||||
const smallDeltaThreshold = 5;
|
||||
|
||||
function sumChunkSizes(chunks: FileEntry[]) {
|
||||
return chunks.reduce((sum, chunk) => sum + chunk.size, 0);
|
||||
}
|
||||
|
||||
function generatedAggregate(before: FileEntry[], after: FileEntry[]): ChunkAggregate {
|
||||
const beforeGenerated = before.filter(chunk => chunk.comparisonKey == null);
|
||||
const afterGenerated = after.filter(chunk => chunk.comparisonKey == null);
|
||||
return {
|
||||
beforeSize: sumChunkSizes(beforeGenerated),
|
||||
afterSize: sumChunkSizes(afterGenerated),
|
||||
beforeCount: beforeGenerated.length,
|
||||
afterCount: afterGenerated.length,
|
||||
};
|
||||
}
|
||||
|
||||
function hasSmallDelta(row: ChunkComparisonRow) {
|
||||
return Math.abs(row.afterSize - row.beforeSize) <= smallDeltaThreshold;
|
||||
}
|
||||
|
||||
function comparisonRowsAggregate(rows: ChunkComparisonRow[]): ChunkAggregate {
|
||||
return {
|
||||
beforeSize: rows.reduce((sum, row) => sum + row.beforeSize, 0),
|
||||
afterSize: rows.reduce((sum, row) => sum + row.afterSize, 0),
|
||||
beforeCount: rows.filter(row => row.beforeFile != null).length,
|
||||
afterCount: rows.filter(row => row.afterFile != null).length,
|
||||
};
|
||||
}
|
||||
|
||||
function comparableMap(chunks: FileEntry[]) {
|
||||
const entries: [string, FileEntry][] = [];
|
||||
for (const chunk of chunks) {
|
||||
if (chunk.comparisonKey != null) entries.push([chunk.comparisonKey, chunk]);
|
||||
}
|
||||
return Object.fromEntries(entries);
|
||||
}
|
||||
|
||||
function summarizeChunkChanges(rows: ReturnType<typeof getChunkComparisonRows>) {
|
||||
return {
|
||||
updated: rows.filter((row) => row.changeType === 'updated').length,
|
||||
added: rows.filter((row) => row.changeType === 'added').length,
|
||||
removed: rows.filter((row) => row.changeType === 'removed').length,
|
||||
};
|
||||
}
|
||||
|
||||
function formatChunkChangeSummary(label: string, summary: ReturnType<typeof summarizeChunkChanges>) {
|
||||
return `${label} (${summary.updated} updated, ${summary.added} added, ${summary.removed} removed)`;
|
||||
}
|
||||
|
||||
function compareChunkComparisonRows(a: ChunkComparisonRow, b: ChunkComparisonRow) {
|
||||
return Math.abs(b.afterSize - b.beforeSize) - Math.abs(a.afterSize - a.beforeSize)
|
||||
|| (b.afterSize - b.beforeSize) - (a.afterSize - a.beforeSize)
|
||||
|| b.sortSize - a.sortSize
|
||||
|| a.name.localeCompare(b.name);
|
||||
}
|
||||
|
||||
function chunkFileDisplay(row: ChunkComparisonRow) {
|
||||
if (row.beforeFile == null) return row.afterFile ?? '';
|
||||
if (row.afterFile == null || row.beforeFile === row.afterFile) return row.beforeFile;
|
||||
return `${row.beforeFile} → ${row.afterFile}`;
|
||||
}
|
||||
|
||||
function chunkMarkdownTable(
|
||||
rows: ReturnType<typeof getChunkComparisonRows>,
|
||||
total?: { beforeSize: number; afterSize: number },
|
||||
generated?: ChunkAggregate,
|
||||
other?: ChunkAggregate,
|
||||
) {
|
||||
const hasGenerated = generated != null && (generated.beforeCount > 0 || generated.afterCount > 0);
|
||||
const hasOther = other != null && (other.beforeCount > 0 || other.afterCount > 0);
|
||||
if (rows.length === 0 && total == null && !hasGenerated && !hasOther) return '_No data_';
|
||||
|
||||
const lines = [
|
||||
'| Chunk | Before | After | Δ | Δ (%) |',
|
||||
'| --- | ---: | ---: | ---: | ---: |',
|
||||
];
|
||||
if (total != null) {
|
||||
lines.push(`| (total) | ${util.formatBytes(total.beforeSize)} | ${util.formatBytes(total.afterSize)} | ${util.calcAndFormatDeltaBytes(total.beforeSize, total.afterSize, 1000)} | ${util.calcAndFormatDeltaPercent(total.beforeSize, total.afterSize, 0.1).replaceAll('\\%', '\\\\%')} |`);
|
||||
lines.push('| | | | | |');
|
||||
}
|
||||
|
||||
for (const row of rows) {
|
||||
const chunkFile = chunkFileDisplay(row);
|
||||
if (row.changeType === 'added') {
|
||||
lines.push(`| <details><summary>\`${util.escapeMdTableCell(row.name)}\`</summary> \`${util.escapeMdTableCell(chunkFile)}\` </details> | ${util.formatBytes(row.beforeSize)} | ${util.formatBytes(row.afterSize)} | ${util.calcAndFormatDeltaBytes(row.beforeSize, row.afterSize, 1000)} | $\\color{orange}{\\text{( + )}}$ |`);
|
||||
} else if (row.changeType === 'removed') {
|
||||
lines.push(`| <details><summary>\`${util.escapeMdTableCell(row.name)}\`</summary> \`${util.escapeMdTableCell(chunkFile)}\` </details> | ${util.formatBytes(row.beforeSize)} | ${util.formatBytes(row.afterSize)} | ${util.calcAndFormatDeltaBytes(row.beforeSize, row.afterSize, 1000)} | $\\color{green}{\\text{( - )}}$ |`);
|
||||
} else {
|
||||
lines.push(`| <details><summary>\`${util.escapeMdTableCell(row.name)}\`</summary> \`${util.escapeMdTableCell(chunkFile)}\` </details> | ${util.formatBytes(row.beforeSize)} | ${util.formatBytes(row.afterSize)} | ${util.calcAndFormatDeltaBytes(row.beforeSize, row.afterSize, 1000)} | ${util.calcAndFormatDeltaPercent(row.beforeSize, row.afterSize, 0.1).replaceAll('\\%', '\\\\%')} |`);
|
||||
}
|
||||
}
|
||||
if (hasGenerated) {
|
||||
lines.push(`| (other generated chunks) | ${util.formatBytes(generated.beforeSize)} | ${util.formatBytes(generated.afterSize)} | ${util.calcAndFormatDeltaBytes(generated.beforeSize, generated.afterSize, 1000)} | ${util.calcAndFormatDeltaPercent(generated.beforeSize, generated.afterSize, 0.1).replaceAll('\\%', '\\\\%')} |`);
|
||||
}
|
||||
if (hasOther) {
|
||||
lines.push(`| (other) | ${util.formatBytes(other.beforeSize)} | ${util.formatBytes(other.afterSize)} | ${util.calcAndFormatDeltaBytes(other.beforeSize, other.afterSize, 1000)} | ${util.calcAndFormatDeltaPercent(other.beforeSize, other.afterSize, 0.1).replaceAll('\\%', '\\\\%')} |`);
|
||||
}
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function renderFrontendChunkReport(before: Awaited<ReturnType<typeof collectReport>>, after: Awaited<ReturnType<typeof collectReport>>) {
|
||||
const beforeComparable = before.comparableChunks;
|
||||
const afterComparable = after.comparableChunks;
|
||||
const allChunkKeys = [...new Set([...Object.keys(beforeComparable), ...Object.keys(afterComparable)])];
|
||||
const allComparisonRows = getChunkComparisonRows(allChunkKeys, beforeComparable, afterComparable);
|
||||
|
||||
const changedRows = allComparisonRows.filter((row) => row.changeType !== 'unchanged');
|
||||
const diffSummary = summarizeChunkChanges(changedRows);
|
||||
const diffTotal = {
|
||||
beforeSize: sumChunkSizes(before.chunks),
|
||||
afterSize: sumChunkSizes(after.chunks),
|
||||
};
|
||||
const diffGenerated = generatedAggregate(before.chunks, after.chunks);
|
||||
const diffOther = comparisonRowsAggregate(changedRows.filter(hasSmallDelta));
|
||||
const diffRows = changedRows.filter(row => !hasSmallDelta(row)).sort(compareChunkComparisonRows).slice(0, 30); // TODO: 実際に30を超えて切り捨てられたrowがあった場合はその旨をmarkdown内に表示するようにする
|
||||
|
||||
const beforeStartupFiles = new Set(before.startupFiles);
|
||||
const afterStartupFiles = new Set(after.startupFiles);
|
||||
const beforeStartupChunks = before.chunks.filter(chunk => beforeStartupFiles.has(chunk.file));
|
||||
const afterStartupChunks = after.chunks.filter(chunk => afterStartupFiles.has(chunk.file));
|
||||
const beforeStartupComparable = comparableMap(beforeStartupChunks);
|
||||
const afterStartupComparable = comparableMap(afterStartupChunks);
|
||||
const startupKeys = [...new Set([...Object.keys(beforeStartupComparable), ...Object.keys(afterStartupComparable)])];
|
||||
const startupComparisonRows = getChunkComparisonRows(startupKeys, beforeStartupComparable, afterStartupComparable);
|
||||
const startupSummary = summarizeChunkChanges(startupComparisonRows);
|
||||
const startupOther = comparisonRowsAggregate(startupComparisonRows.filter(hasSmallDelta));
|
||||
const startupRows = startupComparisonRows.filter(row => !hasSmallDelta(row)).sort(compareChunkComparisonRows);
|
||||
const startupTotal = {
|
||||
beforeSize: sumChunkSizes(beforeStartupChunks),
|
||||
afterSize: sumChunkSizes(afterStartupChunks),
|
||||
};
|
||||
const startupGenerated = generatedAggregate(beforeStartupChunks, afterStartupChunks);
|
||||
|
||||
return [
|
||||
'<details>',
|
||||
`<summary>${formatChunkChangeSummary('Chunk size diff', diffSummary)}</summary>`,
|
||||
'',
|
||||
chunkMarkdownTable(diffRows, diffTotal, diffGenerated, diffOther),
|
||||
'',
|
||||
'</details>',
|
||||
'',
|
||||
'<details>',
|
||||
`<summary>${formatChunkChangeSummary('Startup chunk size', startupSummary)}</summary>`,
|
||||
'',
|
||||
chunkMarkdownTable(startupRows, startupTotal, startupGenerated, startupOther),
|
||||
'',
|
||||
`_Startup chunks are the Vite entry for \`src/_boot_.ts\` and its static imports._`,
|
||||
'',
|
||||
'</details>',
|
||||
'',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
const [beforeDir, afterDir, beforeStatsFile, afterStatsFile, outFile] = args;
|
||||
const before = await collectReport(beforeDir);
|
||||
const after = await collectReport(afterDir);
|
||||
const beforeStats = JSON.parse(await fs.readFile(beforeStatsFile, 'utf8')) as VisualizerReport;
|
||||
const afterStats = JSON.parse(await fs.readFile(afterStatsFile, 'utf8')) as VisualizerReport;
|
||||
const beforeVisualizerReport = collectVisualizerReport(beforeStats);
|
||||
const afterVisualizerReport = collectVisualizerReport(afterStats);
|
||||
const visualizerArtifactLink = `[Open treemap HTML](${process.env.FRONTEND_BUNDLE_REPORT_ARTIFACT_URL})`;
|
||||
|
||||
const body = [
|
||||
`## 📦 Frontend Bundle Report`,
|
||||
'',
|
||||
renderFrontendChunkReport(before, after),
|
||||
'',
|
||||
'## Bundle Stats',
|
||||
'',
|
||||
renderVisualizerSummaryTable(beforeVisualizerReport, afterVisualizerReport),
|
||||
'',
|
||||
visualizerArtifactLink,
|
||||
].join('\n');
|
||||
|
||||
await fs.writeFile(outFile, body);
|
||||
@@ -1,520 +0,0 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
// NOTE: このファイルはworkflow上でバックエンドからも参照されるため、side effectがあってはならない
|
||||
|
||||
import * as util from './utility.mts';
|
||||
|
||||
export const heapSnapshotCategory = {
|
||||
total: { label: 'Total', color: 'gray', colorHex: '#888888' },
|
||||
code: { label: 'Code', color: 'orange', colorHex: '#f28e2c' },
|
||||
strings: { label: 'Strings', color: 'red', colorHex: '#e15759' },
|
||||
jsArrays: { label: 'JS arrays', color: 'cyan', colorHex: '#76b7b2' },
|
||||
typedArrays: { label: 'Typed arrays', color: 'green', colorHex: '#59a14f' },
|
||||
systemObjects: { label: 'System objects', color: 'yellow', colorHex: '#edc949' },
|
||||
otherJsObjects: { label: 'Other JS objs', color: 'violet', colorHex: '#af7aa1' },
|
||||
otherNonJsObjects: { label: 'Other non-JS objs', color: 'pink', colorHex: '#ff9da7' },
|
||||
} as const satisfies Record<string, { label: string; color: string; colorHex: string }>;
|
||||
|
||||
export type HeapSnapshotData = {
|
||||
categories: Record<keyof typeof heapSnapshotCategory, number>;
|
||||
nodeCounts: Record<keyof typeof heapSnapshotCategory, number>;
|
||||
breakdowns?: Record<keyof typeof heapSnapshotCategory, Record<string, number>>;
|
||||
};
|
||||
|
||||
export type HeapSnapshotReport = {
|
||||
summary: HeapSnapshotData;
|
||||
samples: {
|
||||
round: number;
|
||||
data: HeapSnapshotData;
|
||||
}[];
|
||||
};
|
||||
|
||||
export const defaultHeapSnapshotBreakdownTopN = 6;
|
||||
|
||||
export function createEmptyHeapSnapshotData(): HeapSnapshotData {
|
||||
const categories = {} as HeapSnapshotData['categories'];
|
||||
const nodeCounts = {} as HeapSnapshotData['nodeCounts'];
|
||||
for (const category of Object.keys(heapSnapshotCategory) as (keyof typeof heapSnapshotCategory)[]) {
|
||||
categories[category] = 0;
|
||||
nodeCounts[category] = 0;
|
||||
}
|
||||
return {
|
||||
categories,
|
||||
nodeCounts,
|
||||
breakdowns: {} as HeapSnapshotData['breakdowns'],
|
||||
};
|
||||
}
|
||||
|
||||
function sanitizeHeapSnapshotBreakdownLabel(value: unknown, fallback = 'unknown') {
|
||||
const label = String(value ?? '').replace(/\s+/g, ' ').trim();
|
||||
if (label === '') return fallback;
|
||||
if (label.length <= 80) return label;
|
||||
return `${label.slice(0, 77)}...`;
|
||||
}
|
||||
|
||||
function classifyHeapSnapshotBreakdown(category: keyof typeof heapSnapshotCategory, type: string, name: string) {
|
||||
if (category === 'strings') return type;
|
||||
|
||||
if (category === 'jsArrays') {
|
||||
if (type === 'array elements') return 'Array elements';
|
||||
if (type === 'object' && name === 'Array') return 'Array objects';
|
||||
return sanitizeHeapSnapshotBreakdownLabel(`${type}: ${name}`);
|
||||
}
|
||||
|
||||
if (category === 'typedArrays') {
|
||||
if (name === 'system / JSArrayBufferData') return 'ArrayBuffer data';
|
||||
return sanitizeHeapSnapshotBreakdownLabel(`${type}: ${name}`);
|
||||
}
|
||||
|
||||
if (category === 'systemObjects') {
|
||||
if (name.startsWith('system /')) return sanitizeHeapSnapshotBreakdownLabel(name);
|
||||
if (name.startsWith('(system ')) return sanitizeHeapSnapshotBreakdownLabel(name);
|
||||
return sanitizeHeapSnapshotBreakdownLabel(`${type}: ${name}`, type);
|
||||
}
|
||||
|
||||
if (category === 'otherJsObjects') {
|
||||
if (type === 'object') return sanitizeHeapSnapshotBreakdownLabel(`object: ${name}`, 'object: unknown');
|
||||
return type;
|
||||
}
|
||||
|
||||
if (category === 'otherNonJsObjects') {
|
||||
if (type === 'extra native bytes') return 'Extra native bytes';
|
||||
if (type === 'native') return sanitizeHeapSnapshotBreakdownLabel(`native: ${name}`, 'native: unknown');
|
||||
return sanitizeHeapSnapshotBreakdownLabel(`${type}: ${name}`, type);
|
||||
}
|
||||
|
||||
if (category === 'code') {
|
||||
const lowerName = name.toLowerCase();
|
||||
if (lowerName.includes('bytecode')) return 'bytecode';
|
||||
if (lowerName.includes('builtin')) return 'builtins';
|
||||
if (lowerName.includes('regexp')) return 'regexp code';
|
||||
if (lowerName.includes('stub')) return 'stubs';
|
||||
return sanitizeHeapSnapshotBreakdownLabel(`code: ${name}`, 'code: unknown');
|
||||
}
|
||||
|
||||
return sanitizeHeapSnapshotBreakdownLabel(`${type}: ${name}`, type);
|
||||
}
|
||||
|
||||
export function collapseHeapSnapshotBreakdown(breakdown: Record<string, number>, topN = defaultHeapSnapshotBreakdownTopN) {
|
||||
const entries = Object.entries(breakdown)
|
||||
.filter(([, value]) => value > 0)
|
||||
.toSorted((a, b) => b[1] - a[1]);
|
||||
|
||||
const topEntries = entries.slice(0, topN);
|
||||
const otherValue = entries
|
||||
.slice(topN)
|
||||
.reduce((sum, [, value]) => sum + value, 0);
|
||||
|
||||
const collapsed = Object.fromEntries(topEntries);
|
||||
if (otherValue > 0) collapsed.Other = otherValue;
|
||||
return collapsed;
|
||||
}
|
||||
|
||||
export function collapseHeapSnapshotBreakdowns(
|
||||
breakdowns: Partial<Record<keyof typeof heapSnapshotCategory, Record<string, number>>>,
|
||||
topN = defaultHeapSnapshotBreakdownTopN,
|
||||
) {
|
||||
const collapsed = {} as NonNullable<HeapSnapshotData['breakdowns']>;
|
||||
for (const category of Object.keys(heapSnapshotCategory) as (keyof typeof heapSnapshotCategory)[]) {
|
||||
if (category === 'total') continue;
|
||||
|
||||
const categoryBreakdown = breakdowns[category];
|
||||
if (categoryBreakdown == null) continue;
|
||||
|
||||
const collapsedCategory = collapseHeapSnapshotBreakdown(categoryBreakdown, topN);
|
||||
if (Object.keys(collapsedCategory).length > 0) {
|
||||
collapsed[category] = collapsedCategory;
|
||||
}
|
||||
}
|
||||
|
||||
return collapsed;
|
||||
}
|
||||
|
||||
// Keep these buckets aligned with Chrome DevTools' heap snapshot Statistics view.
|
||||
export function analyzeHeapSnapshot(snapshot: any, options: { breakdownTopN?: number } = {}): HeapSnapshotData {
|
||||
const meta = snapshot?.snapshot?.meta;
|
||||
const nodes = snapshot?.nodes;
|
||||
const edges = snapshot?.edges;
|
||||
const strings = snapshot?.strings;
|
||||
if (meta == null || !Array.isArray(nodes) || !Array.isArray(edges) || !Array.isArray(strings)) {
|
||||
throw new Error('Invalid heap snapshot format');
|
||||
}
|
||||
|
||||
const nodeFields = meta.node_fields;
|
||||
if (!Array.isArray(nodeFields)) throw new Error('Invalid heap snapshot node fields');
|
||||
const edgeFields = meta.edge_fields;
|
||||
if (!Array.isArray(edgeFields)) throw new Error('Invalid heap snapshot edge fields');
|
||||
|
||||
const typeOffset = nodeFields.indexOf('type');
|
||||
const nameOffset = nodeFields.indexOf('name');
|
||||
const selfSizeOffset = nodeFields.indexOf('self_size');
|
||||
const edgeCountOffset = nodeFields.indexOf('edge_count');
|
||||
if (typeOffset < 0 || nameOffset < 0 || selfSizeOffset < 0 || edgeCountOffset < 0) {
|
||||
throw new Error('Heap snapshot is missing required node fields');
|
||||
}
|
||||
const edgeTypeOffset = edgeFields.indexOf('type');
|
||||
const edgeNameOffset = edgeFields.indexOf('name_or_index');
|
||||
const edgeToNodeOffset = edgeFields.indexOf('to_node');
|
||||
if (edgeTypeOffset < 0 || edgeNameOffset < 0 || edgeToNodeOffset < 0) {
|
||||
throw new Error('Heap snapshot is missing required edge fields');
|
||||
}
|
||||
|
||||
const nodeTypeNames = meta.node_types?.[typeOffset];
|
||||
if (!Array.isArray(nodeTypeNames)) throw new Error('Invalid heap snapshot node types');
|
||||
const edgeTypeNames = meta.edge_types?.[edgeTypeOffset];
|
||||
if (!Array.isArray(edgeTypeNames)) throw new Error('Invalid heap snapshot edge types');
|
||||
|
||||
const nodeFieldCount = nodeFields.length;
|
||||
const edgeFieldCount = edgeFields.length;
|
||||
const nativeType = nodeTypeNames.indexOf('native');
|
||||
const codeType = nodeTypeNames.indexOf('code');
|
||||
const hiddenType = nodeTypeNames.indexOf('hidden');
|
||||
const stringTypes = new Set([
|
||||
nodeTypeNames.indexOf('string'),
|
||||
nodeTypeNames.indexOf('concatenated string'),
|
||||
nodeTypeNames.indexOf('sliced string'),
|
||||
]);
|
||||
const internalEdgeType = edgeTypeNames.indexOf('internal');
|
||||
const extraNativeBytes = Number.isFinite(snapshot.snapshot.extra_native_bytes) ? snapshot.snapshot.extra_native_bytes : 0;
|
||||
const { categories, nodeCounts } = createEmptyHeapSnapshotData();
|
||||
const breakdowns = {} as Record<keyof typeof heapSnapshotCategory, Record<string, number>>;
|
||||
for (const category of Object.keys(heapSnapshotCategory) as (keyof typeof heapSnapshotCategory)[]) {
|
||||
if (category !== 'total') breakdowns[category] = {};
|
||||
}
|
||||
|
||||
function addValue(map: Record<string, number>, key: string, value: number) {
|
||||
map[key] = (map[key] ?? 0) + value;
|
||||
}
|
||||
|
||||
const edgeStartIndexes = new Map<number, number>();
|
||||
const retainerCounts = new Map<number, number>();
|
||||
let edgeIndex = 0;
|
||||
for (let nodeIndex = 0; nodeIndex < nodes.length; nodeIndex += nodeFieldCount) {
|
||||
edgeStartIndexes.set(nodeIndex, edgeIndex);
|
||||
const edgeCount = nodes[nodeIndex + edgeCountOffset] ?? 0;
|
||||
for (let i = 0; i < edgeCount; i++, edgeIndex += edgeFieldCount) {
|
||||
const toNodeIndex = edges[edgeIndex + edgeToNodeOffset];
|
||||
retainerCounts.set(toNodeIndex, (retainerCounts.get(toNodeIndex) ?? 0) + 1);
|
||||
}
|
||||
}
|
||||
|
||||
const jsArrayElementNodeIndexes = new Set<number>();
|
||||
|
||||
function addCategoryValue(category: keyof typeof heapSnapshotCategory, value: number, type: string, name: string, nodeIndex: number | null = null) {
|
||||
if (value <= 0) return;
|
||||
categories[category] += value;
|
||||
addValue(breakdowns[category], classifyHeapSnapshotBreakdown(category, type, name), value);
|
||||
if (nodeIndex != null) nodeCounts[category]++;
|
||||
}
|
||||
|
||||
function addJsArrayElementSize(nodeIndex: number) {
|
||||
const beginEdgeIndex = edgeStartIndexes.get(nodeIndex) ?? 0;
|
||||
const edgeCount = nodes[nodeIndex + edgeCountOffset] ?? 0;
|
||||
for (let i = 0, currentEdgeIndex = beginEdgeIndex; i < edgeCount; i++, currentEdgeIndex += edgeFieldCount) {
|
||||
const edgeType = edges[currentEdgeIndex + edgeTypeOffset];
|
||||
if (edgeType !== internalEdgeType) continue;
|
||||
|
||||
const edgeName = strings[edges[currentEdgeIndex + edgeNameOffset]];
|
||||
if (edgeName !== 'elements') continue;
|
||||
|
||||
const elementsNodeIndex = edges[currentEdgeIndex + edgeToNodeOffset];
|
||||
if ((retainerCounts.get(elementsNodeIndex) ?? 0) === 1) {
|
||||
const elementsSize = nodes[elementsNodeIndex + selfSizeOffset] ?? 0;
|
||||
addCategoryValue('jsArrays', elementsSize, 'array elements', 'Array elements', elementsNodeIndex);
|
||||
jsArrayElementNodeIndexes.add(elementsNodeIndex);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (extraNativeBytes > 0) {
|
||||
addCategoryValue('otherNonJsObjects', extraNativeBytes, 'extra native bytes', 'extra native bytes');
|
||||
}
|
||||
|
||||
for (let nodeIndex = 0; nodeIndex < nodes.length; nodeIndex += nodeFieldCount) {
|
||||
const typeId = nodes[nodeIndex + typeOffset];
|
||||
const type = nodeTypeNames[typeId] ?? 'unknown';
|
||||
const name = strings[nodes[nodeIndex + nameOffset]] ?? '';
|
||||
const selfSize = nodes[nodeIndex + selfSizeOffset] ?? 0;
|
||||
categories.total += selfSize;
|
||||
nodeCounts.total++;
|
||||
|
||||
if (typeId === hiddenType) {
|
||||
addCategoryValue('systemObjects', selfSize, type, name, nodeIndex);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (typeId === nativeType) {
|
||||
if (name === 'system / JSArrayBufferData') {
|
||||
addCategoryValue('typedArrays', selfSize, type, name, nodeIndex);
|
||||
} else {
|
||||
addCategoryValue('otherNonJsObjects', selfSize, type, name, nodeIndex);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (typeId === codeType) {
|
||||
addCategoryValue('code', selfSize, type, name, nodeIndex);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (stringTypes.has(typeId)) {
|
||||
addCategoryValue('strings', selfSize, type, name, nodeIndex);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (name === 'Array') {
|
||||
addCategoryValue('jsArrays', selfSize, type, name, nodeIndex);
|
||||
addJsArrayElementSize(nodeIndex);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
categories.total += extraNativeBytes;
|
||||
|
||||
for (let nodeIndex = 0; nodeIndex < nodes.length; nodeIndex += nodeFieldCount) {
|
||||
if (jsArrayElementNodeIndexes.has(nodeIndex)) continue;
|
||||
|
||||
const typeId = nodes[nodeIndex + typeOffset];
|
||||
if (typeId === hiddenType || typeId === nativeType || typeId === codeType || stringTypes.has(typeId)) continue;
|
||||
|
||||
const name = strings[nodes[nodeIndex + nameOffset]] ?? '';
|
||||
if (name === 'Array') continue;
|
||||
|
||||
const type = nodeTypeNames[typeId] ?? 'unknown';
|
||||
const selfSize = nodes[nodeIndex + selfSizeOffset] ?? 0;
|
||||
addCategoryValue('otherJsObjects', selfSize, type, name, nodeIndex);
|
||||
}
|
||||
|
||||
return {
|
||||
categories,
|
||||
nodeCounts,
|
||||
breakdowns: collapseHeapSnapshotBreakdowns(breakdowns, options.breakdownTopN),
|
||||
};
|
||||
}
|
||||
|
||||
function finiteMedian(values: (number | null | undefined)[]) {
|
||||
const finiteValues = values.filter(value => Number.isFinite(value)) as number[];
|
||||
if (finiteValues.length === 0) return null;
|
||||
return util.median(finiteValues);
|
||||
}
|
||||
|
||||
export function summarizeHeapSnapshotDataSamples<T>(
|
||||
samples: T[],
|
||||
getData: (sample: T) => HeapSnapshotData | null | undefined,
|
||||
options: { breakdownTopN?: number } = {},
|
||||
) {
|
||||
const data = samples.map(getData);
|
||||
const categories = {} as HeapSnapshotData['categories'];
|
||||
for (const category of Object.keys(heapSnapshotCategory) as (keyof typeof heapSnapshotCategory)[]) {
|
||||
const value = finiteMedian(data.map(snapshot => snapshot?.categories?.[category]));
|
||||
if (value != null) categories[category] = value;
|
||||
}
|
||||
|
||||
const nodeCounts = {} as HeapSnapshotData['nodeCounts'];
|
||||
for (const category of Object.keys(heapSnapshotCategory) as (keyof typeof heapSnapshotCategory)[]) {
|
||||
const value = finiteMedian(data.map(snapshot => snapshot?.nodeCounts?.[category]));
|
||||
if (value != null) nodeCounts[category] = value;
|
||||
}
|
||||
|
||||
if (Object.keys(categories).length === 0) return null;
|
||||
|
||||
const breakdowns = {} as NonNullable<HeapSnapshotData['breakdowns']>;
|
||||
for (const category of Object.keys(heapSnapshotCategory) as (keyof typeof heapSnapshotCategory)[]) {
|
||||
if (category === 'total') continue;
|
||||
|
||||
const childKeys = new Set<string>();
|
||||
for (const snapshot of data) {
|
||||
for (const childKey of Object.keys(snapshot?.breakdowns?.[category] ?? {})) {
|
||||
childKeys.add(childKey);
|
||||
}
|
||||
}
|
||||
|
||||
const categoryBreakdown = {} as Record<string, number>;
|
||||
for (const childKey of childKeys) {
|
||||
const value = finiteMedian(data.map(snapshot => snapshot?.breakdowns?.[category]?.[childKey]));
|
||||
if (value != null) categoryBreakdown[childKey] = value;
|
||||
}
|
||||
|
||||
const collapsed = collapseHeapSnapshotBreakdown(categoryBreakdown, options.breakdownTopN);
|
||||
if (Object.keys(collapsed).length > 0) {
|
||||
breakdowns[category] = collapsed;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
categories,
|
||||
nodeCounts,
|
||||
...(Object.keys(breakdowns).length > 0 ? { breakdowns } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function getHeapSnapshotCategoryValue(report: HeapSnapshotReport, category: keyof typeof heapSnapshotCategory) {
|
||||
return report.summary.categories[category];
|
||||
}
|
||||
|
||||
export function renderHeapSnapshotTable(base: HeapSnapshotReport, head: HeapSnapshotReport) {
|
||||
const lines = [
|
||||
'| Metric | Base | Head | Δ median | Δ MAD | Δ min | Δ max |',
|
||||
'| --- | ---: | ---: | ---: | ---: | ---: | ---: |',
|
||||
];
|
||||
const baseTotal = getHeapSnapshotCategoryValue(base, 'total');
|
||||
const headTotal = getHeapSnapshotCategoryValue(head, 'total');
|
||||
|
||||
function getHeapSnapshotSampleSpread(report: HeapSnapshotReport, category: keyof typeof heapSnapshotCategory) {
|
||||
const values = report.samples
|
||||
.map(sample => sample.data.categories[category])
|
||||
.filter(value => Number.isFinite(value)) as number[];
|
||||
if (values.length < 2) throw new Error(`Not enough samples for category ${category}`);
|
||||
|
||||
const center = util.median(values);
|
||||
return util.median(values.map(value => Math.abs(value - center)));
|
||||
}
|
||||
|
||||
for (const category of Object.keys(heapSnapshotCategory) as (keyof typeof heapSnapshotCategory)[]) {
|
||||
const baseValue = getHeapSnapshotCategoryValue(base, category);
|
||||
const headValue = getHeapSnapshotCategoryValue(head, category);
|
||||
const baseSpread = getHeapSnapshotSampleSpread(base, category);
|
||||
const headSpread = getHeapSnapshotSampleSpread(head, category);
|
||||
const summary = util.pairedDeltaSummary(base.samples, head.samples, (sample) => sample.data.categories[category]);
|
||||
const percent = summary.median * 100 / baseValue;
|
||||
|
||||
if (category === 'total') {
|
||||
const deltaMedian = `${util.formatDeltaBytes(summary.median, 100000)}<br>${util.formatDeltaPercent(percent, 0.1).replaceAll('\\%', '\\\\%')}`;
|
||||
const baseText = `${util.formatBytes(baseValue)} <br> ± ${util.formatBytes(baseSpread)}`;
|
||||
const headText = `${util.formatBytes(headValue)} <br> ± ${util.formatBytes(headSpread)}`;
|
||||
const metricText = `$\\color{${heapSnapshotCategory[category].color}}{\\rule{8pt}{8pt}}$ **${heapSnapshotCategory[category].label}**`;
|
||||
lines.push(`| ${metricText} | ${baseText} | ${headText} | ${deltaMedian} | ${util.formatBytes(summary.mad)} | ${util.formatDeltaBytes(summary.min, 100000)} | ${util.formatDeltaBytes(summary.max, 100000)} |`);
|
||||
lines.push('| | | | | | | |');
|
||||
} else {
|
||||
const deltaMedian = util.formatDeltaBytes(summary.median, 100000);
|
||||
const baseText = util.formatBytes(baseValue);
|
||||
const headText = util.formatBytes(headValue);
|
||||
const basePercent = util.formatPercent((baseValue * 100) / baseTotal);
|
||||
const headPercent = util.formatPercent((headValue * 100) / headTotal);
|
||||
const metricText = `<details><summary>$\\color{${heapSnapshotCategory[category].color}}{\\rule{8pt}{8pt}}$ **${heapSnapshotCategory[category].label}**</summary>${basePercent} → ${headPercent}</details>`;
|
||||
lines.push(`| ${metricText} | ${baseText} | ${headText} | ${deltaMedian} | ${util.formatBytes(summary.mad)} | ${util.formatDeltaBytes(summary.min, 100000)} | ${util.formatDeltaBytes(summary.max, 100000)} |`);
|
||||
}
|
||||
}
|
||||
|
||||
if (lines.length === 2) return null;
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
const heapSnapshotSankeyChildMinRatio = 0.3;
|
||||
const heapSnapshotSankeyParentMinPercent = 10;
|
||||
|
||||
function escapeCsvValue(value: string) {
|
||||
return `"${String(value).replaceAll('"', '""')}"`;
|
||||
}
|
||||
|
||||
export function renderHeapSnapshotSankey(report: HeapSnapshotReport, title: string) {
|
||||
const total = getHeapSnapshotCategoryValue(report, 'total');
|
||||
if (total == null || total <= 0) return null;
|
||||
|
||||
function getHeapSnapshotBreakdownEntries(category: keyof typeof heapSnapshotCategory) {
|
||||
const breakdown = report.summary.breakdowns?.[category];
|
||||
if (breakdown == null || typeof breakdown !== 'object') return [];
|
||||
|
||||
return Object.entries(breakdown)
|
||||
.filter(([, value]) => Number.isFinite(value) && value > 0)
|
||||
.toSorted((a, b) => b[1] - a[1]);
|
||||
}
|
||||
|
||||
function formatHeapSnapshotSankeyChildLabel(label: string) {
|
||||
return String(label).replace(/^[^:]+:\s*/, '');
|
||||
}
|
||||
|
||||
function formatSankeyPercentValue(value: number) {
|
||||
const rounded = Math.round(value * 100) / 100;
|
||||
if (rounded === 0 && value > 0) return '0.01';
|
||||
if (Number.isInteger(rounded)) return String(rounded);
|
||||
return rounded.toFixed(2).replace(/0+$/, '').replace(/\.$/, '');
|
||||
}
|
||||
|
||||
const categories = (Object.keys(heapSnapshotCategory) as (keyof typeof heapSnapshotCategory)[])
|
||||
.filter(category => category !== 'total')
|
||||
.map(category => {
|
||||
const value = getHeapSnapshotCategoryValue(report, category);
|
||||
if (value == null || value <= 0) return null;
|
||||
const breakdownEntries = getHeapSnapshotBreakdownEntries(category);
|
||||
const breakdownTotal = breakdownEntries.reduce((sum, [, childValue]) => sum + childValue, 0);
|
||||
const percent = (value * 100) / total;
|
||||
const childEntries = [];
|
||||
let otherPercent = 0;
|
||||
|
||||
if (breakdownTotal > 0 && percent > heapSnapshotSankeyParentMinPercent) {
|
||||
for (const [childName, childValue] of breakdownEntries) {
|
||||
const childRatio = childValue / breakdownTotal;
|
||||
const childPercent = percent * childRatio;
|
||||
if (childRatio >= heapSnapshotSankeyChildMinRatio) {
|
||||
childEntries.push([formatHeapSnapshotSankeyChildLabel(childName), childPercent]);
|
||||
} else {
|
||||
otherPercent += childPercent;
|
||||
}
|
||||
}
|
||||
|
||||
if (childEntries.length > 0 && otherPercent > 0) {
|
||||
childEntries.push(['Other', otherPercent]);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
category,
|
||||
percent,
|
||||
childEntries,
|
||||
};
|
||||
})
|
||||
.filter(value => value != null);
|
||||
|
||||
if (categories.length === 0) return null;
|
||||
|
||||
const nodeColors = {
|
||||
[title]: heapSnapshotCategory.total.colorHex,
|
||||
} as Record<string, string>;
|
||||
for (const { category, childEntries } of categories) {
|
||||
const categoryColor = heapSnapshotCategory[category].colorHex;
|
||||
nodeColors[category] = categoryColor;
|
||||
|
||||
for (const [childName] of childEntries) {
|
||||
nodeColors[childName] = categoryColor;
|
||||
}
|
||||
}
|
||||
|
||||
const lines = [
|
||||
`<details><summary>${title} heap snapshot composition</summary>`,
|
||||
'',
|
||||
'```mermaid',
|
||||
`%%{init: ${JSON.stringify({
|
||||
sankey: {
|
||||
showValues: false,
|
||||
linkColor: 'target',
|
||||
labelStyle: 'outlined',
|
||||
nodeAlignment: 'center',
|
||||
nodePadding: 10,
|
||||
nodeColors: {
|
||||
...nodeColors,
|
||||
'Other': '#888888',
|
||||
},
|
||||
},
|
||||
})}}%%`,
|
||||
'sankey-beta',
|
||||
];
|
||||
|
||||
for (const { category, percent, childEntries } of categories) {
|
||||
lines.push(`${escapeCsvValue(title)},${escapeCsvValue(heapSnapshotCategory[category].label)},${formatSankeyPercentValue(percent)}`);
|
||||
|
||||
for (const [childName, childPercent] of childEntries) {
|
||||
lines.push(`${escapeCsvValue(heapSnapshotCategory[category].label)},${escapeCsvValue(childName)},${formatSankeyPercentValue(childPercent)}`);
|
||||
}
|
||||
}
|
||||
|
||||
lines.push('```');
|
||||
lines.push('');
|
||||
lines.push('</details>');
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
@@ -1,311 +0,0 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
// NOTE: このファイルはworkflow上でバックエンドからも参照されるため、side effectがあってはならない
|
||||
|
||||
import { spawn, spawnSync, type ChildProcessWithoutNullStreams } from 'node:child_process';
|
||||
import { promises as fs } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
export function sleep(ms: number) {
|
||||
return new Promise(resolvePromise => setTimeout(resolvePromise, ms));
|
||||
}
|
||||
|
||||
export function median(values: number[]) {
|
||||
const sorted = values.toSorted((a, b) => a - b);
|
||||
const center = Math.floor(sorted.length / 2);
|
||||
if (sorted.length % 2 === 1) return sorted[center];
|
||||
return Math.round((sorted[center - 1] + sorted[center]) / 2);
|
||||
}
|
||||
|
||||
export function mad(values: number[]) {
|
||||
if (values.length < 2) throw new Error('Not enough samples to calculate MAD');
|
||||
|
||||
const center = median(values);
|
||||
return median(values.map(value => Math.abs(value - center)));
|
||||
}
|
||||
|
||||
function getSamplesByRound<T extends { round: number; }[]>(samples: T) {
|
||||
const samplesByRound = new Map<number, T[number]>();
|
||||
for (const sample of samples) {
|
||||
if (sample.round <= 0) continue;
|
||||
samplesByRound.set(sample.round, sample);
|
||||
}
|
||||
return samplesByRound;
|
||||
}
|
||||
|
||||
export function pairedDeltaSummary<T extends { round: number; }[]>(baseSamples: T, headSamples: T, getValue: (sample: T[number]) => number | null) {
|
||||
const baseSamplesByRound = getSamplesByRound(baseSamples);
|
||||
const headSamplesByRound = getSamplesByRound(headSamples);
|
||||
const values = [];
|
||||
|
||||
for (const [round, baseSample] of baseSamplesByRound) {
|
||||
const headSample = headSamplesByRound.get(round);
|
||||
if (headSample == null) continue;
|
||||
|
||||
const baseValue = getValue(baseSample);
|
||||
const headValue = getValue(headSample);
|
||||
if (baseValue == null || headValue == null) continue;
|
||||
|
||||
values.push(headValue - baseValue);
|
||||
}
|
||||
|
||||
return {
|
||||
median: median(values),
|
||||
mad: mad(values),
|
||||
min: Math.min(...values),
|
||||
max: Math.max(...values),
|
||||
samples: values.length,
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizePath(filePath: string) {
|
||||
return filePath.split(path.sep).join('/');
|
||||
}
|
||||
|
||||
export async function fileExists(filePath: string) {
|
||||
try {
|
||||
await fs.access(filePath);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function fileSize(filePath: string) {
|
||||
const stat = await fs.stat(filePath);
|
||||
return stat.size;
|
||||
}
|
||||
|
||||
export async function* traverseDirectory(dir: string): AsyncGenerator<string> {
|
||||
for (const entry of await fs.readdir(dir, { withFileTypes: true })) {
|
||||
const fullPath = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
yield* traverseDirectory(fullPath);
|
||||
} else if (entry.isFile()) {
|
||||
yield fullPath;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function escapeLatex(text: string) {
|
||||
return text
|
||||
.replaceAll('\\', '\\\\')
|
||||
.replaceAll('{', '\\{')
|
||||
.replaceAll('}', '\\}')
|
||||
.replaceAll('%', '\\%');
|
||||
}
|
||||
|
||||
export function escapeMdTableCell(value: string) {
|
||||
return String(value).replaceAll('|', '\\|').replaceAll('\n', '<br>');
|
||||
}
|
||||
|
||||
export function formatMs(value: number | null | undefined) {
|
||||
if (value == null || !Number.isFinite(value)) return '-';
|
||||
if (value >= 1_000) return `${formatNumber(value / 1_000)} s`;
|
||||
return `${formatNumber(value)} ms`;
|
||||
}
|
||||
|
||||
export function formatSecondsAsMs(value: number | null | undefined) {
|
||||
if (value == null || !Number.isFinite(value)) return '-';
|
||||
return formatMs(value * 1_000);
|
||||
}
|
||||
|
||||
export function formatColoredDelta(delta: number, text: (value: number) => string, colorThreshold = 0) {
|
||||
if (delta === 0) return text(0);
|
||||
const sign = delta > 0 ? '+' : '-';
|
||||
if (Math.abs(delta) < colorThreshold) return `$\\text{${sign}${escapeLatex(text(Math.abs(delta)))}}$`;
|
||||
const color = delta > 0 ? 'orange' : 'green';
|
||||
return `$\\color{${color}}{\\text{${sign}${escapeLatex(text(Math.abs(delta)))}}}$`;
|
||||
}
|
||||
|
||||
const numberFormatter = new Intl.NumberFormat('en-US', {
|
||||
maximumFractionDigits: 1,
|
||||
});
|
||||
|
||||
export function formatNumber(value: number) {
|
||||
return numberFormatter.format(value);
|
||||
}
|
||||
|
||||
export function formatBytes(value: number) {
|
||||
if (value === 0) return '0 B';
|
||||
const units = ['B', 'KB', 'MB', 'GB'];
|
||||
let unitIndex = 0;
|
||||
let size = value;
|
||||
while (size >= 1000 && unitIndex < units.length - 1) {
|
||||
size /= 1000;
|
||||
unitIndex += 1;
|
||||
}
|
||||
|
||||
const maximumFractionDigits = size >= 10 || unitIndex === 0 ? 0 : 1;
|
||||
return `${numberFormatter.format(Number(size.toFixed(maximumFractionDigits)))} ${units[unitIndex]}`;
|
||||
}
|
||||
|
||||
export function calcAndFormatDeltaNumber(before: number, after: number, colorThreshold = 0) {
|
||||
if (before == null || after == null) return '-';
|
||||
const delta = after - before;
|
||||
return formatColoredDelta(delta, v => formatNumber(v), colorThreshold);
|
||||
}
|
||||
|
||||
export function formatDeltaBytes(deltaBytes: number, colorThreshold = 0) {
|
||||
return formatColoredDelta(deltaBytes, v => formatBytes(v), colorThreshold);
|
||||
}
|
||||
|
||||
export function calcAndFormatDeltaBytes(before: number, after: number, colorThreshold = 0) {
|
||||
if (before == null || after == null) return '-';
|
||||
const delta = after - before;
|
||||
return formatDeltaBytes(delta, colorThreshold);
|
||||
}
|
||||
|
||||
export function formatPercent(value: number) {
|
||||
return `${formatNumber(value)}%`;
|
||||
}
|
||||
|
||||
export function formatDeltaPercent(deltaPercent: number, colorThreshold = 0) {
|
||||
return formatColoredDelta(deltaPercent, v => formatPercent(v), colorThreshold);
|
||||
}
|
||||
|
||||
export function calcAndFormatDeltaPercent(before: number, after: number, colorThreshold = 0) {
|
||||
if (before == null || before === 0 || after == null || after === 0) return '-';
|
||||
const delta = after - before;
|
||||
return formatDeltaPercent(delta / before * 100, colorThreshold);
|
||||
}
|
||||
|
||||
export function commandName(command: string) {
|
||||
if (process.platform !== 'win32') return command;
|
||||
if (command === 'pnpm') return 'pnpm.cmd';
|
||||
return command;
|
||||
}
|
||||
|
||||
export function readIntegerEnv(name: string, defaultValue: number, min: number) {
|
||||
const rawValue = process.env[name];
|
||||
if (rawValue == null || rawValue === '') return defaultValue;
|
||||
if (!/^\d+$/.test(rawValue)) throw new Error(`${name} must be an integer`);
|
||||
|
||||
const value = Number(rawValue);
|
||||
if (!Number.isSafeInteger(value) || value < min) throw new Error(`${name} must be >= ${min}`);
|
||||
return value;
|
||||
}
|
||||
|
||||
export function run(command: string, args: string[], options: { cwd?: string; env?: NodeJS.ProcessEnv; logStdout?: boolean } = {}) {
|
||||
return new Promise<string>((resolvePromise, reject) => {
|
||||
const child = spawn(commandName(command), args, {
|
||||
cwd: options.cwd,
|
||||
env: options.env,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
|
||||
child.stdout.on('data', data => {
|
||||
stdout += data;
|
||||
if (options.logStdout) process.stderr.write(data);
|
||||
});
|
||||
|
||||
child.stderr.on('data', data => {
|
||||
stderr += data;
|
||||
process.stderr.write(data);
|
||||
});
|
||||
|
||||
child.on('error', reject);
|
||||
|
||||
child.on('close', code => {
|
||||
if (code === 0) {
|
||||
resolvePromise(stdout);
|
||||
} else {
|
||||
reject(new Error(`${command} ${args.join(' ')} failed with exit code ${code}\n${stderr}`));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function startServer(label: string, repoDir: string) {
|
||||
process.stderr.write(`[${label}] Starting Misskey test server\n`);
|
||||
const child = spawn(commandName('pnpm'), ['start:test'], {
|
||||
cwd: repoDir,
|
||||
env: process.env,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
detached: process.platform !== 'win32',
|
||||
});
|
||||
child.stdout.on('data', data => process.stderr.write(`[server:${label}] ${data}`));
|
||||
child.stderr.on('data', data => process.stderr.write(`[server:${label}] ${data}`));
|
||||
return child;
|
||||
}
|
||||
|
||||
export async function waitForServer(baseUrl: string, child: ChildProcessWithoutNullStreams) {
|
||||
const startedAt = Date.now();
|
||||
while (Date.now() - startedAt < 120_000) {
|
||||
if (child.exitCode != null) throw new Error(`Misskey server exited early with code ${child.exitCode}`);
|
||||
try {
|
||||
const response = await fetch(`${baseUrl}/`, { redirect: 'manual' });
|
||||
if (response.status < 500) return;
|
||||
} catch {
|
||||
// retry
|
||||
}
|
||||
await sleep(1_000);
|
||||
}
|
||||
throw new Error(`Timed out waiting for ${baseUrl}`);
|
||||
}
|
||||
|
||||
export async function api(baseUrl: string, endpoint: string, body: Record<string, unknown>) {
|
||||
const response = await fetch(`${baseUrl}/api/${endpoint}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`/api/${endpoint} returned ${response.status}: ${await response.text()}`);
|
||||
}
|
||||
if (response.status === 204) return null;
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
export async function prepareInstance(baseUrl: string) {
|
||||
await api(baseUrl, 'reset-db', {});
|
||||
await api(baseUrl, 'admin/accounts/create', {
|
||||
username: 'admin',
|
||||
password: 'admin1234',
|
||||
setupPassword: 'example_password_please_change_this_or_you_will_get_hacked',
|
||||
});
|
||||
}
|
||||
|
||||
export async function stopServer(child: ChildProcessWithoutNullStreams) {
|
||||
if (child.exitCode != null) return;
|
||||
|
||||
if (process.platform === 'win32') {
|
||||
spawnSync('taskkill', ['/pid', String(child.pid), '/t', '/f'], { stdio: 'ignore' });
|
||||
} else if (child.pid != null) {
|
||||
try {
|
||||
process.kill(-child.pid, 'SIGTERM');
|
||||
} catch {
|
||||
child.kill('SIGTERM');
|
||||
}
|
||||
}
|
||||
|
||||
await new Promise<void>(resolvePromise => {
|
||||
if (child.exitCode != null) {
|
||||
resolvePromise();
|
||||
return;
|
||||
}
|
||||
child.once('exit', () => resolvePromise());
|
||||
setTimeout(() => {
|
||||
if (child.pid != null) {
|
||||
try {
|
||||
if (process.platform === 'win32') {
|
||||
spawnSync('taskkill', ['/pid', String(child.pid), '/t', '/f'], { stdio: 'ignore' });
|
||||
} else {
|
||||
process.kill(-child.pid, 'SIGKILL');
|
||||
}
|
||||
} catch {
|
||||
child.kill('SIGKILL');
|
||||
}
|
||||
}
|
||||
resolvePromise();
|
||||
}, 10_000).unref();
|
||||
});
|
||||
}
|
||||
@@ -16,13 +16,13 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6.0.3
|
||||
uses: actions/checkout@v7.0.0
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v6.0.9
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6.4.0
|
||||
uses: actions/setup-node@v7.0.0
|
||||
with:
|
||||
node-version-file: '.node-version'
|
||||
cache: 'pnpm'
|
||||
|
||||
@@ -17,7 +17,17 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6.0.3
|
||||
uses: actions/checkout@v7.0.0
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v6.0.9
|
||||
- name: Use Node.js
|
||||
uses: actions/setup-node@v7.0.0
|
||||
with:
|
||||
node-version-file: '.node-version'
|
||||
cache: 'pnpm'
|
||||
- name: Install dependencies
|
||||
run: pnpm --filter diagnostics-backend... install --frozen-lockfile
|
||||
|
||||
- name: Download artifacts
|
||||
uses: actions/download-artifact@v8
|
||||
@@ -60,7 +70,11 @@ jobs:
|
||||
env:
|
||||
MK_MEMORY_HEAP_SNAPSHOT_ARTIFACT_URL_BASE: ${{ steps.find-heap-snapshot-artifacts.outputs.base-url }}
|
||||
MK_MEMORY_HEAP_SNAPSHOT_ARTIFACT_URL_HEAD: ${{ steps.find-heap-snapshot-artifacts.outputs.head-url }}
|
||||
run: node .github/scripts/backend-diagnostics.render-md.mts ./artifacts/memory-base.json ./artifacts/memory-head.json ./output.md
|
||||
run: >-
|
||||
pnpm --filter diagnostics-backend run render-md
|
||||
"$GITHUB_WORKSPACE/artifacts/memory-base.json"
|
||||
"$GITHUB_WORKSPACE/artifacts/memory-head.json"
|
||||
"$GITHUB_WORKSPACE/output.md"
|
||||
- uses: thollander/actions-comment-pull-request@v3
|
||||
with:
|
||||
pr-number: ${{ steps.load-pr-num.outputs.pr-number }}
|
||||
|
||||
@@ -9,10 +9,8 @@ on:
|
||||
paths:
|
||||
- packages/backend/**
|
||||
- packages/misskey-js/**
|
||||
- .github/scripts/utility.mts
|
||||
- .github/scripts/backend-diagnostics.render-md.mts
|
||||
- .github/scripts/backend-diagnostics.inspect.mts
|
||||
- .github/scripts/memory-stability-util*.mts
|
||||
- packages-private/diagnostics-shared/**
|
||||
- packages-private/diagnostics-backend/**
|
||||
- .github/workflows/backend-diagnostics.inspect.yml
|
||||
- .github/workflows/backend-diagnostics.comment.yml
|
||||
|
||||
@@ -37,13 +35,13 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout base
|
||||
uses: actions/checkout@v6.0.3
|
||||
uses: actions/checkout@v7.0.0
|
||||
with:
|
||||
ref: ${{ github.base_ref }}
|
||||
path: base
|
||||
submodules: true
|
||||
- name: Checkout head
|
||||
uses: actions/checkout@v6.0.3
|
||||
uses: actions/checkout@v7.0.0
|
||||
with:
|
||||
ref: refs/pull/${{ github.event.number }}/merge
|
||||
path: head
|
||||
@@ -53,7 +51,7 @@ jobs:
|
||||
with:
|
||||
package_json_file: head/package.json
|
||||
- name: Use Node.js
|
||||
uses: actions/setup-node@v6.4.0
|
||||
uses: actions/setup-node@v7.0.0
|
||||
with:
|
||||
node-version-file: 'head/.node-version'
|
||||
cache: 'pnpm'
|
||||
@@ -89,11 +87,18 @@ jobs:
|
||||
working-directory: head
|
||||
run: pnpm build
|
||||
- name: Measure backend memory usage
|
||||
working-directory: head
|
||||
env:
|
||||
MK_MEMORY_COMPARE_ROUNDS: 10
|
||||
MK_MEMORY_COMPARE_ROUNDS: 15
|
||||
MK_MEMORY_COMPARE_WARMUP_ROUNDS: 1
|
||||
MK_MEMORY_HEAP_SNAPSHOT: 1
|
||||
run: node head/.github/scripts/backend-diagnostics.inspect.mts base head memory-base.json memory-head.json
|
||||
MK_MEMORY_HEAP_SNAPSHOT_ROUNDS: 3
|
||||
# 計測ハーネスはhead側のものを両方に使うが、成果物はrunnerのworkspace直下に出す
|
||||
MK_MEMORY_HEAP_SNAPSHOT_OUTPUT_DIR: ${{ github.workspace }}
|
||||
run: >-
|
||||
pnpm --filter diagnostics-backend run inspect
|
||||
"$GITHUB_WORKSPACE/base" "$GITHUB_WORKSPACE/head"
|
||||
"$GITHUB_WORKSPACE/memory-base.json" "$GITHUB_WORKSPACE/memory-head.json"
|
||||
- name: Upload base heap snapshot
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
|
||||
@@ -12,11 +12,16 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout head
|
||||
uses: actions/checkout@v6.0.3
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6.4.0
|
||||
uses: actions/checkout@v7.0.0
|
||||
|
||||
- name: setup pnpm
|
||||
uses: pnpm/action-setup@v6
|
||||
|
||||
- name: setup node
|
||||
uses: actions/setup-node@v7.0.0
|
||||
with:
|
||||
node-version-file: '.node-version'
|
||||
cache: pnpm
|
||||
|
||||
- name: Checkout base
|
||||
run: |
|
||||
@@ -27,17 +32,16 @@ jobs:
|
||||
git checkout origin/${{ github.base_ref }} CHANGELOG.md
|
||||
|
||||
- name: Copy to Checker directory for CHANGELOG-base.md
|
||||
run: cp _base/CHANGELOG.md scripts/changelog-checker/CHANGELOG-base.md
|
||||
run: cp _base/CHANGELOG.md packages-private/changelog-checker/CHANGELOG-base.md
|
||||
- name: Copy to Checker directory for CHANGELOG-head.md
|
||||
run: cp CHANGELOG.md scripts/changelog-checker/CHANGELOG-head.md
|
||||
run: cp CHANGELOG.md packages-private/changelog-checker/CHANGELOG-head.md
|
||||
- name: diff
|
||||
continue-on-error: true
|
||||
run: diff -u CHANGELOG-base.md CHANGELOG-head.md
|
||||
working-directory: scripts/changelog-checker
|
||||
working-directory: packages-private/changelog-checker
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm --filter changelog-checker... install --frozen-lockfile
|
||||
|
||||
- name: Setup Checker
|
||||
run: npm install
|
||||
working-directory: scripts/changelog-checker
|
||||
- name: Run Checker
|
||||
run: npm run run
|
||||
working-directory: scripts/changelog-checker
|
||||
run: pnpm --filter changelog-checker check
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
name: Check Misskey JS autogen (comment)
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
types: [completed]
|
||||
workflows:
|
||||
- Check Misskey JS autogen # check-misskey-js-autogen.yml
|
||||
|
||||
jobs:
|
||||
comment-misskey-js-autogen:
|
||||
runs-on: ubuntu-latest
|
||||
# No code is cloned or executed here, so it is safe to hold pull-requests: write
|
||||
if: ${{ github.event.workflow_run.event == 'pull_request' }}
|
||||
permissions:
|
||||
actions: read
|
||||
pull-requests: write
|
||||
|
||||
steps:
|
||||
# The result is missing when the run failed before the check job (e.g. the PR does not build), so do not fail here
|
||||
- name: Download result
|
||||
id: download-result
|
||||
continue-on-error: true
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: misskey-js-autogen-result
|
||||
path: result
|
||||
github-token: ${{ github.token }}
|
||||
repository: ${{ github.repository }}
|
||||
run-id: ${{ github.event.workflow_run.id }}
|
||||
|
||||
# The artifact comes from a run that built fork code, so validate it before using
|
||||
- name: Load result
|
||||
id: load-result
|
||||
if: steps.download-result.outcome == 'success'
|
||||
run: |
|
||||
pr_number="$(cat result/pr-number)"
|
||||
changes="$(cat result/changes)"
|
||||
|
||||
case "$pr_number" in
|
||||
''|*[!0-9]*) echo "invalid pr number"; exit 1;;
|
||||
esac
|
||||
case "$changes" in
|
||||
true|false) ;;
|
||||
*) echo "invalid result"; exit 1;;
|
||||
esac
|
||||
|
||||
echo "pr-number=$pr_number" >> "$GITHUB_OUTPUT"
|
||||
echo "changes=$changes" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: send message
|
||||
if: steps.load-result.outputs.changes == 'true'
|
||||
uses: thollander/actions-comment-pull-request@v3
|
||||
with:
|
||||
pr-number: ${{ steps.load-result.outputs.pr-number }}
|
||||
comment-tag: check-misskey-js-autogen
|
||||
message: |-
|
||||
Thank you for sending us a great Pull Request! 👍
|
||||
Please regenerate misskey-js type definitions! 🙏
|
||||
|
||||
example:
|
||||
```sh
|
||||
pnpm run build-misskey-js-with-types
|
||||
```
|
||||
|
||||
- name: send message
|
||||
if: steps.load-result.outputs.changes == 'false'
|
||||
uses: thollander/actions-comment-pull-request@v3
|
||||
with:
|
||||
pr-number: ${{ steps.load-result.outputs.pr-number }}
|
||||
comment-tag: check-misskey-js-autogen
|
||||
mode: delete
|
||||
message: "Thank you!"
|
||||
create_if_not_exists: false
|
||||
@@ -1,7 +1,8 @@
|
||||
# this name is used in check-misskey-js-autogen.comment.yml so be careful when change name
|
||||
name: Check Misskey JS autogen
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
pull_request:
|
||||
branches:
|
||||
- master
|
||||
- develop
|
||||
@@ -10,7 +11,6 @@ on:
|
||||
- packages/backend/**
|
||||
|
||||
jobs:
|
||||
# pull_request_target safety: permissions: read-all, and there are no secrets used in this job
|
||||
generate-misskey-js:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
@@ -18,7 +18,7 @@ jobs:
|
||||
if: ${{ github.event.pull_request.mergeable == null || github.event.pull_request.mergeable == true }}
|
||||
steps:
|
||||
- name: checkout
|
||||
uses: actions/checkout@v6.0.3
|
||||
uses: actions/checkout@v7.0.0
|
||||
with:
|
||||
submodules: true
|
||||
persist-credentials: false
|
||||
@@ -29,7 +29,7 @@ jobs:
|
||||
|
||||
- name: setup node
|
||||
id: setup-node
|
||||
uses: actions/setup-node@v6.4.0
|
||||
uses: actions/setup-node@v7.0.0
|
||||
with:
|
||||
node-version-file: '.node-version'
|
||||
cache: pnpm
|
||||
@@ -58,7 +58,6 @@ jobs:
|
||||
name: generated-misskey-js
|
||||
path: packages/misskey-js/generator/built/autogen
|
||||
|
||||
# pull_request_target safety: permissions: read-all, and no user codes are executed
|
||||
get-actual-misskey-js:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
@@ -66,7 +65,7 @@ jobs:
|
||||
if: ${{ github.event.pull_request.mergeable == null || github.event.pull_request.mergeable == true }}
|
||||
steps:
|
||||
- name: checkout
|
||||
uses: actions/checkout@v6.0.3
|
||||
uses: actions/checkout@v7.0.0
|
||||
with:
|
||||
submodules: true
|
||||
persist-credentials: false
|
||||
@@ -78,12 +77,11 @@ jobs:
|
||||
name: actual-misskey-js
|
||||
path: packages/misskey-js/src/autogen
|
||||
|
||||
# pull_request_target safety: nothing is cloned from repository
|
||||
comment-misskey-js-autogen:
|
||||
check-misskey-js-autogen:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [generate-misskey-js, get-actual-misskey-js]
|
||||
permissions:
|
||||
pull-requests: write
|
||||
contents: read
|
||||
steps:
|
||||
- name: download generated-misskey-js
|
||||
uses: actions/download-artifact@v8
|
||||
@@ -111,28 +109,21 @@ jobs:
|
||||
- name: Print full diff
|
||||
run: cat ./misskey-js.diff
|
||||
|
||||
- name: send message
|
||||
if: steps.check-changes.outputs.changes == 'true'
|
||||
uses: thollander/actions-comment-pull-request@v3
|
||||
with:
|
||||
comment-tag: check-misskey-js-autogen
|
||||
message: |-
|
||||
Thank you for sending us a great Pull Request! 👍
|
||||
Please regenerate misskey-js type definitions! 🙏
|
||||
# A fork's pull_request token cannot comment, so hand the result to check-misskey-js-autogen.comment.yml
|
||||
- name: Save result
|
||||
env:
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
CHANGES: ${{ steps.check-changes.outputs.changes }}
|
||||
run: |
|
||||
mkdir -p result
|
||||
echo "$PR_NUMBER" > result/pr-number
|
||||
echo "$CHANGES" > result/changes
|
||||
|
||||
example:
|
||||
```sh
|
||||
pnpm run build-misskey-js-with-types
|
||||
```
|
||||
|
||||
- name: send message
|
||||
if: steps.check-changes.outputs.changes == 'false'
|
||||
uses: thollander/actions-comment-pull-request@v3
|
||||
- name: Upload result
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
comment-tag: check-misskey-js-autogen
|
||||
mode: delete
|
||||
message: "Thank you!"
|
||||
create_if_not_exists: false
|
||||
name: misskey-js-autogen-result
|
||||
path: result
|
||||
|
||||
- name: Make failure if changes are detected
|
||||
if: steps.check-changes.outputs.changes == 'true'
|
||||
|
||||
@@ -20,7 +20,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6.0.3
|
||||
uses: actions/checkout@v7.0.0
|
||||
- name: Check version
|
||||
run: |
|
||||
if [ "$(jq -r '.version' package.json)" != "$(jq -r '.version' packages/misskey-js/package.json)" ]; then
|
||||
|
||||
@@ -12,7 +12,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6.0.3
|
||||
uses: actions/checkout@v7.0.0
|
||||
- name: Check
|
||||
run: |
|
||||
counter=0
|
||||
|
||||
@@ -10,7 +10,7 @@ jobs:
|
||||
check_copyright_year:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6.0.3
|
||||
- uses: actions/checkout@v7.0.0
|
||||
- run: |
|
||||
if [ "$(grep Copyright COPYING | sed -e 's/.*2014-\([0-9]*\) .*/\1/g')" -ne "$(date +%Y)" ]; then
|
||||
echo "Please change copyright year!"
|
||||
|
||||
@@ -27,7 +27,7 @@ jobs:
|
||||
platform=${{ matrix.platform }}
|
||||
echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v6.0.3
|
||||
uses: actions/checkout@v7.0.0
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v4
|
||||
- name: Log in to Docker Hub
|
||||
|
||||
@@ -32,7 +32,7 @@ jobs:
|
||||
platform=${{ matrix.platform }}
|
||||
echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v6.0.3
|
||||
uses: actions/checkout@v7.0.0
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v4
|
||||
- name: Docker meta
|
||||
|
||||
@@ -17,7 +17,7 @@ jobs:
|
||||
DOCKLE_VERSION: 0.4.15
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6.0.3
|
||||
- uses: actions/checkout@v7.0.0
|
||||
|
||||
- name: Download and install dockle v${{ env.DOCKLE_VERSION }}
|
||||
run: |
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
name: Frontend browser diagnostics (comment)
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows:
|
||||
- Frontend browser diagnostics (inspect)
|
||||
types:
|
||||
- completed
|
||||
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
issues: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
comment:
|
||||
name: Comment frontend browser diagnostics report
|
||||
if: github.event.workflow_run.event == 'pull_request' && github.event.workflow_run.conclusion == 'success'
|
||||
runs-on: ubuntu-latest
|
||||
concurrency:
|
||||
group: frontend-browser-diagnostics-report-${{ github.event.workflow_run.id }}
|
||||
cancel-in-progress: true
|
||||
steps:
|
||||
- name: Download browser metrics report
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: frontend-browser-diagnostics
|
||||
path: ${{ runner.temp }}/frontend-browser-diagnostics
|
||||
github-token: ${{ github.token }}
|
||||
repository: ${{ github.repository }}
|
||||
run-id: ${{ github.event.workflow_run.id }}
|
||||
|
||||
- name: Load PR number
|
||||
id: load-pr-number
|
||||
shell: bash
|
||||
run: echo "pr-number=$(cat "$RUNNER_TEMP/frontend-browser-diagnostics/pr-number.txt")" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Comment on pull request
|
||||
uses: thollander/actions-comment-pull-request@v3
|
||||
with:
|
||||
pr-number: ${{ steps.load-pr-number.outputs.pr-number }}
|
||||
comment-tag: frontend-browser-diagnostics
|
||||
file-path: ${{ runner.temp }}/frontend-browser-diagnostics/frontend-browser-diagnostics.md
|
||||
@@ -1,210 +0,0 @@
|
||||
name: Frontend browser diagnostics (inspect)
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types:
|
||||
- opened
|
||||
- synchronize
|
||||
- reopened
|
||||
- ready_for_review
|
||||
paths:
|
||||
- packages/frontend/**
|
||||
- packages/frontend-shared/**
|
||||
- packages/frontend-builder/**
|
||||
- packages/backend/**
|
||||
- packages/i18n/**
|
||||
- packages/icons-subsetter/**
|
||||
- packages/misskey-js/**
|
||||
- packages/misskey-reversi/**
|
||||
- packages/misskey-bubble-game/**
|
||||
- package.json
|
||||
- pnpm-lock.yaml
|
||||
- pnpm-workspace.yaml
|
||||
- .node-version
|
||||
- .github/misskey/test.yml
|
||||
- .github/scripts/utility.mts
|
||||
- .github/scripts/heap-snapshot-util.mts
|
||||
- .github/scripts/chrome.mts
|
||||
- .github/scripts/frontend-browser-diagnostics.render-additional-html.mts
|
||||
- .github/scripts/frontend-browser-diagnostics.render-md.mts
|
||||
- .github/scripts/frontend-browser-diagnostics.inspect.mts
|
||||
- .github/workflows/frontend-browser-diagnostics.inspect.yml
|
||||
- .github/workflows/frontend-browser-diagnostics.comment.yml
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
|
||||
concurrency:
|
||||
group: frontend-browser-diagnostics-inspect-${{ github.event.pull_request.number }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
report:
|
||||
name: Measure frontend browser metrics
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 90
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:18
|
||||
ports:
|
||||
- 54312:5432
|
||||
env:
|
||||
POSTGRES_DB: test-misskey
|
||||
POSTGRES_HOST_AUTH_METHOD: trust
|
||||
redis:
|
||||
image: redis:8
|
||||
ports:
|
||||
- 56312:6379
|
||||
|
||||
steps:
|
||||
- name: Checkout base
|
||||
uses: actions/checkout@v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
repository: ${{ github.event.pull_request.base.repo.full_name }}
|
||||
ref: ${{ github.event.pull_request.base.sha }}
|
||||
path: before
|
||||
submodules: true
|
||||
|
||||
- name: Checkout pull request
|
||||
uses: actions/checkout@v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
repository: ${{ github.event.pull_request.head.repo.full_name }}
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
path: after
|
||||
submodules: true
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v6.0.9
|
||||
with:
|
||||
package_json_file: after/package.json
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6.4.0
|
||||
with:
|
||||
node-version-file: after/.node-version
|
||||
cache: pnpm
|
||||
cache-dependency-path: |
|
||||
before/pnpm-lock.yaml
|
||||
after/pnpm-lock.yaml
|
||||
|
||||
- name: Install dependencies for base
|
||||
working-directory: before
|
||||
run: pnpm i --frozen-lockfile
|
||||
|
||||
- name: Configure base
|
||||
working-directory: before
|
||||
run: cp .github/misskey/test.yml .config
|
||||
|
||||
- name: Build base
|
||||
working-directory: before
|
||||
run: pnpm build
|
||||
|
||||
- name: Install dependencies for pull request
|
||||
working-directory: after
|
||||
run: pnpm i --frozen-lockfile
|
||||
|
||||
- name: Configure pull request
|
||||
working-directory: after
|
||||
run: cp .github/misskey/test.yml .config
|
||||
|
||||
- name: Build pull request
|
||||
working-directory: after
|
||||
run: pnpm build
|
||||
|
||||
- name: Install Playwright browsers
|
||||
working-directory: after/packages/frontend
|
||||
run: pnpm exec playwright install --with-deps --no-shell chromium
|
||||
|
||||
- name: Measure frontend browser metrics
|
||||
shell: bash
|
||||
env:
|
||||
FRONTEND_BROWSER_METRICS_SAMPLE_COUNT: 5
|
||||
MK_ENABLE_CROSS_ORIGIN_ISOLATION: "true"
|
||||
run: |
|
||||
REPORT_DIR="$RUNNER_TEMP/frontend-browser-diagnostics"
|
||||
mkdir -p "$REPORT_DIR"
|
||||
node after/.github/scripts/frontend-browser-diagnostics.inspect.mts before after "$REPORT_DIR/before-browser.json" "$REPORT_DIR/after-browser.json"
|
||||
|
||||
- name: Upload browser base heap snapshot
|
||||
id: upload-browser-base-heap-snapshot
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
path: base-heap-snapshot.heapsnapshot
|
||||
archive: false
|
||||
if-no-files-found: error
|
||||
retention-days: 7
|
||||
|
||||
- name: Upload browser head heap snapshot
|
||||
id: upload-browser-head-heap-snapshot
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
path: head-heap-snapshot.heapsnapshot
|
||||
archive: false
|
||||
if-no-files-found: error
|
||||
retention-days: 7
|
||||
|
||||
- name: Generate browser detailed html
|
||||
shell: bash
|
||||
run: |
|
||||
REPORT_DIR="$RUNNER_TEMP/frontend-browser-diagnostics"
|
||||
test -s "$REPORT_DIR/before-browser.json"
|
||||
test -s "$REPORT_DIR/after-browser.json"
|
||||
node after/.github/scripts/frontend-browser-diagnostics.render-additional-html.mts "$REPORT_DIR/before-browser.json" "$REPORT_DIR/after-browser.json" "$REPORT_DIR/frontend-browser-detailed-html.html"
|
||||
|
||||
- name: Upload browser detailed html
|
||||
id: upload-browser-detailed-html
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: frontend-browser-metrics-detailed-html
|
||||
path: ${{ runner.temp }}/frontend-browser-diagnostics/frontend-browser-detailed-html.html
|
||||
if-no-files-found: error
|
||||
archive: false
|
||||
retention-days: 7
|
||||
|
||||
- name: Generate browser metrics report
|
||||
shell: bash
|
||||
env:
|
||||
BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
FRONTEND_BROWSER_BASE_HEAP_SNAPSHOT_ARTIFACT_URL: ${{ steps.upload-browser-base-heap-snapshot.outputs.artifact-url }}
|
||||
FRONTEND_BROWSER_HEAD_HEAP_SNAPSHOT_ARTIFACT_URL: ${{ steps.upload-browser-head-heap-snapshot.outputs.artifact-url }}
|
||||
FRONTEND_BROWSER_DETAILED_HTML_ARTIFACT_URL: ${{ steps.upload-browser-detailed-html.outputs.artifact-url }}
|
||||
run: |
|
||||
REPORT_DIR="$RUNNER_TEMP/frontend-browser-diagnostics"
|
||||
test -s "$REPORT_DIR/before-browser.json"
|
||||
test -s "$REPORT_DIR/after-browser.json"
|
||||
node after/.github/scripts/frontend-browser-diagnostics.render-md.mts "$REPORT_DIR/before-browser.json" "$REPORT_DIR/after-browser.json" "$REPORT_DIR/frontend-browser-diagnostics.md"
|
||||
printf '%s\n' "$PR_NUMBER" > "$REPORT_DIR/pr-number.txt"
|
||||
printf '%s\n' "$BASE_SHA" > "$REPORT_DIR/base-sha.txt"
|
||||
printf '%s\n' "$HEAD_SHA" > "$REPORT_DIR/head-sha.txt"
|
||||
printf '%s\n' "${{ github.event.pull_request.html_url }}" > "$REPORT_DIR/pr-url.txt"
|
||||
|
||||
- name: Check browser metrics report
|
||||
shell: bash
|
||||
run: |
|
||||
REPORT_DIR="$RUNNER_TEMP/frontend-browser-diagnostics"
|
||||
test -s "$REPORT_DIR/frontend-browser-diagnostics.md"
|
||||
test -s "$REPORT_DIR/frontend-browser-detailed-html.html"
|
||||
test -s "$REPORT_DIR/pr-number.txt"
|
||||
test -s "$REPORT_DIR/head-sha.txt"
|
||||
cat "$REPORT_DIR/frontend-browser-diagnostics.md" >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Upload browser metrics report
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: frontend-browser-diagnostics
|
||||
path: |
|
||||
${{ runner.temp }}/frontend-browser-diagnostics/before-browser.json
|
||||
${{ runner.temp }}/frontend-browser-diagnostics/after-browser.json
|
||||
${{ runner.temp }}/frontend-browser-diagnostics/frontend-browser-diagnostics.md
|
||||
${{ runner.temp }}/frontend-browser-diagnostics/pr-number.txt
|
||||
${{ runner.temp }}/frontend-browser-diagnostics/base-sha.txt
|
||||
${{ runner.temp }}/frontend-browser-diagnostics/head-sha.txt
|
||||
${{ runner.temp }}/frontend-browser-diagnostics/pr-url.txt
|
||||
if-no-files-found: error
|
||||
retention-days: 7
|
||||
@@ -1,44 +0,0 @@
|
||||
name: Frontend bundle diagnostics (comment)
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows:
|
||||
- Frontend bundle diagnostics (inspect)
|
||||
types:
|
||||
- completed
|
||||
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
issues: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
comment:
|
||||
name: Comment frontend bundle report
|
||||
if: github.event.workflow_run.event == 'pull_request' && github.event.workflow_run.conclusion == 'success'
|
||||
runs-on: ubuntu-latest
|
||||
concurrency:
|
||||
group: frontend-bundle-diagnostics-report-${{ github.event.workflow_run.id }}
|
||||
cancel-in-progress: true
|
||||
steps:
|
||||
- name: Download bundle report
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: frontend-bundle-report
|
||||
path: ${{ runner.temp }}/frontend-bundle-report
|
||||
github-token: ${{ github.token }}
|
||||
repository: ${{ github.repository }}
|
||||
run-id: ${{ github.event.workflow_run.id }}
|
||||
|
||||
- name: Load PR number
|
||||
id: load-pr-number
|
||||
shell: bash
|
||||
run: echo "pr-number=$(cat "$RUNNER_TEMP/frontend-bundle-report/pr-number.txt")" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Comment on pull request
|
||||
uses: thollander/actions-comment-pull-request@v3
|
||||
with:
|
||||
pr-number: ${{ steps.load-pr-number.outputs.pr-number }}
|
||||
comment-tag: frontend-bundle-diagnostics
|
||||
file-path: ${{ runner.temp }}/frontend-bundle-report/frontend-bundle-diagnostics-report.md
|
||||
@@ -1,144 +0,0 @@
|
||||
name: Frontend bundle diagnostics (inspect)
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types:
|
||||
- opened
|
||||
- synchronize
|
||||
- reopened
|
||||
- ready_for_review
|
||||
paths:
|
||||
- packages/frontend/**
|
||||
- packages/frontend-shared/**
|
||||
- packages/frontend-builder/**
|
||||
- packages/i18n/**
|
||||
- packages/icons-subsetter/**
|
||||
- packages/misskey-js/**
|
||||
- packages/misskey-reversi/**
|
||||
- packages/misskey-bubble-game/**
|
||||
- package.json
|
||||
- pnpm-lock.yaml
|
||||
- pnpm-workspace.yaml
|
||||
- .node-version
|
||||
- .github/scripts/utility.mts
|
||||
- .github/scripts/frontend-bundle-diagnostics.render-md.mts
|
||||
- .github/workflows/frontend-bundle-diagnostics.inspect.yml
|
||||
- .github/workflows/frontend-bundle-diagnostics.comment.yml
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
|
||||
concurrency:
|
||||
group: frontend-bundle-diagnostics-inspect-${{ github.event.pull_request.number }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
report:
|
||||
name: Build frontend bundle report
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout base
|
||||
uses: actions/checkout@v6.0.3
|
||||
with:
|
||||
repository: ${{ github.event.pull_request.base.repo.full_name }}
|
||||
ref: ${{ github.event.pull_request.base.sha }}
|
||||
path: before
|
||||
submodules: true
|
||||
|
||||
- name: Checkout pull request
|
||||
uses: actions/checkout@v6.0.3
|
||||
with:
|
||||
repository: ${{ github.event.pull_request.head.repo.full_name }}
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
path: after
|
||||
submodules: true
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v6.0.9
|
||||
with:
|
||||
package_json_file: after/package.json
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6.4.0
|
||||
with:
|
||||
node-version-file: after/.node-version
|
||||
cache: pnpm
|
||||
cache-dependency-path: |
|
||||
before/pnpm-lock.yaml
|
||||
after/pnpm-lock.yaml
|
||||
|
||||
- name: Install dependencies for base
|
||||
working-directory: before
|
||||
run: pnpm i --frozen-lockfile
|
||||
|
||||
- name: Build frontend dependencies for base
|
||||
working-directory: before
|
||||
run: pnpm --filter "frontend^..." run build
|
||||
|
||||
- name: Prepare report output
|
||||
run: mkdir -p "$RUNNER_TEMP/frontend-bundle-report"
|
||||
|
||||
- name: Build frontend report for base
|
||||
working-directory: before
|
||||
env:
|
||||
FRONTEND_BUNDLE_VISUALIZER: 'true'
|
||||
FRONTEND_BUNDLE_VISUALIZER_FILE: ${{ runner.temp }}/frontend-bundle-report/before-stats.json
|
||||
run: pnpm --filter frontend run build
|
||||
|
||||
- name: Install dependencies for pull request
|
||||
working-directory: after
|
||||
run: pnpm i --frozen-lockfile
|
||||
|
||||
- name: Build frontend dependencies for pull request
|
||||
working-directory: after
|
||||
run: pnpm --filter "frontend^..." run build
|
||||
|
||||
- name: Build frontend report for pull request
|
||||
working-directory: after
|
||||
env:
|
||||
FRONTEND_BUNDLE_VISUALIZER: 'true'
|
||||
FRONTEND_BUNDLE_VISUALIZER_FILE: ${{ runner.temp }}/frontend-bundle-report/after-stats.json
|
||||
FRONTEND_BUNDLE_VISUALIZER_HTML_FILE: ${{ runner.temp }}/frontend-bundle-report/frontend-bundle-visualizer.html
|
||||
run: pnpm --filter frontend run build
|
||||
|
||||
- name: Upload bundle visualizer
|
||||
id: upload-bundle-visualizer
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: frontend-bundle-visualizer
|
||||
path: ${{ runner.temp }}/frontend-bundle-report/frontend-bundle-visualizer.html
|
||||
if-no-files-found: error
|
||||
archive: false
|
||||
retention-days: 7
|
||||
|
||||
- name: Generate report markdown
|
||||
shell: bash
|
||||
env:
|
||||
BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
FRONTEND_BUNDLE_REPORT_ARTIFACT_URL: ${{ steps.upload-bundle-visualizer.outputs.artifact-url }}
|
||||
run: |
|
||||
REPORT_DIR="$RUNNER_TEMP/frontend-bundle-report"
|
||||
node after/.github/scripts/frontend-bundle-diagnostics.render-md.mts before after "$REPORT_DIR/before-stats.json" "$REPORT_DIR/after-stats.json" "$REPORT_DIR/frontend-bundle-diagnostics-report.md"
|
||||
printf '%s\n' "$PR_NUMBER" > "$REPORT_DIR/pr-number.txt"
|
||||
printf '%s\n' "$BASE_SHA" > "$REPORT_DIR/base-sha.txt"
|
||||
printf '%s\n' "$HEAD_SHA" > "$REPORT_DIR/head-sha.txt"
|
||||
printf '%s\n' "${{ github.event.pull_request.html_url }}" > "$REPORT_DIR/pr-url.txt"
|
||||
|
||||
- name: Check report
|
||||
run: |
|
||||
REPORT_DIR="$RUNNER_TEMP/frontend-bundle-report"
|
||||
test -s "$REPORT_DIR/before-stats.json"
|
||||
test -s "$REPORT_DIR/after-stats.json"
|
||||
test -s "$REPORT_DIR/frontend-bundle-diagnostics-report.md"
|
||||
cat "$REPORT_DIR/frontend-bundle-diagnostics-report.md" >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Upload bundle report
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: frontend-bundle-report
|
||||
path: ${{ runner.temp }}/frontend-bundle-report/
|
||||
if-no-files-found: error
|
||||
retention-days: 7
|
||||
@@ -0,0 +1,75 @@
|
||||
name: Frontend diagnostics (comment)
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows:
|
||||
- Frontend diagnostics (inspect)
|
||||
types:
|
||||
- completed
|
||||
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
issues: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
comment:
|
||||
name: Comment frontend diagnostics report
|
||||
if: github.event.workflow_run.event == 'pull_request' && github.event.workflow_run.conclusion == 'success'
|
||||
runs-on: ubuntu-latest
|
||||
concurrency:
|
||||
group: frontend-diagnostics-report-${{ github.event.workflow_run.id }}
|
||||
cancel-in-progress: true
|
||||
steps:
|
||||
- name: Download frontend diagnostics report
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: frontend-diagnostics
|
||||
path: ${{ runner.temp }}/frontend-diagnostics
|
||||
github-token: ${{ github.token }}
|
||||
repository: ${{ github.repository }}
|
||||
run-id: ${{ github.event.workflow_run.id }}
|
||||
|
||||
- name: Resolve current pull request
|
||||
id: resolve-pr
|
||||
uses: actions/github-script@v9
|
||||
env:
|
||||
PR_NUMBER_FILE: ${{ runner.temp }}/frontend-diagnostics/pr-number.txt
|
||||
with:
|
||||
script: |
|
||||
const fs = require('fs/promises');
|
||||
const { owner, repo } = context.repo;
|
||||
const workflowRun = context.payload.workflow_run;
|
||||
const pullRequestNumberText = (await fs.readFile(process.env.PR_NUMBER_FILE, 'utf8')).trim();
|
||||
if (!/^[1-9]\d*$/.test(pullRequestNumberText)) {
|
||||
throw new Error('Invalid pull request number in frontend diagnostics artifact.');
|
||||
}
|
||||
|
||||
const pullRequestNumber = Number(pullRequestNumberText);
|
||||
if (!Number.isSafeInteger(pullRequestNumber)) {
|
||||
throw new Error('Pull request number in frontend diagnostics artifact is not a safe integer.');
|
||||
}
|
||||
|
||||
const { data: currentPullRequest } = await github.rest.pulls.get({
|
||||
owner,
|
||||
repo,
|
||||
pull_number: pullRequestNumber,
|
||||
});
|
||||
|
||||
if (currentPullRequest.state !== 'open' || currentPullRequest.head.sha !== workflowRun.head_sha) {
|
||||
core.notice(`Skipping stale frontend diagnostics report for pull request #${pullRequestNumber}.`);
|
||||
core.setOutput('is-current', 'false');
|
||||
return;
|
||||
}
|
||||
|
||||
core.setOutput('is-current', 'true');
|
||||
core.setOutput('pr-number', String(pullRequestNumber));
|
||||
|
||||
- name: Comment on pull request
|
||||
if: steps.resolve-pr.outputs.is-current == 'true'
|
||||
uses: thollander/actions-comment-pull-request@v3
|
||||
with:
|
||||
pr-number: ${{ steps.resolve-pr.outputs.pr-number }}
|
||||
comment-tag: frontend-diagnostics
|
||||
file-path: ${{ runner.temp }}/frontend-diagnostics/frontend-diagnostics.md
|
||||
@@ -0,0 +1,246 @@
|
||||
name: Frontend diagnostics (inspect)
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types:
|
||||
- opened
|
||||
- synchronize
|
||||
- reopened
|
||||
- ready_for_review
|
||||
paths:
|
||||
- packages/frontend/**
|
||||
- packages/frontend-shared/**
|
||||
- packages/frontend-builder/**
|
||||
- packages/backend/**
|
||||
- packages/i18n/**
|
||||
- packages/icons-subsetter/**
|
||||
- packages/misskey-js/**
|
||||
- packages/misskey-reversi/**
|
||||
- packages/misskey-bubble-game/**
|
||||
- package.json
|
||||
- pnpm-lock.yaml
|
||||
- pnpm-workspace.yaml
|
||||
- .node-version
|
||||
- .github/misskey/test.yml
|
||||
- packages-private/diagnostics-shared/**
|
||||
- packages-private/diagnostics-frontend/**
|
||||
- .github/workflows/frontend-diagnostics.inspect.yml
|
||||
- .github/workflows/frontend-diagnostics.comment.yml
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
|
||||
concurrency:
|
||||
group: frontend-diagnostics-inspect-${{ github.event.pull_request.number }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
report:
|
||||
name: Measure frontend diagnostics
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 90
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:18
|
||||
ports:
|
||||
- 54312:5432
|
||||
env:
|
||||
POSTGRES_DB: test-misskey
|
||||
POSTGRES_HOST_AUTH_METHOD: trust
|
||||
redis:
|
||||
image: redis:8
|
||||
ports:
|
||||
- 56312:6379
|
||||
|
||||
steps:
|
||||
- name: Checkout base
|
||||
uses: actions/checkout@v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
repository: ${{ github.event.pull_request.base.repo.full_name }}
|
||||
ref: ${{ github.event.pull_request.base.sha }}
|
||||
path: base
|
||||
submodules: true
|
||||
|
||||
- name: Checkout head
|
||||
uses: actions/checkout@v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
repository: ${{ github.event.pull_request.head.repo.full_name }}
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
path: head
|
||||
submodules: true
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v6.0.9
|
||||
with:
|
||||
package_json_file: head/package.json
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v7.0.0
|
||||
with:
|
||||
node-version-file: head/.node-version
|
||||
cache: pnpm
|
||||
cache-dependency-path: |
|
||||
base/pnpm-lock.yaml
|
||||
head/pnpm-lock.yaml
|
||||
|
||||
- name: Prepare report output
|
||||
shell: bash
|
||||
run: mkdir -p "$RUNNER_TEMP/frontend-diagnostics"
|
||||
|
||||
- name: Install dependencies for base
|
||||
working-directory: base
|
||||
run: pnpm i --frozen-lockfile
|
||||
|
||||
- name: Configure base
|
||||
working-directory: base
|
||||
run: cp .github/misskey/test.yml .config
|
||||
|
||||
- name: Build base
|
||||
working-directory: base
|
||||
env:
|
||||
FRONTEND_BUNDLE_VISUALIZER: 'true'
|
||||
FRONTEND_BUNDLE_VISUALIZER_FILE: ${{ runner.temp }}/frontend-diagnostics/base-bundle-stats.json
|
||||
run: pnpm build
|
||||
|
||||
- name: Install dependencies for head
|
||||
working-directory: head
|
||||
run: pnpm i --frozen-lockfile
|
||||
|
||||
- name: Configure head
|
||||
working-directory: head
|
||||
run: cp .github/misskey/test.yml .config
|
||||
|
||||
- name: Build head
|
||||
working-directory: head
|
||||
env:
|
||||
FRONTEND_BUNDLE_VISUALIZER: 'true'
|
||||
FRONTEND_BUNDLE_VISUALIZER_FILE: ${{ runner.temp }}/frontend-diagnostics/head-bundle-stats.json
|
||||
FRONTEND_BUNDLE_VISUALIZER_HTML_FILE: ${{ runner.temp }}/frontend-diagnostics/frontend-bundle-visualizer.html
|
||||
run: pnpm build
|
||||
|
||||
- name: Upload bundle visualizer
|
||||
id: upload-bundle-visualizer
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: frontend-bundle-visualizer
|
||||
path: ${{ runner.temp }}/frontend-diagnostics/frontend-bundle-visualizer.html
|
||||
if-no-files-found: error
|
||||
archive: false
|
||||
retention-days: 7
|
||||
|
||||
- name: Install Playwright browsers
|
||||
working-directory: head/packages-private/diagnostics-frontend
|
||||
run: pnpm exec playwright install --with-deps --no-shell chromium
|
||||
|
||||
- name: Measure frontend browser metrics
|
||||
shell: bash
|
||||
working-directory: head
|
||||
env:
|
||||
FRONTEND_BROWSER_METRICS_SAMPLE_COUNT: 5
|
||||
MK_ENABLE_CROSS_ORIGIN_ISOLATION: "true"
|
||||
# 計測ハーネスはhead側のものを両方に使うが、成果物はrunnerのworkspace直下に出す
|
||||
FRONTEND_BROWSER_HEAP_SNAPSHOT_OUTPUT_DIR: ${{ github.workspace }}
|
||||
run: |
|
||||
REPORT_DIR="$RUNNER_TEMP/frontend-diagnostics"
|
||||
pnpm --filter diagnostics-frontend run inspect-browser \
|
||||
"$GITHUB_WORKSPACE/base" "$GITHUB_WORKSPACE/head" \
|
||||
"$REPORT_DIR/base-browser.json" "$REPORT_DIR/head-browser.json"
|
||||
|
||||
- name: Upload browser base heap snapshot
|
||||
id: upload-browser-base-heap-snapshot
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: frontend-browser-base-heap-snapshot
|
||||
path: base-heap-snapshot.heapsnapshot
|
||||
archive: false
|
||||
if-no-files-found: error
|
||||
retention-days: 7
|
||||
|
||||
- name: Upload browser head heap snapshot
|
||||
id: upload-browser-head-heap-snapshot
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: frontend-browser-head-heap-snapshot
|
||||
path: head-heap-snapshot.heapsnapshot
|
||||
archive: false
|
||||
if-no-files-found: error
|
||||
retention-days: 7
|
||||
|
||||
- name: Generate browser detailed html
|
||||
shell: bash
|
||||
working-directory: head
|
||||
run: |
|
||||
REPORT_DIR="$RUNNER_TEMP/frontend-diagnostics"
|
||||
test -s "$REPORT_DIR/base-browser.json"
|
||||
test -s "$REPORT_DIR/head-browser.json"
|
||||
pnpm --filter diagnostics-frontend run render-browser-html \
|
||||
"$REPORT_DIR/base-browser.json" "$REPORT_DIR/head-browser.json" \
|
||||
"$REPORT_DIR/frontend-browser-detailed-html.html"
|
||||
|
||||
- name: Upload browser detailed html
|
||||
id: upload-browser-detailed-html
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: frontend-browser-metrics-detailed-html
|
||||
path: ${{ runner.temp }}/frontend-diagnostics/frontend-browser-detailed-html.html
|
||||
if-no-files-found: error
|
||||
archive: false
|
||||
retention-days: 7
|
||||
|
||||
- name: Generate frontend diagnostics report
|
||||
shell: bash
|
||||
working-directory: head
|
||||
env:
|
||||
BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
FRONTEND_BUNDLE_REPORT_ARTIFACT_URL: ${{ steps.upload-bundle-visualizer.outputs.artifact-url }}
|
||||
FRONTEND_BROWSER_BASE_HEAP_SNAPSHOT_ARTIFACT_URL: ${{ steps.upload-browser-base-heap-snapshot.outputs.artifact-url }}
|
||||
FRONTEND_BROWSER_HEAD_HEAP_SNAPSHOT_ARTIFACT_URL: ${{ steps.upload-browser-head-heap-snapshot.outputs.artifact-url }}
|
||||
FRONTEND_BROWSER_DETAILED_HTML_ARTIFACT_URL: ${{ steps.upload-browser-detailed-html.outputs.artifact-url }}
|
||||
run: |
|
||||
REPORT_DIR="$RUNNER_TEMP/frontend-diagnostics"
|
||||
test -s "$REPORT_DIR/base-bundle-stats.json"
|
||||
test -s "$REPORT_DIR/head-bundle-stats.json"
|
||||
test -s "$REPORT_DIR/base-browser.json"
|
||||
test -s "$REPORT_DIR/head-browser.json"
|
||||
pnpm --filter diagnostics-frontend run render-md \
|
||||
"$GITHUB_WORKSPACE/base" "$GITHUB_WORKSPACE/head" \
|
||||
"$REPORT_DIR/base-bundle-stats.json" "$REPORT_DIR/head-bundle-stats.json" \
|
||||
"$REPORT_DIR/base-browser.json" "$REPORT_DIR/head-browser.json" \
|
||||
"$REPORT_DIR/frontend-diagnostics.md"
|
||||
printf '%s\n' "$PR_NUMBER" > "$REPORT_DIR/pr-number.txt"
|
||||
printf '%s\n' "$BASE_SHA" > "$REPORT_DIR/base-sha.txt"
|
||||
printf '%s\n' "$HEAD_SHA" > "$REPORT_DIR/head-sha.txt"
|
||||
printf '%s\n' "${{ github.event.pull_request.html_url }}" > "$REPORT_DIR/pr-url.txt"
|
||||
|
||||
- name: Check frontend diagnostics report
|
||||
shell: bash
|
||||
run: |
|
||||
REPORT_DIR="$RUNNER_TEMP/frontend-diagnostics"
|
||||
test -s "$REPORT_DIR/frontend-diagnostics.md"
|
||||
test -s "$REPORT_DIR/frontend-browser-detailed-html.html"
|
||||
test -s "$REPORT_DIR/pr-number.txt"
|
||||
test -s "$REPORT_DIR/head-sha.txt"
|
||||
cat "$REPORT_DIR/frontend-diagnostics.md" >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Upload frontend diagnostics report
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: frontend-diagnostics
|
||||
path: |
|
||||
${{ runner.temp }}/frontend-diagnostics/base-bundle-stats.json
|
||||
${{ runner.temp }}/frontend-diagnostics/head-bundle-stats.json
|
||||
${{ runner.temp }}/frontend-diagnostics/base-browser.json
|
||||
${{ runner.temp }}/frontend-diagnostics/head-browser.json
|
||||
${{ runner.temp }}/frontend-diagnostics/frontend-diagnostics.md
|
||||
${{ runner.temp }}/frontend-diagnostics/pr-number.txt
|
||||
${{ runner.temp }}/frontend-diagnostics/base-sha.txt
|
||||
${{ runner.temp }}/frontend-diagnostics/head-sha.txt
|
||||
${{ runner.temp }}/frontend-diagnostics/pr-url.txt
|
||||
if-no-files-found: error
|
||||
retention-days: 7
|
||||
@@ -25,14 +25,14 @@ jobs:
|
||||
ref: refs/pull/${{ github.event.number }}/merge
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6.0.3
|
||||
- uses: actions/checkout@v7.0.0
|
||||
with:
|
||||
ref: ${{ matrix.ref }}
|
||||
submodules: true
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v6.0.9
|
||||
- name: Use Node.js
|
||||
uses: actions/setup-node@v6.4.0
|
||||
uses: actions/setup-node@v7.0.0
|
||||
with:
|
||||
node-version-file: '.node-version'
|
||||
cache: 'pnpm'
|
||||
|
||||
@@ -11,6 +11,6 @@ jobs:
|
||||
pull-requests: write
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/labeler@v6
|
||||
- uses: actions/labeler@v7
|
||||
with:
|
||||
repo-token: "${{ secrets.GITHUB_TOKEN }}"
|
||||
|
||||
@@ -16,6 +16,8 @@ on:
|
||||
- packages/misskey-js/**
|
||||
- packages/misskey-bubble-game/**
|
||||
- packages/misskey-reversi/**
|
||||
- packages/misskey-world/**
|
||||
- packages/frontend-misskey-world-engine/**
|
||||
- packages/shared/eslint.config.js
|
||||
- scripts/check-dts*.mjs
|
||||
- .github/workflows/lint.yml
|
||||
@@ -32,6 +34,8 @@ on:
|
||||
- packages/misskey-js/**
|
||||
- packages/misskey-bubble-game/**
|
||||
- packages/misskey-reversi/**
|
||||
- packages/misskey-world/**
|
||||
- packages/frontend-misskey-world-engine/**
|
||||
- packages/shared/eslint.config.js
|
||||
- scripts/check-dts*.mjs
|
||||
- .github/workflows/lint.yml
|
||||
@@ -40,13 +44,13 @@ jobs:
|
||||
pnpm_install:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6.0.3
|
||||
- uses: actions/checkout@v7.0.0
|
||||
with:
|
||||
fetch-depth: 0
|
||||
submodules: true
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v6.0.9
|
||||
- uses: actions/setup-node@v6.4.0
|
||||
- uses: actions/setup-node@v7.0.0
|
||||
with:
|
||||
node-version-file: '.node-version'
|
||||
cache: 'pnpm'
|
||||
@@ -69,23 +73,25 @@ jobs:
|
||||
- misskey-js
|
||||
- misskey-bubble-game
|
||||
- misskey-reversi
|
||||
- misskey-world
|
||||
- frontend-misskey-world-engine
|
||||
env:
|
||||
eslint-cache-version: v1
|
||||
eslint-cache-path: ${{ github.workspace }}/node_modules/.cache/eslint-${{ matrix.workspace }}
|
||||
steps:
|
||||
- uses: actions/checkout@v6.0.3
|
||||
- uses: actions/checkout@v7.0.0
|
||||
with:
|
||||
fetch-depth: 0
|
||||
submodules: true
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v6.0.9
|
||||
- uses: actions/setup-node@v6.4.0
|
||||
- uses: actions/setup-node@v7.0.0
|
||||
with:
|
||||
node-version-file: '.node-version'
|
||||
cache: 'pnpm'
|
||||
- run: pnpm i --frozen-lockfile
|
||||
- name: Restore eslint cache
|
||||
uses: actions/cache@v5.0.5
|
||||
uses: actions/cache@v6.1.0
|
||||
with:
|
||||
path: ${{ env.eslint-cache-path }}
|
||||
key: eslint-${{ env.eslint-cache-version }}-${{ matrix.workspace }}-${{ hashFiles('**/pnpm-lock.yaml') }}-${{ github.ref_name }}-${{ github.sha }}
|
||||
@@ -104,13 +110,13 @@ jobs:
|
||||
- sw
|
||||
- misskey-js
|
||||
steps:
|
||||
- uses: actions/checkout@v6.0.3
|
||||
- uses: actions/checkout@v7.0.0
|
||||
with:
|
||||
fetch-depth: 0
|
||||
submodules: true
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v6.0.9
|
||||
- uses: actions/setup-node@v6.4.0
|
||||
- uses: actions/setup-node@v7.0.0
|
||||
with:
|
||||
node-version-file: '.node-version'
|
||||
cache: 'pnpm'
|
||||
@@ -123,13 +129,13 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
continue-on-error: true
|
||||
steps:
|
||||
- uses: actions/checkout@v6.0.3
|
||||
- uses: actions/checkout@v7.0.0
|
||||
with:
|
||||
fetch-depth: 0
|
||||
submodules: true
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v6.0.9
|
||||
- uses: actions/setup-node@v6.4.0
|
||||
- uses: actions/setup-node@v7.0.0
|
||||
with:
|
||||
node-version-file: '.node-version'
|
||||
cache: 'pnpm'
|
||||
|
||||
@@ -16,13 +16,13 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
continue-on-error: true
|
||||
steps:
|
||||
- uses: actions/checkout@v6.0.3
|
||||
- uses: actions/checkout@v7.0.0
|
||||
with:
|
||||
fetch-depth: 0
|
||||
submodules: true
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v6.0.9
|
||||
- uses: actions/setup-node@v6.4.0
|
||||
- uses: actions/setup-node@v7.0.0
|
||||
with:
|
||||
node-version-file: ".node-version"
|
||||
cache: "pnpm"
|
||||
|
||||
@@ -16,13 +16,13 @@ jobs:
|
||||
id-token: write
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6.0.3
|
||||
- uses: actions/checkout@v7.0.0
|
||||
with:
|
||||
submodules: true
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v6.0.9
|
||||
- name: Use Node.js
|
||||
uses: actions/setup-node@v6.4.0
|
||||
uses: actions/setup-node@v7.0.0
|
||||
with:
|
||||
node-version-file: '.node-version'
|
||||
cache: 'pnpm'
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
name: Lint and test packages-private
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
- develop
|
||||
paths:
|
||||
- packages-private/**
|
||||
- packages/shared/eslint.config.js
|
||||
- package.json
|
||||
- pnpm-workspace.yaml
|
||||
- pnpm-lock.yaml
|
||||
- .github/workflows/packages-private.yml
|
||||
pull_request:
|
||||
paths:
|
||||
- packages-private/**
|
||||
- packages/shared/eslint.config.js
|
||||
- package.json
|
||||
- pnpm-workspace.yaml
|
||||
- pnpm-lock.yaml
|
||||
- .github/workflows/packages-private.yml
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
check:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
workspace:
|
||||
- diagnostics-shared
|
||||
- diagnostics-backend
|
||||
- diagnostics-frontend
|
||||
- changelog-checker
|
||||
steps:
|
||||
- uses: actions/checkout@v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v6.0.9
|
||||
- uses: actions/setup-node@v7.0.0
|
||||
with:
|
||||
node-version-file: '.node-version'
|
||||
cache: 'pnpm'
|
||||
- run: pnpm --filter ${{ matrix.workspace }}... install --frozen-lockfile
|
||||
# lint = typecheck + eslint
|
||||
- run: pnpm --filter ${{ matrix.workspace }} run lint
|
||||
# 各パッケージは必ず test スクリプトを持たせる (無いと ERR_PNPM_RECURSIVE_RUN_NO_SCRIPT で落ちる)
|
||||
- run: pnpm --filter ${{ matrix.workspace }} run test
|
||||
@@ -19,7 +19,7 @@ jobs:
|
||||
edit:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
# headが$GITHUB_REF_NAME, baseが$STABLE_BRANCHかつopenのPRを1つ取得
|
||||
- name: Get PR
|
||||
run: |
|
||||
|
||||
@@ -36,7 +36,7 @@ jobs:
|
||||
outputs:
|
||||
pr_number: ${{ steps.get_pr.outputs.pr_number }}
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
# headが$GITHUB_REF_NAME, baseが$STABLE_BRANCHかつopenのPRを1つ取得
|
||||
- name: Get PRs
|
||||
run: |
|
||||
|
||||
@@ -22,12 +22,12 @@ jobs:
|
||||
NODE_OPTIONS: "--max_old_space_size=7168"
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6.0.3
|
||||
- uses: actions/checkout@v7.0.0
|
||||
if: github.event_name != 'pull_request_target'
|
||||
with:
|
||||
fetch-depth: 0
|
||||
submodules: true
|
||||
- uses: actions/checkout@v6.0.3
|
||||
- uses: actions/checkout@v7.0.0
|
||||
if: github.event_name == 'pull_request_target'
|
||||
with:
|
||||
fetch-depth: 0
|
||||
@@ -39,7 +39,7 @@ jobs:
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v6.0.9
|
||||
- name: Use Node.js
|
||||
uses: actions/setup-node@v6.4.0
|
||||
uses: actions/setup-node@v7.0.0
|
||||
with:
|
||||
node-version-file: '.node-version'
|
||||
cache: 'pnpm'
|
||||
|
||||
@@ -43,7 +43,7 @@ jobs:
|
||||
ports:
|
||||
- 56312:6379
|
||||
meilisearch:
|
||||
image: getmeili/meilisearch:v1.48.1
|
||||
image: getmeili/meilisearch:v1.49.0
|
||||
ports:
|
||||
- 57712:7700
|
||||
env:
|
||||
@@ -51,7 +51,7 @@ jobs:
|
||||
MEILI_ENV: development
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6.0.3
|
||||
- uses: actions/checkout@v7.0.0
|
||||
with:
|
||||
submodules: true
|
||||
- name: Setup pnpm
|
||||
@@ -60,7 +60,7 @@ jobs:
|
||||
run: |
|
||||
sudo apt install -y ffmpeg
|
||||
- name: Use Node.js
|
||||
uses: actions/setup-node@v6.4.0
|
||||
uses: actions/setup-node@v7.0.0
|
||||
with:
|
||||
node-version-file: ${{ matrix.node-version-file }}
|
||||
cache: 'pnpm'
|
||||
@@ -74,7 +74,7 @@ jobs:
|
||||
- name: Test
|
||||
run: pnpm --filter backend test-and-coverage
|
||||
- name: Upload to Codecov
|
||||
uses: codecov/codecov-action@v6
|
||||
uses: codecov/codecov-action@v7
|
||||
with:
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
files: ./packages/backend/coverage/coverage-final.json
|
||||
@@ -103,13 +103,13 @@ jobs:
|
||||
- 56312:6379
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6.0.3
|
||||
- uses: actions/checkout@v7.0.0
|
||||
with:
|
||||
submodules: true
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v6.0.9
|
||||
- name: Use Node.js
|
||||
uses: actions/setup-node@v6.4.0
|
||||
uses: actions/setup-node@v7.0.0
|
||||
with:
|
||||
node-version-file: ${{ matrix.node-version-file }}
|
||||
cache: 'pnpm'
|
||||
@@ -123,7 +123,7 @@ jobs:
|
||||
- name: Test
|
||||
run: pnpm --filter backend test-and-coverage:e2e
|
||||
- name: Upload to Codecov
|
||||
uses: codecov/codecov-action@v6
|
||||
uses: codecov/codecov-action@v7
|
||||
with:
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
files: ./packages/backend/coverage/coverage-final.json
|
||||
@@ -147,7 +147,7 @@ jobs:
|
||||
POSTGRES_HOST_AUTH_METHOD: trust
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6.0.3
|
||||
- uses: actions/checkout@v7.0.0
|
||||
with:
|
||||
submodules: true
|
||||
- name: Setup pnpm
|
||||
@@ -156,7 +156,7 @@ jobs:
|
||||
id: current-date
|
||||
run: echo "today=$(date +'%Y-%m-%d')" >> $GITHUB_OUTPUT
|
||||
- name: Use Node.js
|
||||
uses: actions/setup-node@v6.4.0
|
||||
uses: actions/setup-node@v7.0.0
|
||||
with:
|
||||
node-version-file: ${{ matrix.node-version-file }}
|
||||
cache: 'pnpm'
|
||||
|
||||
@@ -26,7 +26,7 @@ jobs:
|
||||
- .node-version
|
||||
- .github/min.node-version
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
submodules: true
|
||||
- name: Setup pnpm
|
||||
@@ -35,7 +35,7 @@ jobs:
|
||||
run: |
|
||||
sudo apt install -y ffmpeg
|
||||
- name: Use Node.js
|
||||
uses: actions/setup-node@v6.4.0
|
||||
uses: actions/setup-node@v7.0.0
|
||||
with:
|
||||
node-version-file: ${{ matrix.node-version-file }}
|
||||
cache: 'pnpm'
|
||||
|
||||
@@ -28,13 +28,13 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6.0.3
|
||||
- uses: actions/checkout@v7.0.0
|
||||
with:
|
||||
submodules: true
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v6.0.9
|
||||
- name: Use Node.js
|
||||
uses: actions/setup-node@v6.4.0
|
||||
uses: actions/setup-node@v7.0.0
|
||||
with:
|
||||
node-version-file: '.node-version'
|
||||
cache: 'pnpm'
|
||||
@@ -48,7 +48,7 @@ jobs:
|
||||
- name: Test
|
||||
run: pnpm --filter frontend test-and-coverage
|
||||
- name: Upload Coverage
|
||||
uses: codecov/codecov-action@v6
|
||||
uses: codecov/codecov-action@v7
|
||||
with:
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
files: ./packages/frontend/coverage/coverage-final.json
|
||||
@@ -71,13 +71,13 @@ jobs:
|
||||
- 56312:6379
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6.0.3
|
||||
- uses: actions/checkout@v7.0.0
|
||||
with:
|
||||
submodules: true
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v6.0.9
|
||||
- name: Use Node.js
|
||||
uses: actions/setup-node@v6.4.0
|
||||
uses: actions/setup-node@v7.0.0
|
||||
with:
|
||||
node-version-file: '.node-version'
|
||||
cache: 'pnpm'
|
||||
|
||||
@@ -22,13 +22,13 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6.0.3
|
||||
uses: actions/checkout@v7.0.0
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v6.0.9
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6.4.0
|
||||
uses: actions/setup-node@v7.0.0
|
||||
with:
|
||||
node-version-file: '.node-version'
|
||||
cache: 'pnpm'
|
||||
@@ -48,7 +48,7 @@ jobs:
|
||||
CI: true
|
||||
|
||||
- name: Upload Coverage
|
||||
uses: codecov/codecov-action@v6
|
||||
uses: codecov/codecov-action@v7
|
||||
with:
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
files: ./packages/misskey-js/coverage/coverage-final.json
|
||||
|
||||
@@ -16,13 +16,13 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6.0.3
|
||||
- uses: actions/checkout@v7.0.0
|
||||
with:
|
||||
submodules: true
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v6.0.9
|
||||
- name: Use Node.js
|
||||
uses: actions/setup-node@v6.4.0
|
||||
uses: actions/setup-node@v7.0.0
|
||||
with:
|
||||
node-version-file: '.node-version'
|
||||
cache: 'pnpm'
|
||||
|
||||
@@ -17,13 +17,13 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6.0.3
|
||||
- uses: actions/checkout@v7.0.0
|
||||
with:
|
||||
submodules: true
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v6.0.9
|
||||
- name: Use Node.js
|
||||
uses: actions/setup-node@v6.4.0
|
||||
uses: actions/setup-node@v7.0.0
|
||||
with:
|
||||
node-version-file: '.node-version'
|
||||
cache: 'pnpm'
|
||||
|
||||
+20
-15
@@ -12,16 +12,19 @@
|
||||
- Node.js のセキュリティアップデートに伴い、最低動作バージョンを 22.22.2 / 24.17.0 / 26.4.0 に引き上げました。
|
||||
- Docker Image は Node.js 26.4.0-trixie に更新されています。
|
||||
- バックエンドで画像処理に用いているライブラリ sharp のシステム要件の変更により、**SSE4.2 命令セットをサポートしていない x86_64 CPU では Misskey が正しく動作しなくなります**。仮想マシンに Misskey をデプロイしている場合や、古いハードウェアをお使いの場合は、アップデート前にお使いの環境をご確認ください。なお、ARM64 など x86_64 ではない環境においてはこの変更による影響はありません。
|
||||
- YAMLパーサーをアップデートし、より厳格なチェックが行われるようになりました。起動時にconfigの読み取りで構文エラーが発生する可能性があります。\
|
||||
例えば `allowPrivateNetworks` を 2026.6.0 までの `example.yml` の構文を元に記述している場合は、配列の閉じ括弧のインデントを上げるか、リスト表示に書き換える必要があります。詳しくは https://github.com/misskey-dev/misskey/pull/17701 をご覧ください。
|
||||
|
||||
### General
|
||||
- Feat: コントロールパネルから二要素認証を解除できるように
|
||||
- Feat: 条件に一致したURLプレビューのサムネイルを隠すことができるように
|
||||
(Based on https://github.com/MisskeyIO/misskey/pull/214)
|
||||
- Enhance: 依存関係の更新
|
||||
|
||||
### Client
|
||||
- 2025.4.0 以前の設定情報の移行処理が削除されました
|
||||
- 2025.4.0 から直接 2026.6.0 以上にアップデートする場合は設定が移行されませんので注意してください。移行したい場合は一度 2026.5.1 を経由してください。
|
||||
- Enhance: 画像ビューワーを独自実装に変更・動画プレイヤーを統合
|
||||
- Enhance: 画像ビューワーを刷新・動画プレイヤーを統合
|
||||
- 操作性の改善
|
||||
- ビューワーとMisskeyの各種機能との統合を強化
|
||||
- パフォーマンスの向上
|
||||
@@ -29,7 +32,9 @@
|
||||
- Fix: 幅が狭い画面で動画の再生が困難な問題を修正
|
||||
- Fix: 一部の画像のみセンシティブなとき、ビューワー内で画像を切り替えるとセンシティブな画像がそのまま表示される問題を修正
|
||||
- Fix: 一部の画像をビューワーで読み込んだ際に正しく表示されない問題を修正
|
||||
- Enhance: タイムラインの読み込みパフォーマンスを改善
|
||||
- Enhance: ファイルアップロード前にプレビューできるように
|
||||
- Enhance: タブがバックグラウンドの間は必要ない定期更新処理を停止するように
|
||||
- Enhance: 翻訳の更新
|
||||
- Fix: 「画像を新しいタブで開く」が機能しなくなっていた問題を修正
|
||||
- Fix: デバイスタイプをスマートフォンに固定している状態で画面幅が広いとき、画面左上のアイコンが表示されない問題を修正
|
||||
- Fix: チャットでIMEの変換を確定するEnterでメッセージが送信されてしまうことがある問題を修正
|
||||
@@ -37,32 +42,32 @@
|
||||
- Fix: いくつかのイベントリスナーが正しく解除されない問題を修正(メモリ使用量の改善)
|
||||
- Fix: 非ログイン時トップページをスクロール操作できないことがある問題を修正
|
||||
- Fix: ローカルユーザーへのホスト付きメンションが本文に含まれる指名ノートの作成時、投稿フォームにて、当該ユーザーが宛先に含まれていても正しく認識されない問題を修正
|
||||
- Fix: チャートの描画終了後にリソースが解放されない問題を修正
|
||||
- Fix: ドライブの「このファイルからノートを作成」やギャラリーの「ノートで共有」、誕生日ウィジェットからのノート作成において、通常投稿の下書きが表示される問題を修正
|
||||
- Fix: QRコードリーダーがページを離れても停止しない問題を修正
|
||||
- Fix: ノートの詳細表示で削除された引用元が表示されない問題を修正
|
||||
|
||||
### Server
|
||||
- Feat: OpenTelemetryサポート
|
||||
- 詳細な設定はconfigファイルを参照してください。
|
||||
- Sentryとの併用も可能です。Sentry併用時は、PostgreSQL Query と Redis command は Sentry で計装されます。
|
||||
- 以下の自動計装をサポートしています。(計装対象にする項目は設定可能)
|
||||
- PostgreSQL query
|
||||
- Redis command
|
||||
- 全ての受信HTTPリクエスト
|
||||
- 全ての送信HTTPリクエスト
|
||||
- ジョブキュー(エンキュー元のトレースを含む)
|
||||
- Feat: ログ基盤の刷新
|
||||
- API内部エラーのログに構造化属性と正規化したエラー情報を付与し、認証情報を自動的に秘匿するように(従来形式の表示は維持)
|
||||
- ログ全体の既定出力レベルとドメインごとの出力レベルを設定できるように
|
||||
- バックエンドのログを1行JSON形式で出力できるように
|
||||
- SentryのTrace ContextをJSON形式のログへ関連付けられるように
|
||||
- HTTPのAccess logをstatus class単位で出力できるように(開発時のリクエスト・レスポンス本文、SentryのTrace Contextにも対応)
|
||||
- Enhance: Sentry バックエンドの自動計装を `sentryForBackend.disabledIntegrations` で個別に無効化できるように
|
||||
- Enhance: センシティブメディアの判定を外部サービス ([sensitive-detector](https://github.com/misskey-dev/sensitive-detector)) に分離し、`nsfwjs` / `@tensorflow/tfjs(-node)` の同梱と NSFW 判定モデルを廃止 (#16804)
|
||||
- Enhance: Node.js 22.23.0以降、24.17.0以降、26.4.0以降をサポートするように
|
||||
- Enhance: Node.js 22.22.2以降、24.17.0以降、26.4.0以降をサポートするように
|
||||
- Enhance: Docker Image の Node.js を 26.4.0 に、Debian を trixie (v13) に更新
|
||||
- Enhance: URLプレビューの結果を内部でキャッシュするように
|
||||
- Enhance: API内部エラーのログに構造化属性と正規化したエラー情報を付与し、認証情報を自動的に秘匿するように(従来形式の表示は維持)
|
||||
- Enhance: ログ全体の既定levelとlogger domainごとの出力levelを設定できるように
|
||||
- Enhance: バックエンドのログを1行JSON形式で出力できるように
|
||||
- Fix: `/stats` API のレスポンス型が正しくない問題を修正
|
||||
- Fix: ハッシュタグに関連するデータを更新する際のエラーハンドリングを修正
|
||||
- Fix: Sentry 使用環境下にて、Misskey が発行した SQL クエリが span に含まれない問題を修正
|
||||
- Fix: Sentry 使用環境下にて、外部送信リクエストへ `sentry-trace` / `baggage` ヘッダーが既定で付与されないように
|
||||
- Fix: フォロワー限定投稿へのリプライをホーム投稿に出来る問題を修正
|
||||
- Fix: ファイルをアップロードするAPIにて、処理終了後に一時ファイルが削除されないことがある問題を修正
|
||||
- Fix: 初期設定で作成したアカウント以外でアカウント作成APIが使用できない問題を修正
|
||||
- Fix: フォロー中のチャンネルを再度フォローした際にALREADY_FOLLOWINGエラーを返すように
|
||||
- Fix: セキュリティに関する修正
|
||||
|
||||
## 2026.6.0
|
||||
|
||||
|
||||
+10
@@ -30,6 +30,8 @@ COPY --link ["packages/sw/package.json", "./packages/sw/"]
|
||||
COPY --link ["packages/misskey-js/package.json", "./packages/misskey-js/"]
|
||||
COPY --link ["packages/misskey-reversi/package.json", "./packages/misskey-reversi/"]
|
||||
COPY --link ["packages/misskey-bubble-game/package.json", "./packages/misskey-bubble-game/"]
|
||||
COPY --link ["packages/misskey-world/package.json", "./packages/misskey-world/"]
|
||||
COPY --link ["packages/frontend-misskey-world-engine/package.json", "./packages/frontend-misskey-world-engine/"]
|
||||
|
||||
ARG NODE_ENV=production
|
||||
|
||||
@@ -61,6 +63,8 @@ COPY --link ["packages/backend/package.json", "./packages/backend/"]
|
||||
COPY --link ["packages/misskey-js/package.json", "./packages/misskey-js/"]
|
||||
COPY --link ["packages/misskey-reversi/package.json", "./packages/misskey-reversi/"]
|
||||
COPY --link ["packages/misskey-bubble-game/package.json", "./packages/misskey-bubble-game/"]
|
||||
COPY --link ["packages/misskey-world/package.json", "./packages/misskey-world/"]
|
||||
COPY --link ["packages/frontend-misskey-world-engine/package.json", "./packages/frontend-misskey-world-engine/"]
|
||||
|
||||
ARG NODE_ENV=production
|
||||
|
||||
@@ -99,12 +103,18 @@ COPY --chown=misskey:misskey --from=target-builder /misskey/packages/backend/nod
|
||||
COPY --chown=misskey:misskey --from=target-builder /misskey/packages/misskey-js/node_modules ./packages/misskey-js/node_modules
|
||||
COPY --chown=misskey:misskey --from=target-builder /misskey/packages/misskey-reversi/node_modules ./packages/misskey-reversi/node_modules
|
||||
COPY --chown=misskey:misskey --from=target-builder /misskey/packages/misskey-bubble-game/node_modules ./packages/misskey-bubble-game/node_modules
|
||||
COPY --chown=misskey:misskey --from=target-builder /misskey/packages/misskey-world/node_modules ./packages/misskey-world/node_modules
|
||||
COPY --chown=misskey:misskey --from=target-builder /misskey/packages/frontend-misskey-world-engine/node_modules ./packages/frontend-misskey-world-engine/node_modules
|
||||
|
||||
COPY --chown=misskey:misskey --from=native-builder /misskey/built ./built
|
||||
COPY --chown=misskey:misskey --from=native-builder /misskey/packages/misskey-js/built ./packages/misskey-js/built
|
||||
COPY --chown=misskey:misskey --from=native-builder /misskey/packages/misskey-reversi/built ./packages/misskey-reversi/built
|
||||
COPY --chown=misskey:misskey --from=native-builder /misskey/packages/misskey-bubble-game/built ./packages/misskey-bubble-game/built
|
||||
COPY --chown=misskey:misskey --from=native-builder /misskey/packages/backend/built ./packages/backend/built
|
||||
COPY --chown=misskey:misskey --from=native-builder /misskey/packages/i18n/built ./packages/i18n/built
|
||||
COPY --chown=misskey:misskey --from=target-builder /misskey/packages/misskey-world/built ./packages/misskey-world/built
|
||||
COPY --chown=misskey:misskey --from=target-builder /misskey/packages/frontend-misskey-world-engine/built ./packages/frontend-misskey-world-engine/built
|
||||
|
||||
COPY --chown=misskey:misskey . ./
|
||||
|
||||
ENV LD_PRELOAD=/usr/local/lib/libjemalloc.so
|
||||
|
||||
@@ -1,176 +0,0 @@
|
||||
# Frontend bundle report: generated chunk handling
|
||||
|
||||
## Background
|
||||
|
||||
The frontend bundle report compares the Vite manifests produced for the pull request base and head builds. Chunks with a `src` value are currently identified by that source path. Chunks without `src` are identified by Rolldown's generated `name`.
|
||||
|
||||
Generated names are not unique or stable identities. Multiple unrelated shared chunks can all be called `dist`, `esm`, `index`, or a similar path-derived name. The current collection logic stores those chunks in a `Map` under `chunk:<name>`, so later entries overwrite earlier entries. This produces two problems:
|
||||
|
||||
1. unrelated chunks can be compared as if they were the same chunk; and
|
||||
2. overwritten chunks are omitted from the reported total.
|
||||
|
||||
Individual size changes for these generated shared chunks are not sufficiently actionable to justify heuristic matching.
|
||||
|
||||
## Goals
|
||||
|
||||
- Never compare unrelated generated chunks as the same chunk.
|
||||
- Keep generated chunks out of the individual chunk-diff rows.
|
||||
- Preserve the contribution of every physical JavaScript chunk in totals.
|
||||
- Show the aggregate size change of generated chunks so unexplained bundle growth remains visible.
|
||||
- Apply the same rules to the full chunk report and the startup chunk report.
|
||||
- Continue comparing source-backed chunks and intentionally named chunks.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- Inferring chunk identity from module-set similarity.
|
||||
- Preserving an `updated` relationship for every shared chunk after code splitting changes.
|
||||
- Changing the frontend's code-splitting strategy or output filenames.
|
||||
- Suppressing specific names such as `esm` or `dist` with a denylist.
|
||||
|
||||
## Approaches considered
|
||||
|
||||
### Denylist generated-looking names
|
||||
|
||||
Exclude names such as `esm`, `dist`, `lib`, and `index`.
|
||||
|
||||
This is not selected because Rolldown can generate many other names, and a denylist can also hide an intentionally named chunk. It would leave the underlying name collision and total-size bug in place.
|
||||
|
||||
### Compare only stable identities and aggregate the rest
|
||||
|
||||
Classify chunks by whether the report has a stable semantic identity. Source-backed chunks and explicitly supported manual chunk names remain individually comparable. All other generated chunks are retained as physical files but shown only as an aggregate.
|
||||
|
||||
This is the selected approach. It removes false comparisons without introducing matching heuristics, and it keeps all bytes visible.
|
||||
|
||||
### Match shared chunks by their module sets
|
||||
|
||||
Use visualizer metadata to compare exact or similar sets of module IDs.
|
||||
|
||||
This could retain more individual diffs, but splits, merges, dependency-version paths, and small module movements make the matching policy complex and potentially misleading. It can be added later if aggregate data proves insufficient.
|
||||
|
||||
## Design
|
||||
|
||||
### Separate physical chunks from comparable identities
|
||||
|
||||
`collectReport` will no longer use a single map keyed by the comparison identity as its source of truth. It will collect every resolved JavaScript output file exactly once into a physical chunk collection.
|
||||
|
||||
Each physical chunk contains:
|
||||
|
||||
- its resolved relative output path;
|
||||
- its byte size;
|
||||
- its manifest key, when present;
|
||||
- its display name; and
|
||||
- an optional comparison key.
|
||||
|
||||
The physical output path is used only for de-duplication within one build. It is not used to match changed chunks across builds.
|
||||
|
||||
### Comparison-key classification
|
||||
|
||||
A chunk receives a comparison key only in one of these cases:
|
||||
|
||||
1. `chunk.src` is present: use `src:<normalized source path>`.
|
||||
2. `chunk.name` is in an explicit allowlist of intentionally stable manual chunks: use `named:<name>`.
|
||||
|
||||
The initial stable-name allowlist is:
|
||||
|
||||
- `vue`
|
||||
- `i18n`
|
||||
|
||||
These names correspond to the explicit `codeSplitting.groups` configuration in `packages/frontend/vite.config.ts`.
|
||||
|
||||
All other manifest chunks without `src`, including generated names such as `esm` and `dist`, receive no comparison key. JavaScript files found in the localized output directory but absent from the manifest also receive no comparison key.
|
||||
|
||||
If a supposedly stable comparison key occurs more than once within one build, the report must not silently overwrite it. Collection will fail with a descriptive error because duplicate `src` or allowlisted manual names violate the assumptions required for a correct comparison.
|
||||
|
||||
### Full chunk report
|
||||
|
||||
The report computes three independent values:
|
||||
|
||||
1. **Total:** the sum of every unique physical JavaScript chunk.
|
||||
2. **Generated chunk aggregate:** the sum of chunks without a comparison key.
|
||||
3. **Individual rows:** before/after comparisons for stable comparison keys only.
|
||||
|
||||
The table starts with the existing `(total)` row. Generated chunks are rendered at the bottom of the table as one aggregate row such as:
|
||||
|
||||
```text
|
||||
(other generated chunks)
|
||||
```
|
||||
|
||||
This row compares aggregate sizes, not individual chunk identities. Generated chunks do not participate in the updated/added/removed row counts. No additional generated-chunk count note is rendered below the table.
|
||||
|
||||
### Small-delta aggregation
|
||||
|
||||
Stable comparison rows whose absolute byte delta is at most `5 B` are grouped into an `(other)` aggregate instead of being rendered individually. The threshold is inclusive: deltas from `-5 B` through `+5 B` are grouped, while a `6 B` absolute delta remains an individual row. Small additions and removals are handled by the same rule.
|
||||
|
||||
The `(other)` row reports the sum of the grouped chunks' before sizes and the sum of their after sizes. It does not expose an arbitrary representative filename. In the full chunk report, only changed rows are candidates because unchanged rows are already omitted. In the startup report, all rows currently eligible for display are candidates, so unchanged rows are also grouped instead of being listed individually.
|
||||
|
||||
The updated/added/removed counts are calculated before small-delta rows are grouped. Therefore the summary continues to include every stable changed chunk, including chunks represented only by `(other)`.
|
||||
|
||||
Table rows are ordered as follows:
|
||||
|
||||
1. `(total)`;
|
||||
2. one empty separator row;
|
||||
3. individual stable comparison rows whose absolute delta is greater than `5 B`;
|
||||
4. `(other generated chunks)`, when generated chunks exist; and
|
||||
5. `(other)`, when small-delta stable chunks exist.
|
||||
|
||||
There is no additional separator row before the aggregate rows. If no individual stable row exists, the empty row after `(total)` is therefore immediately followed by the aggregate rows.
|
||||
|
||||
The existing 30-row limit applies after small-delta rows have been removed from the individual-row candidates.
|
||||
|
||||
### Startup chunk report
|
||||
|
||||
Startup traversal continues to follow the entry chunk's static `imports`, but it records manifest keys or resolved physical file paths rather than generated comparison keys. This prevents two startup chunks named `dist` from collapsing into one.
|
||||
|
||||
Startup totals include every unique physical startup chunk. Stable startup chunks receive individual rows, while startup chunks without stable identities contribute to the same `(other generated chunks)` aggregate row within the startup table.
|
||||
|
||||
### Displayed filenames
|
||||
|
||||
For an individually comparable chunk, the details should expose both filenames when they differ. A single before-side filename must not imply that the after size belongs to that same file.
|
||||
|
||||
The display can use one filename when unchanged and `before → after` when the content-hashed filename differs.
|
||||
|
||||
The generated aggregate row does not display an arbitrary representative filename.
|
||||
|
||||
## Data flow
|
||||
|
||||
1. Read each build's Vite manifest.
|
||||
2. Resolve localized output files and collect unique physical chunks.
|
||||
3. Attach optional stable comparison keys using the classification rules.
|
||||
4. Traverse startup imports using manifest identities and map them to physical chunks.
|
||||
5. Calculate full and startup totals from physical chunks.
|
||||
6. Calculate generated aggregates from chunks without comparison keys.
|
||||
7. Compare only unique stable keys for individual rows.
|
||||
8. Render totals, generated aggregates, and stable rows.
|
||||
|
||||
## Error handling
|
||||
|
||||
- A manifest entry whose expected localized JavaScript file is absent remains a fatal report-generation error.
|
||||
- Duplicate physical output paths are de-duplicated and counted once.
|
||||
- Duplicate stable comparison keys fail with an error that includes the key and conflicting files.
|
||||
- Missing or malformed manifest data remains fatal rather than producing a partial report.
|
||||
|
||||
## Testing
|
||||
|
||||
Add focused fixtures or pure-function tests covering:
|
||||
|
||||
- two unrelated `dist` chunks are both counted in total and grouped into the generated aggregate;
|
||||
- `dist` and `esm` chunks never produce individual comparison rows;
|
||||
- source-backed chunks with the same `src` are reported as updated;
|
||||
- source-backed additions and removals remain visible;
|
||||
- allowlisted `vue` and `i18n` chunks remain individually comparable;
|
||||
- duplicate stable keys produce a descriptive error instead of overwriting;
|
||||
- full totals equal the sum of all unique physical chunks;
|
||||
- startup totals include multiple same-name generated chunks exactly once each;
|
||||
- generated aggregate changes do not affect the updated/added/removed summary counts;
|
||||
- differing before/after filenames are rendered without attributing both sizes to one file;
|
||||
- deltas of exactly `5 B` are grouped into `(other)`, while `6 B` deltas remain individual;
|
||||
- small updated, added, and removed chunks remain included in the summary counts;
|
||||
- an empty separator row appears immediately after `(total)`, with no additional separator before `(other generated chunks)` or `(other)`;
|
||||
- no generated-chunk grouping note is rendered below the table; and
|
||||
- the same small-delta and ordering rules apply to the full and startup tables.
|
||||
|
||||
Validation should include the focused tests and repository lint. No CHANGELOG entry is required because this changes developer-facing CI reporting rather than Misskey user behavior.
|
||||
|
||||
## Future extension
|
||||
|
||||
If aggregate generated-chunk data is later found insufficient, visualizer module metadata can support a separate opt-in analysis. That extension must preserve the physical-chunk accounting introduced here and must not reintroduce name-based identity.
|
||||
@@ -619,6 +619,8 @@ output: "Sortida"
|
||||
script: "Script"
|
||||
disablePagesScript: "Desactivar AiScript a les pàgines "
|
||||
updateRemoteUser: "Actualitzar la informació de l'usuari remot"
|
||||
unsetMfa: "Desactiva l'autenticació de dos factors"
|
||||
unsetMfaConfirm: "Voldries desactivar l'autenticació de dos factors?"
|
||||
unsetUserAvatar: "Desactiva l'avatar "
|
||||
unsetUserAvatarConfirm: "Segur que vols desactivar l'avatar?"
|
||||
unsetUserBanner: "Desactiva el bàner "
|
||||
@@ -1417,6 +1419,8 @@ addToEmojiPalette: "Afegeix al calaix d'emojis"
|
||||
emojiPaletteAlreadyAddedConfirm: "Aquest emoji ja està inclòs en aquest calaix d'emojis. Vols afegir-lo de nou?"
|
||||
append: "Afegeix al final"
|
||||
prepend: "Afegeix al principi"
|
||||
urlPreviewSensitiveList: "Llista d'URLs per restringir la visualització de miniatures"
|
||||
urlPreviewSensitiveListDescription: "Si separeu els termes amb un espai, s'interpretarà com una condició 'AND'; si els separeu amb un salt de línia, s'interpretarà com una condició 'OR'. Si envolupeu els termes amb barres obliqües, s'interpretarà com una expressió regular. Si es troba una coincidència, la miniatura ja no es mostrarà."
|
||||
_imageEditing:
|
||||
_vars:
|
||||
caption: "Títol de l'arxiu"
|
||||
@@ -2149,6 +2153,15 @@ _sensitiveMediaDetection:
|
||||
setSensitiveFlagAutomaticallyDescription: "Els resultats de la detecció interna seran desats, inclòs si aquesta opció es troba desactivada."
|
||||
analyzeVideos: "Activar anàlisis de vídeos "
|
||||
analyzeVideosDescription: "Analitzar els vídeos a més de les imatges. Això incrementarà lleugerament la càrrega del servidor."
|
||||
externalServiceInfo: "La detecció de mitjans sensibles s'ha delegat a un servei extern (sensitive-detector). Per utilitzar aquesta funció, cal configurar un servei sidecar separat i establir els detalls de connexió que es detallen a continuació. Si no es configuren els detalls de connexió, no es durà a terme cap detecció (el contingut es tractarà com a no sensible)."
|
||||
apiUrl: "URL per connectar-se al servei de verificació"
|
||||
apiUrlDescription: "L'URL base del servei de detector de sensibilitat (per exemple, http://localhost:3009). Si us connecteu a un servei en una xarxa privada, afegiu la xarxa de destinació a la configuració `allowedPrivateNetworks` del fitxer de configuració. Si utilitzeu un proxy, configureu també `proxyBypassHosts`. Si es deixa en blanc, no es realitzaran comprovacions de sensibilitat."
|
||||
apiKey: "Clau de l'API"
|
||||
apiKeyDescription: "Introduïu això si l'autenticació (token Bearer) està configurada al costat del servei d'autenticació. Si no està configurada, deixeu aquest camp en blanc."
|
||||
timeout: "Temps d'espera (mil·lisegons)"
|
||||
timeoutDescription: "1 Aquesta és la durada del temps mort per sol·licitud de validació."
|
||||
maxImagesPerRequest: "1 Nombre màxim d'imatges per sol·licitud"
|
||||
maxImagesPerRequestDescription: "1 Quan s'analitzen múltiples fotogrames, com en un vídeo, aquest és el nombre màxim d'imatges que es poden enviar en una única petició. Qualsevol imatge que superi aquest límit es dividirà en parts i s'enviarà seqüencialment. Assegureu-vos que aquest ajust no superi el valor de `maxParts` al costat del `sensitive-detector` (per defecte: 10). Si es supera aquest límit, totes les imatges d'aquest lot es consideraran no sensibles."
|
||||
_emailUnavailable:
|
||||
used: "Aquest correu electrònic ja s'està fent servir"
|
||||
format: "El format del correu electrònic és invàlid "
|
||||
@@ -2461,6 +2474,7 @@ _permissions:
|
||||
"read:admin:show-moderation-log": "Veure registre de moderació "
|
||||
"read:admin:show-user": "Veure informació privada de l'usuari "
|
||||
"write:admin:suspend-user": "Suspendre usuari"
|
||||
"write:admin:unset-mfa": "Desactiva l'autenticació de dos factors per a un usuari"
|
||||
"write:admin:unset-user-avatar": "Esborrar avatar d'usuari "
|
||||
"write:admin:unset-user-banner": "Esborrar bàner de l'usuari "
|
||||
"write:admin:unsuspend-user": "Treure la suspensió d'un usuari"
|
||||
@@ -2976,6 +2990,7 @@ _moderationLogTypes:
|
||||
createAvatarDecoration: "Decoració de l'avatar creada"
|
||||
updateAvatarDecoration: "S'ha actualitzat la decoració de l'avatar "
|
||||
deleteAvatarDecoration: "S'ha esborrat la decoració de l'avatar "
|
||||
unsetMfa: "Desactiva l'autenticació de dos factors per a l'usuari"
|
||||
unsetUserAvatar: "Esborrar l'avatar d'aquest usuari"
|
||||
unsetUserBanner: "Esborrar el bàner d'aquest usuari"
|
||||
createSystemWebhook: "Crear un SystemWebhook"
|
||||
|
||||
@@ -619,6 +619,8 @@ output: "Output"
|
||||
script: "Script"
|
||||
disablePagesScript: "Disable AiScript on Pages"
|
||||
updateRemoteUser: "Update remote user information"
|
||||
unsetMfa: "Reset two-factor authentication"
|
||||
unsetMfaConfirm: "Are you sure you want to reset two-factor authentication?"
|
||||
unsetUserAvatar: "Unset avatar"
|
||||
unsetUserAvatarConfirm: "Are you sure you want to unset the avatar?"
|
||||
unsetUserBanner: "Unset banner"
|
||||
@@ -1417,6 +1419,8 @@ addToEmojiPalette: "Add to emoji palette"
|
||||
emojiPaletteAlreadyAddedConfirm: "This emoji is already included in this emoji palette. Do you want to add it again?"
|
||||
append: "Append to end"
|
||||
prepend: "Append to beginning"
|
||||
urlPreviewSensitiveList: "URL to restrict thumbnail display"
|
||||
urlPreviewSensitiveListDescription: "Use spaces to specify AND conditions, and line breaks to specify OR conditions. Enclose text in slashes to use regular expressions. If a match is found, the thumbnail will be hidden."
|
||||
_imageEditing:
|
||||
_vars:
|
||||
caption: "File caption"
|
||||
@@ -2149,6 +2153,15 @@ _sensitiveMediaDetection:
|
||||
setSensitiveFlagAutomaticallyDescription: "The results of the internal detection will be retained even if this option is turned off."
|
||||
analyzeVideos: "Enable analysis of videos"
|
||||
analyzeVideosDescription: "Analyzes videos in addition to images. This will slightly increase the load on the server."
|
||||
externalServiceInfo: "The detection of sensitive media has been offloaded to an external service (sensitive-detector). To use this feature, you must set up a separate service and configure the connection details provided below. If no connection details are configured, no detection will be performed (it will be treated as non-sensitive)."
|
||||
apiUrl: "Detection service endpoint URL"
|
||||
apiUrlDescription: "The base URL for the sensitive-detector service (e.g., http://localhost:3009). If you are connecting to a service on a private network, please allow the target network in the allowedPrivateNetworks setting in the configuration file. If you are using a proxy, please also configure proxyBypassHosts. If left blank, sensitive media detection will not be performed."
|
||||
apiKey: "API key"
|
||||
apiKeyDescription: "Enter this if authentication (Bearer token) is configured on the detector service. If it is not configured, please leave it blank."
|
||||
timeout: "Timeout (Milliseconds)"
|
||||
timeoutDescription: "Timeout duration for each judgment request."
|
||||
maxImagesPerRequest: "Max images per request"
|
||||
maxImagesPerRequestDescription: "Maximum number of images that can be sent in a single request when processing multi-frame at once, such as videos. Any images exceeding this limit will be split and sent sequentially. Please ensure this is set to not exceed the maxParts setting (default: 10) on the detector service. If it exceeds this limit, all items in that chunk will be treated as non-sensitive."
|
||||
_emailUnavailable:
|
||||
used: "This email address is already being used"
|
||||
format: "The format of this email address is invalid"
|
||||
@@ -2461,6 +2474,7 @@ _permissions:
|
||||
"read:admin:show-moderation-log": "View moderation log"
|
||||
"read:admin:show-user": "View private user info"
|
||||
"write:admin:suspend-user": "Suspend user"
|
||||
"write:admin:unset-mfa": "Reset two-factor authentication for the user"
|
||||
"write:admin:unset-user-avatar": "Remove user avatar"
|
||||
"write:admin:unset-user-banner": "Remove user banner"
|
||||
"write:admin:unsuspend-user": "Unsuspend user"
|
||||
@@ -2976,6 +2990,7 @@ _moderationLogTypes:
|
||||
createAvatarDecoration: "Avatar decoration created"
|
||||
updateAvatarDecoration: "Avatar decoration updated"
|
||||
deleteAvatarDecoration: "Avatar decoration deleted"
|
||||
unsetMfa: "Reset two-factor authentication for the user"
|
||||
unsetUserAvatar: "User avatar unset"
|
||||
unsetUserBanner: "User banner unset"
|
||||
createSystemWebhook: "System Webhook created"
|
||||
|
||||
@@ -619,6 +619,8 @@ output: "Salida"
|
||||
script: "Script"
|
||||
disablePagesScript: "Deshabilitar AiScript en Páginas"
|
||||
updateRemoteUser: "Actualizar información de usuario remoto"
|
||||
unsetMfa: "Desactivar la autenticación de dos factores"
|
||||
unsetMfaConfirm: "¿Desea desactivar la autenticación de dos factores?"
|
||||
unsetUserAvatar: "Quitar avatar"
|
||||
unsetUserAvatarConfirm: "¿Confirmas que quieres quitar tu avatar?"
|
||||
unsetUserBanner: "Quitar banner"
|
||||
@@ -1417,6 +1419,8 @@ addToEmojiPalette: "Añadir a la paleta de emojis"
|
||||
emojiPaletteAlreadyAddedConfirm: "Este emoji ya está incluido en esta paleta de emojis. ¿Quieres volver a añadirlo?"
|
||||
append: "Añadir al final"
|
||||
prepend: "Añadir al principio"
|
||||
urlPreviewSensitiveList: "URL para restringir la visualización de miniaturas"
|
||||
urlPreviewSensitiveListDescription: "Si se separan con un espacio, se interpretará como una condición «AND»; si se separan con un salto de línea, se interpretará como una condición «OR». Si se escriben entre barras, se interpretarán como expresiones regulares. Si se encuentra una coincidencia, no se mostrará la miniatura."
|
||||
_imageEditing:
|
||||
_vars:
|
||||
caption: "Título del archivo"
|
||||
@@ -2149,6 +2153,15 @@ _sensitiveMediaDetection:
|
||||
setSensitiveFlagAutomaticallyDescription: "Los resultados de la detección interna pueden ser retenidos incluso si la opción está desactivada."
|
||||
analyzeVideos: "Habilitar el análisis de videos"
|
||||
analyzeVideosDescription: "Analizar videos en adición a las imágenes. Esto puede incrementar ligeramente la carga del servidor."
|
||||
externalServiceInfo: "La detección de contenidos sensibles se ha externalizado a un servicio externo (sensitive-detector). Para utilizar esta función, es necesario configurar por separado un servicio «sidecar» y establecer los datos de conexión que se indican a continuación. Si no se configuran los datos de conexión, no se realizará la detección (se tratará como contenido no sensible)."
|
||||
apiUrl: "URL de conexión al servicio de verificación"
|
||||
apiUrlDescription: "URL base del servicio «sensitive-detector» (por ejemplo: http://localhost:3009). Si te conectas a un servicio situado en una red privada, debes permitir la red de destino en el parámetro «allowedPrivateNetworks» del archivo de configuración. Si utilizas un proxy, configura también el parámetro «proxyBypassHosts». Si se deja en blanco, no se realizará la evaluación de datos sensibles."
|
||||
apiKey: "Clave API"
|
||||
apiKeyDescription: "Introduce este dato si se ha configurado la autenticación (token Bearer) en el servicio de validación. Si no se ha configurado, déjalo en blanco."
|
||||
timeout: "Tiempo de espera (milisegundos)"
|
||||
timeoutDescription: "Duración del tiempo de espera para cada solicitud de resolución."
|
||||
maxImagesPerRequest: "Número máximo de imágenes por solicitud"
|
||||
maxImagesPerRequestDescription: "Es el número máximo de imágenes que se pueden enviar en una sola solicitud al analizar varios fotogramas, como en un vídeo. Las imágenes que superen este límite se dividirán y se enviarán de forma secuencial. Configúralo de manera que no se supere el valor de «maxParts» de sensitive-detector (por defecto: 10). Si se supera este límite, todas las imágenes de ese fragmento se considerarán no sensibles."
|
||||
_emailUnavailable:
|
||||
used: "Ya fue usado"
|
||||
format: "Formato no válido."
|
||||
@@ -2461,6 +2474,7 @@ _permissions:
|
||||
"read:admin:show-moderation-log": "Ver log de moderación"
|
||||
"read:admin:show-user": "Ver información privada de usuario"
|
||||
"write:admin:suspend-user": "Suspender cuentas de usuario"
|
||||
"write:admin:unset-mfa": "Desactivar la autenticación de dos factores del usuario"
|
||||
"write:admin:unset-user-avatar": "Quitar avatares de usuario"
|
||||
"write:admin:unset-user-banner": "Quitar banner de usuarios"
|
||||
"write:admin:unsuspend-user": "Quitar suspensión de cuentas de usuario"
|
||||
@@ -2976,6 +2990,7 @@ _moderationLogTypes:
|
||||
createAvatarDecoration: "Decoración de avatar creada"
|
||||
updateAvatarDecoration: "Decoración de avatar actualizada"
|
||||
deleteAvatarDecoration: "Decoración de avatar eliminada"
|
||||
unsetMfa: "Desactivar la autenticación de dos factores del usuario"
|
||||
unsetUserAvatar: "Quitar decoración de avatar de este usuario"
|
||||
unsetUserBanner: "Quitar banner de este usuario"
|
||||
createSystemWebhook: "Crear un SystemWebhook"
|
||||
|
||||
@@ -619,6 +619,8 @@ output: "Output"
|
||||
script: "Script"
|
||||
disablePagesScript: "Disabilitare AiScript nelle pagine"
|
||||
updateRemoteUser: "Aggiorna dati dal profilo remoto"
|
||||
unsetMfa: "Rimuovere l'autenticazione a due fattori (2FA/MFA)"
|
||||
unsetMfaConfirm: "Vuoi davvero rimuovere l'autenticazione a due fattori?"
|
||||
unsetUserAvatar: "Rimozione foto profilo"
|
||||
unsetUserAvatarConfirm: "Vuoi davvero rimuovere la foto profilo?"
|
||||
unsetUserBanner: "Rimuovi intestazione profilo"
|
||||
@@ -1417,6 +1419,8 @@ addToEmojiPalette: "Aggiungi alla tavolozza emoji"
|
||||
emojiPaletteAlreadyAddedConfirm: "Questa emoji è già inclusa in nella tavolozza. Vuoi davvero aggiungerla?"
|
||||
append: "Accodare"
|
||||
prepend: "Anteporre"
|
||||
urlPreviewSensitiveList: "URL da impedire alla vista delle anteprime"
|
||||
urlPreviewSensitiveListDescription: "Separando con uno spazio si indica E, separando con una linea si indica O. Circondando con barre / si indica una Espressione Regolare.\nLe URL che coincidono con le indicazioni non verranno visualizzate."
|
||||
_imageEditing:
|
||||
_vars:
|
||||
caption: "Didascalia dell'immagine"
|
||||
@@ -2149,6 +2153,15 @@ _sensitiveMediaDetection:
|
||||
setSensitiveFlagAutomaticallyDescription: "Anche se questa impostazione è disattivata, il risultato della decisione viene conservato internamente."
|
||||
analyzeVideos: "Abilitazione dell'analisi video."
|
||||
analyzeVideosDescription: "Assicuratevi che vengano analizzati anche i video oltre alle immagini fisse. Il carico del server aumenterà leggermente."
|
||||
externalServiceInfo: "Abbiamo spostato esternamente il riconoscimento di media espliciti (sensitive-detector). Per usufruirne devi impostare un servizio separato e indicare di seguito la destinazione. Se non è impostata, non viene emesso alcun giudizio (contenuto NON esplicito)."
|
||||
apiUrl: "URL di connessione a Sensitive-Detector"
|
||||
apiUrlDescription: "L'URL di base del servizio (ad esempio http://localhost:3009). Collegandosi a una rete privata, autorizzare la rete nel parametro allowedPrivateNetworks nel file di configurazione.\nCollegandosi con un proxy, è anche necessario impostare proxyBypassHosts. Nel caso il campo sia vuoto, non vengono emessi giudizi di sensibilità."
|
||||
apiKey: "Chiave API"
|
||||
apiKeyDescription: "Indicare il token di autenticazione (Bearer token), soltanto se occorre, altrimenti lasciare vuoto."
|
||||
timeout: "Timeout (ms)"
|
||||
timeoutDescription: "Tempo limite per ogni richiesta"
|
||||
maxImagesPerRequest: "Numero massimo di media per ogni richiesta"
|
||||
maxImagesPerRequestDescription: "In caso ci siano più fotogrammi, come nei video, questo è il numero massimo di immagini da inviare in una richiesta. Oltre questo numero, il totale sarà diviso e inviato in sequenza.\nImpostare in modo che non superi il valore maxParts (default: 10) in Sensitive-Detector. Se viene superato, il media sarà considerato non esplicito."
|
||||
_emailUnavailable:
|
||||
used: "Email già in uso"
|
||||
format: "Formato email non valido"
|
||||
@@ -2461,6 +2474,7 @@ _permissions:
|
||||
"read:admin:show-moderation-log": "Vedere lo storico di moderazione"
|
||||
"read:admin:show-user": "Vedere le informazioni private dei profili"
|
||||
"write:admin:suspend-user": "Sospendere i profili"
|
||||
"write:admin:unset-mfa": "Può rimuovere l'autenticazione a due fattori (2FA/MFA)"
|
||||
"write:admin:unset-user-avatar": "Rimuovere la foto profilo dai profili"
|
||||
"write:admin:unset-user-banner": "Rimuovere l'immagine testata dai profili"
|
||||
"write:admin:unsuspend-user": "Rimuovere la sospensione ai profili"
|
||||
@@ -2976,6 +2990,7 @@ _moderationLogTypes:
|
||||
createAvatarDecoration: "Crea una decorazione della foto profilo"
|
||||
updateAvatarDecoration: "Modifica una decorazione della foto profilo"
|
||||
deleteAvatarDecoration: "Elimina una decorazione della foto profilo"
|
||||
unsetMfa: "Rimossa l'autenticazione a due fattori (2FA7MFA)"
|
||||
unsetUserAvatar: "Toglie una foto profilo"
|
||||
unsetUserBanner: "Toglie una immagine di intestazione profilo"
|
||||
createSystemWebhook: "Aggiunge un System Webhook"
|
||||
|
||||
@@ -1414,6 +1414,8 @@ viewRenotedChannel: "リノート先のチャンネルを見る"
|
||||
previewingTheme: "テーマのプレビュー中"
|
||||
previewingThemeRestore: "元に戻す"
|
||||
accessToken: "アクセストークン"
|
||||
choose: "選択"
|
||||
rotate: "回転"
|
||||
chooseEmojiPalette: "絵文字パレットを選択"
|
||||
addToEmojiPalette: "絵文字パレットに追加"
|
||||
emojiPaletteAlreadyAddedConfirm: "この絵文字はすでにこの絵文字パレットに含まれています。追加しなおしますか?"
|
||||
@@ -3580,3 +3582,476 @@ _qr:
|
||||
scanFile: "端末の画像をスキャン"
|
||||
raw: "テキスト"
|
||||
mfm: "MFM"
|
||||
|
||||
worldAvatar: "Worldアバター"
|
||||
worldAvatar_description: "MisskeyWorld / MisskeyRoomsで使用可能な3Dアバターを作成できます。"
|
||||
|
||||
_miWorld:
|
||||
separateRenderingThread: "描画を別スレッドに分離"
|
||||
separateRenderingThread_description: "有効にするとパフォーマンスが向上します。不安定になる場合は無効すると改善する可能性があります。"
|
||||
graphicsQuality: "グラフィックの品質"
|
||||
graphicsSettings: "グラフィック設定"
|
||||
frameRateLimitation: "フレームレート制限"
|
||||
higherValuePerformanceNote: "高くすると体験が向上しますが、消費電力が増加するなどパフォーマンスに影響を与えます。"
|
||||
resolution: "解像度"
|
||||
fov: "視野角"
|
||||
failedToInitialize: "初期化に失敗しました"
|
||||
crushed_description: "描画が継続できなくなりました。デバイスのリソース不足の可能性が考えられます。"
|
||||
antialiasing: "アンチエイリアス"
|
||||
avatar: "アバター"
|
||||
advancedCustomize: "高度なアレンジ"
|
||||
attachAccessory: "アクセサリーをつける"
|
||||
takeScreenShot: "スクリーンショット"
|
||||
onlineMenu: "オンラインメニュー"
|
||||
connectToOnline: "オンラインに接続"
|
||||
disconnectToOnline: "オンラインから切断"
|
||||
character: "キャラクター"
|
||||
sit: "座る"
|
||||
lyingDown: "寝そべる"
|
||||
standUp: "立ち上がる"
|
||||
showUsernameOnAvatar: "アバターにユーザー名を表示"
|
||||
show2dAvatarOnAvatar: "アバターにユーザーアイコンを表示"
|
||||
|
||||
_avatars:
|
||||
_default:
|
||||
body: "ボディ"
|
||||
eyes: "目"
|
||||
mouth: "口"
|
||||
|
||||
_avatarAccessories:
|
||||
mug: "マグカップ"
|
||||
_mug:
|
||||
bodyMat: "コップの素材"
|
||||
liquidMat: "液体の素材"
|
||||
mikan: "みかん"
|
||||
bolt: "ボルト"
|
||||
_bolt:
|
||||
mat: "素材"
|
||||
|
||||
_miRoom:
|
||||
snapToGrid: "グリッドにスナップ"
|
||||
gridScale: "グリッドサイズ"
|
||||
thereAreUnsavedChanges: "未保存の変更があります"
|
||||
revertAllChangesConfirmation: "全ての変更を取り消し、部屋を最後に保存した状態まで戻しますか?"
|
||||
yourDeviceNotSupported_title: "MisskeyRoomを起動できません"
|
||||
yourDeviceNotSupported_description: "お使いのデバイスがMisskeyRoomをサポートしていないか、デバイスのリソース不足などにより一時的に利用できなくなっています。\nMisskeyRoomを動作させるには、WebGPUをサポートするデバイス・ブラウザが必要です。"
|
||||
imageFit: "画像のはめ込み"
|
||||
imageFit_cover: "覆う"
|
||||
imageFit_contain: "収める"
|
||||
imageFit_stretch: "伸縮"
|
||||
material_metallic: "光沢"
|
||||
material_roughness: "粗さ"
|
||||
light_brightness: "明るさ"
|
||||
advancedCustomize: "高度なアレンジ"
|
||||
enterEditMode: "エディットモードを始める"
|
||||
exitEditMode: "エディットモードを終了"
|
||||
installFurniture: "家具を設置"
|
||||
roomCustomize: "部屋のカスタマイズ"
|
||||
changeRoomName: "ルーム名を編集"
|
||||
roomInfo: "ルーム情報"
|
||||
duplicate: "複製"
|
||||
grab: "掴む"
|
||||
furnitureCustomize: "家具のアレンジ"
|
||||
uninstallFurniture: "しまう"
|
||||
furnituresCount: "家具の数"
|
||||
attachedFilesCount: "添付ファイル数"
|
||||
_furniturePlacement:
|
||||
top: "上面設置"
|
||||
bottom: "下面設置"
|
||||
side: "側面設置"
|
||||
|
||||
_furnitures:
|
||||
haniwa: "はにわ"
|
||||
_haniwa:
|
||||
bodyMat: "本体の素材"
|
||||
insideColor: "中身の色"
|
||||
woodRingFloorLamp: "リングシェードフロアランプ"
|
||||
_woodRingFloorLamp:
|
||||
shadeMat: "シェードの素材"
|
||||
bodyMat: "本体の素材"
|
||||
light: "照明"
|
||||
a4Case: "A4ケース"
|
||||
_a4Case:
|
||||
mat: "素材"
|
||||
aircon: "エアコン"
|
||||
allInOnePc: "一体型PC"
|
||||
_allInOnePc:
|
||||
bezelMat: "ベゼルの素材"
|
||||
bodyMat: "本体の素材"
|
||||
image: "画面の画像"
|
||||
image_desktop: "デスクトップ"
|
||||
screenBrightness: "画面の明るさ"
|
||||
aquarium: "水槽"
|
||||
aromaReedDiffuser: "アロマリードディフューザー"
|
||||
_aromaReedDiffuser:
|
||||
bottleMat: "ボトルの素材"
|
||||
oilMat: "オイルの素材"
|
||||
banknote: "紙幣"
|
||||
beamLamp: "ビームランプ"
|
||||
bed: "ベッド"
|
||||
_bed:
|
||||
frameMat: "フレームの素材"
|
||||
blind: "ブラインド"
|
||||
_blind:
|
||||
angle: "羽根の回転角度"
|
||||
blades: "羽根の枚数"
|
||||
open: "開閉状態"
|
||||
book: "本"
|
||||
_book:
|
||||
height: "高さ"
|
||||
thickness: "厚み"
|
||||
variation: "バリエーション"
|
||||
width: "幅"
|
||||
books: "本の束"
|
||||
_books:
|
||||
variation: "バリエーション"
|
||||
boxWallShelf: "ボックス型ウォールシェルフ"
|
||||
_boxWallShelf:
|
||||
bodyMat: "本体の素材"
|
||||
height: "高さ"
|
||||
width: "幅"
|
||||
withBack: "背板"
|
||||
cactusS: "サボテン S"
|
||||
_cactusS:
|
||||
potMat: "鉢の素材"
|
||||
cardboardBox: "段ボール箱"
|
||||
_cardboardBox:
|
||||
variation: "種類"
|
||||
variation_aizon: "Aizon"
|
||||
variation_default: "デフォルト"
|
||||
variation_mikan: "みかん"
|
||||
ceilingFanLight: "シーリングファンライト"
|
||||
_ceilingFanLight:
|
||||
shadeMat: "シェードの素材"
|
||||
bodyMat: "本体の素材"
|
||||
ceilingFan: "シーリングファン"
|
||||
_ceilingFan:
|
||||
shadeMat: "シェードの素材"
|
||||
bodyMat: "本体の素材"
|
||||
chair: "椅子"
|
||||
_chair:
|
||||
primaryMat: "メインの素材"
|
||||
secondaryMat: "サブの素材"
|
||||
frameMat: "フレームの素材"
|
||||
clippedPicture: "留められた写真"
|
||||
_clippedPicture:
|
||||
height: "高さ"
|
||||
image: "画像"
|
||||
width: "幅"
|
||||
coffeeCup: "コーヒーカップ"
|
||||
colorBox: "カラーボックス"
|
||||
_colorBox:
|
||||
mat: "素材"
|
||||
cuboid: "直方体"
|
||||
_cuboid:
|
||||
mat: "素材"
|
||||
x: "X"
|
||||
y: "Y"
|
||||
z: "Z"
|
||||
cupNoodle: "インスタントラーメン"
|
||||
curtain: "カーテン"
|
||||
custardPudding: "プリン"
|
||||
descriptionPlate: "説明が書かれたプレート"
|
||||
desk: "デスク"
|
||||
_desk:
|
||||
boardMat: "天板の素材"
|
||||
depth: "奥行き"
|
||||
frameMat: "フレームの素材"
|
||||
width: "幅"
|
||||
desktopPc: "デスクトップPC"
|
||||
_desktopPc:
|
||||
bodyMat: "本体の素材"
|
||||
coverMat: "カバーの素材"
|
||||
inner1Mat: "内部素材1"
|
||||
inner2Mat: "内部素材2"
|
||||
inner3Mat: "内部素材3"
|
||||
ledColor: "LEDの色"
|
||||
djMixer: "DJミキサー"
|
||||
djPlayer: "DJプレーヤー"
|
||||
_djPlayer:
|
||||
image: "画像"
|
||||
"image:waveform": "波形"
|
||||
screenBrightness: "画面の明るさ"
|
||||
ductRailSpotLights: "スポットライト付きダクトレール"
|
||||
_ductRailSpotLights:
|
||||
angleH: "水平角度"
|
||||
angleV: "垂直角度"
|
||||
bodyMat: "本体の素材"
|
||||
light: "照明"
|
||||
ductTape: "ガムテープ"
|
||||
herbarium: "ハーバリウム"
|
||||
electronicDisplayBoard: "電光掲示板"
|
||||
_electronicDisplayBoard:
|
||||
frameMat: "フレームの素材"
|
||||
ledBrightness: "LEDの明るさ"
|
||||
ledColor: "LEDの色"
|
||||
text: "テキスト"
|
||||
emptyBento: "空の弁当容器"
|
||||
energyDrink: "エナジードリンク"
|
||||
envelope: "封筒"
|
||||
facialTissue: "ティッシュ"
|
||||
glassCylinderPotPlant: "ガラスシリンダーの鉢植えと植物"
|
||||
handheldGameConsole: "携帯ゲーム機"
|
||||
_handheldGameConsole:
|
||||
bodyMat: "本体の素材"
|
||||
image: "画像"
|
||||
screenBrightness: "画面の明るさ"
|
||||
hangingDuctRail: "吊り下げダクトレール"
|
||||
_hangingDuctRail:
|
||||
bodyMat: "本体の素材"
|
||||
height: "高さ"
|
||||
width: "幅"
|
||||
hangingTShirt: "吊り下げTシャツ"
|
||||
icosahedron: "正二十面体のオブジェ"
|
||||
_icosahedron:
|
||||
mat: "素材"
|
||||
ironFrameTable: "アイアンフレームテーブル"
|
||||
_ironFrameTable:
|
||||
boardMat: "天板の素材"
|
||||
depth: "奥行き"
|
||||
frameMat: "フレームの素材"
|
||||
height: "高さ"
|
||||
width: "幅"
|
||||
issyoubin: "一升瓶"
|
||||
_issyoubin:
|
||||
variation: "種類"
|
||||
keyboard: "キーボード"
|
||||
_keyboard:
|
||||
bodyMat: "本体の素材"
|
||||
keyMat: "キーの素材"
|
||||
laptopPc: "ノートPC"
|
||||
_laptopPc:
|
||||
bezelMat: "ベゼルの素材"
|
||||
bodyMat: "本体の素材"
|
||||
image: "画像"
|
||||
openAngle: "開き具合"
|
||||
screenBrightness: "画面の明るさ"
|
||||
largeMousepad: "大きいマウスパッド"
|
||||
_largeMousepad:
|
||||
image: "画像"
|
||||
lavaLamp: "ラバランプ"
|
||||
_lavaLamp:
|
||||
bodyMat: "本体の素材"
|
||||
glassMat: "ガラスの素材"
|
||||
lavaColor: "オイルの色"
|
||||
lightColor: "ランプの色"
|
||||
letterCase: "レターケース"
|
||||
lowPartitionBar: "低いパーティションバー"
|
||||
_lowPartitionBar:
|
||||
bodyMat: "本体の素材"
|
||||
width: "幅"
|
||||
miObjet: "Miオブジェ"
|
||||
miPlate: "Miプレート"
|
||||
miPlateDisplayed: "飾られたMiプレート"
|
||||
milk: "牛乳"
|
||||
mixer: "ミキサー"
|
||||
monitor: "モニター"
|
||||
_monitor:
|
||||
bodyMat: "本体の素材"
|
||||
image: "画像"
|
||||
screenBrightness: "画面の明るさ"
|
||||
monitorSpeaker: "モニタースピーカー"
|
||||
_monitorSpeaker:
|
||||
mat: "素材"
|
||||
monstera: "モンステラ"
|
||||
_monstera:
|
||||
potMat: "鉢の素材"
|
||||
mug: "マグカップ"
|
||||
_mug:
|
||||
bodyMat: "コップの素材"
|
||||
liquidMat: "液体の素材"
|
||||
newtonsCradle: "ニュートンクレードル"
|
||||
_newtonsCradle:
|
||||
frameMat: "フレームの素材"
|
||||
openedCardboardBox: "開いた段ボール箱"
|
||||
pachira: "パキラ"
|
||||
_pachira:
|
||||
potMat: "鉢の素材"
|
||||
petBottle: "ペットボトル"
|
||||
_petBottle:
|
||||
empty: "空"
|
||||
variation: "種類"
|
||||
variation_greenTea: "緑茶"
|
||||
variation_mineralWater: "ミネラルウォーター"
|
||||
withCap: "キャップ"
|
||||
withLabel: "ラベル"
|
||||
piano: "ピアノ"
|
||||
_piano:
|
||||
bodyMat: "本体の素材"
|
||||
pictureFrame: "シンプルな額縁"
|
||||
_pictureFrame:
|
||||
depth: "厚さ"
|
||||
frameMat: "フレームの素材"
|
||||
frameThickness: "フレームの幅"
|
||||
height: "高さ"
|
||||
image: "画像"
|
||||
matHThickness: "マットの横幅"
|
||||
matVThickness: "マットの縦幅"
|
||||
width: "幅"
|
||||
withCover: "カバーあり"
|
||||
pizza: "ピザ"
|
||||
plant: "植物"
|
||||
plant2: "植物 2"
|
||||
poster: "ポスター"
|
||||
_poster:
|
||||
height: "高さ"
|
||||
image: "画像"
|
||||
width: "幅"
|
||||
powerStrip: "電源タップ"
|
||||
radiometer: "ラジオメーター"
|
||||
randomBooks: "雑多な本"
|
||||
_randomBooks:
|
||||
count: "数"
|
||||
seed: "シード"
|
||||
stackVertically: "平積み"
|
||||
variation: "バリエーション"
|
||||
variation_mix: "いろいろ"
|
||||
variation_mixPlain: "いろいろ(無地)"
|
||||
recordPlayer: "レコードプレーヤー"
|
||||
rolledUpPoster: "丸めたポスター"
|
||||
roundRug: "円形のラグ"
|
||||
router: "ルーター"
|
||||
siphon: "サイフォン"
|
||||
snakeplant: "サンセベリア"
|
||||
_snakeplant:
|
||||
potMat: "鉢の素材"
|
||||
sofa: "ソファ"
|
||||
_sofa:
|
||||
bodyMat: "本体の素材"
|
||||
speaker: "スピーカー"
|
||||
_speaker:
|
||||
innerMat: "内側の素材"
|
||||
outerMat: "外側の素材"
|
||||
speakerStand: "スピーカースタンド"
|
||||
_speakerStand:
|
||||
bodyMat: "本体の素材"
|
||||
height: "高さ"
|
||||
spotLight: "スポットライト"
|
||||
_spotLight:
|
||||
angleH: "横方向の角度"
|
||||
angleV: "縦方向の角度"
|
||||
bodyMat: "本体の素材"
|
||||
light: "照明"
|
||||
downlight: "ダウンライト"
|
||||
_downlight:
|
||||
bodyMat: "本体の素材"
|
||||
light: "照明"
|
||||
sprayer: "霧吹き"
|
||||
stanchionPole: "スタンションポール"
|
||||
_stanchionPole:
|
||||
bodyMat: "本体の素材"
|
||||
ropeMat: "ロープの素材"
|
||||
steelRack: "スチールラック"
|
||||
_steelRack:
|
||||
height: "高さ"
|
||||
numberOfShelfs: "シェルフの数"
|
||||
poleMat: "ポールの素材"
|
||||
shelfPositionOf: "シェルフの位置"
|
||||
shelfMat: "シェルフの素材"
|
||||
widthAndDepthVariation: "W x D"
|
||||
stormGlass: "ストームグラス"
|
||||
tableSalt: "食卓塩"
|
||||
tabletopCalendar: "卓上カレンダー"
|
||||
tabletopDigitalClock: "卓上デジタル時計"
|
||||
_tabletopDigitalClock:
|
||||
bodyMat: "本体の素材"
|
||||
lcdColor: "LCDの色"
|
||||
tabletopFlag: "卓上旗"
|
||||
_tabletopFlag:
|
||||
image: "画像"
|
||||
tabletopGlassPictureFrame: "卓上ガラス製フォトフレーム"
|
||||
_tabletopGlassPictureFrame:
|
||||
height: "高さ"
|
||||
image: "画像"
|
||||
width: "幅"
|
||||
tabletopIronFrameStand: "卓上アイアンフレームスタンド"
|
||||
_tabletopIronFrameStand:
|
||||
boardMat: "板の素材"
|
||||
depth: "奥行き"
|
||||
frameMat: "フレームの素材"
|
||||
height: "高さ"
|
||||
width: "幅"
|
||||
tabletopLcdButtonsController: "LCDボタン付き卓上コントローラー"
|
||||
_tabletopLcdButtonsController:
|
||||
bodyMat: "本体の素材"
|
||||
image: "画像"
|
||||
screenBrightness: "画面の明るさ"
|
||||
tabletopPictureFrame: "卓上フォトフレーム"
|
||||
_tabletopPictureFrame:
|
||||
depth: "厚さ"
|
||||
frameMat: "フレームの素材"
|
||||
frameThickness: "フレームの幅"
|
||||
height: "高さ"
|
||||
image: "画像"
|
||||
matHThickness: "マットの横幅"
|
||||
matVThickness: "マットの縦幅"
|
||||
width: "幅"
|
||||
tapestry: "タペストリー"
|
||||
_tapestry:
|
||||
height: "高さ"
|
||||
image: "画像"
|
||||
width: "幅"
|
||||
tetrapod: "波消ブロックの置物"
|
||||
tv: "テレビ"
|
||||
_tv:
|
||||
bodyMat: "本体の素材"
|
||||
screenBrightness: "画面の明るさ"
|
||||
twistedCubeObjet: "ねじれた立方体のオブジェ"
|
||||
usedTissue: "使用済みティッシュ"
|
||||
wallCanvas: "壁掛けキャンバス"
|
||||
_wallCanvas:
|
||||
height: "高さ"
|
||||
image: "画像"
|
||||
width: "幅"
|
||||
wallClock: "壁掛け時計"
|
||||
_wallClock:
|
||||
frameMat: "フレームの素材"
|
||||
faceMat: "文字盤の素材"
|
||||
handsMat: "針の素材"
|
||||
wallGlassPictureFrame: "ガラスの壁掛けフォトフレーム"
|
||||
_wallGlassPictureFrame:
|
||||
height: "高さ"
|
||||
image: "画像"
|
||||
width: "幅"
|
||||
wallMirror: "壁掛けミラー"
|
||||
_wallMirror:
|
||||
frameMat: "フレームの素材"
|
||||
frameThickness: "フレームの厚み"
|
||||
height: "高さ"
|
||||
width: "幅"
|
||||
wallMountSpotLight: "ウォールマウントスポットライト"
|
||||
_wallMountSpotLight:
|
||||
angleH: "横方向の角度"
|
||||
angleV: "縦方向の角度"
|
||||
bodyMat: "本体の素材"
|
||||
light: "照明"
|
||||
wallShelf: "ウォールシェルフ"
|
||||
_wallShelf:
|
||||
boardMat: "板の素材"
|
||||
boardStyle: "板のスタイル"
|
||||
"boardStyle:color": "単色"
|
||||
"boardStyle:wood": "木目"
|
||||
style: "スタイル"
|
||||
wireBasket: "ワイヤーバスケット"
|
||||
_wireBasket:
|
||||
bodyMat: "本体の素材"
|
||||
wireNet: "ワイヤーネット"
|
||||
_wireNet:
|
||||
bodyMat: "本体の素材"
|
||||
woodRingsPendantLight: "リングペンダントライト"
|
||||
_woodRingsPendantLight:
|
||||
bodyMat: "本体の素材"
|
||||
length: "長さ"
|
||||
light: "照明"
|
||||
shadeMat: "シェードの素材"
|
||||
woodSoundAbsorbingPanel: "木製吸音パネル"
|
||||
ironFrameShelf: "アイアンフレームシェルフ"
|
||||
_ironFrameShelf:
|
||||
boardMat: "板の素材"
|
||||
frameMat: "フレームの素材"
|
||||
height: "段数"
|
||||
width: "幅"
|
||||
kakejiku: "掛軸"
|
||||
_kakejiku:
|
||||
image: "画像"
|
||||
"image:kitsuaishinsei": "喫藍心整"
|
||||
|
||||
@@ -619,6 +619,8 @@ output: "출력"
|
||||
script: "스크립트"
|
||||
disablePagesScript: "Pages 에서 AiScript 를 사용하지 않음"
|
||||
updateRemoteUser: "리모트 유저 정보 갱신"
|
||||
unsetMfa: "2단계 인증 해제"
|
||||
unsetMfaConfirm: "2단계 인증을 해제하시겠습니까?"
|
||||
unsetUserAvatar: "아바타 제거"
|
||||
unsetUserAvatarConfirm: "아바타를 제거할까요?"
|
||||
unsetUserBanner: "배너 제거"
|
||||
@@ -1417,6 +1419,8 @@ addToEmojiPalette: "이모지 팔레트에 추가"
|
||||
emojiPaletteAlreadyAddedConfirm: "이 이모지는 이미 이 이모지 팔레트에 포함돼있습니다. 다시 추가하시겠습니까?"
|
||||
append: "맨뒤에 추가"
|
||||
prepend: "맨앞에 추가"
|
||||
urlPreviewSensitiveList: "썸네일 표시 제한 URL"
|
||||
urlPreviewSensitiveListDescription: "공백으로 구분하면 AND 지정으로 되고, 줄내림으로 구분하면 OR 지정으로 됩니다. 슬래시로 감싸면 정규 표현으로 됩니다. 일치한 경우에 썸네일이 표시되지 않게 됩니다."
|
||||
_imageEditing:
|
||||
_vars:
|
||||
caption: "파일 설명"
|
||||
@@ -2149,6 +2153,15 @@ _sensitiveMediaDetection:
|
||||
setSensitiveFlagAutomaticallyDescription: "이 설정을 해제해도 탐지 결과는 유지됩니다."
|
||||
analyzeVideos: "동영상도 같이 확인하기"
|
||||
analyzeVideosDescription: "사진 뿐만 아니라 동영상의 NSFW 여부도 탐지합니다. 서버의 부하를 약간 증가시킵니다."
|
||||
externalServiceInfo: "민감한 미디어 판정은 외부 서비스(sensitive-detector)로 분리됐습니다. 이 기능을 이용하려면 별도 사이드카 서비스를 설정하고, 아래의 접속 위치를 설정해야 합니다. 접속 위치가 설정되지 않은 경우에는 판정이 이루어지지 않습니다. (민감하지 않음 처리)"
|
||||
apiUrl: "판정 서비스의 접속 위치 URL"
|
||||
apiUrlDescription: "sensitive-detector 서비스의 베이스 URL(예시: http://localhost:3009). 프라이빗 네트워크상의 서비스에 접속하는 경우에는 설정 파일의 allowedPrivateNetworks로 접속 위치 네트워크를 허가해 주십시오. 프록시를 사용하고 있는 경우에는 proxyBypassHosts도 설정해 주십시오. 비어있으면 민감함 판정은 이루어지지 않습니다."
|
||||
apiKey: "API 키"
|
||||
apiKeyDescription: "판정 서비스 측에서 인증(Bearer 토큰)을 설정하고 있는 경우에 입력합니다. 설정하고 있지 않은 경우에는 빈칸으로 둬주십시오."
|
||||
timeout: "타임아웃 (밀리초)"
|
||||
timeoutDescription: "판정 요청 1회당 타임아웃 시간입니다."
|
||||
maxImagesPerRequest: "한 요청당 최대 이미지 수"
|
||||
maxImagesPerRequestDescription: "동영상 등 여러 프레임을 판정할 때, 한 번의 요청에 모아서 보내는 이미지의 최대 장수입니다. 이를 넘으면 분할해 순차적으로 송신됩니다. sensitive-detector 측의 maxParts 설정(기본: 10)을 남지 않도록 설정해 주십시오. 넘은 경우에는 그 청크는 전부 민감하지 않음 처리로 됩니다."
|
||||
_emailUnavailable:
|
||||
used: "이 메일 주소는 사용중입니다"
|
||||
format: "형식이 올바르지 않습니다"
|
||||
@@ -2461,6 +2474,7 @@ _permissions:
|
||||
"read:admin:show-moderation-log": "조정 기록 보기"
|
||||
"read:admin:show-user": "유저 개인정보 보기"
|
||||
"write:admin:suspend-user": "유저 정지하기"
|
||||
"write:admin:unset-mfa": "사용자의 2단계 인증 해제"
|
||||
"write:admin:unset-user-avatar": "유저 아바타 삭제하기"
|
||||
"write:admin:unset-user-banner": "유저 배너 삭제하기"
|
||||
"write:admin:unsuspend-user": "유저 정지 해제하기"
|
||||
@@ -2976,6 +2990,7 @@ _moderationLogTypes:
|
||||
createAvatarDecoration: "아바타 장식 만들기"
|
||||
updateAvatarDecoration: "아바타 장식 수정"
|
||||
deleteAvatarDecoration: "아바타 장식 삭제"
|
||||
unsetMfa: "사용자의 2단계 인증 해제"
|
||||
unsetUserAvatar: "유저 아바타 제거"
|
||||
unsetUserBanner: "유저 배너 제거"
|
||||
createSystemWebhook: "SystemWebhook을 생성"
|
||||
|
||||
+20
-5
@@ -57,7 +57,7 @@ deleteAndEditConfirm: "要删除该帖并重新编辑吗?该帖下的所有回
|
||||
addToList: "添加至列表"
|
||||
addToAntenna: "添加到天线"
|
||||
sendMessage: "发送消息"
|
||||
copyRSS: "复制RSS"
|
||||
copyRSS: "复制 RSS"
|
||||
copyUsername: "复制用户名"
|
||||
copyUserId: "复制用户 ID"
|
||||
copyNoteId: "复制帖子 ID"
|
||||
@@ -456,8 +456,8 @@ about: "关于"
|
||||
aboutMisskey: "关于 Misskey"
|
||||
administrator: "管理员"
|
||||
token: "Token (令牌)"
|
||||
2fa: "双因素认证"
|
||||
setupOf2fa: "设置双因素认证"
|
||||
2fa: "双重验证"
|
||||
setupOf2fa: "设置双重验证"
|
||||
totp: "验证器"
|
||||
totpDescription: "使用验证器输入一次性密码"
|
||||
moderator: "监察员"
|
||||
@@ -485,7 +485,7 @@ markAsReadAllNotifications: "将所有通知标为已读"
|
||||
markAsReadAllUnreadNotes: "将所有帖子标记为已读"
|
||||
markAsReadAllTalkMessages: "将所有私信标记为已读"
|
||||
help: "帮助"
|
||||
inputMessageHere: "在此键入信息"
|
||||
inputMessageHere: "在此输入信息"
|
||||
close: "关闭"
|
||||
invites: "邀请"
|
||||
members: "成员"
|
||||
@@ -619,6 +619,8 @@ output: "输出"
|
||||
script: "脚本"
|
||||
disablePagesScript: "禁用页面脚本"
|
||||
updateRemoteUser: "更新远程用户信息"
|
||||
unsetMfa: "解除双重验证"
|
||||
unsetMfaConfirm: "确认解除双重验证吗?"
|
||||
unsetUserAvatar: "清除头像"
|
||||
unsetUserAvatarConfirm: "要清除头像吗?"
|
||||
unsetUserBanner: "清除横幅"
|
||||
@@ -1417,6 +1419,8 @@ addToEmojiPalette: "添加至表情符号选择器"
|
||||
emojiPaletteAlreadyAddedConfirm: "此表情符号已存在于此表情符号选择器中。要再次添加吗?"
|
||||
append: "加到最后"
|
||||
prepend: "加到最前"
|
||||
urlPreviewSensitiveList: "限制显示缩略图的 URL"
|
||||
urlPreviewSensitiveListDescription: "AND 条件用空格分隔,OR 条件用换行符分隔,正则表达式用斜线包裹。成功匹配则不再显示缩略图。"
|
||||
_imageEditing:
|
||||
_vars:
|
||||
caption: "文件标题"
|
||||
@@ -2149,6 +2153,15 @@ _sensitiveMediaDetection:
|
||||
setSensitiveFlagAutomaticallyDescription: "即使关闭此配置,识别结果也会在内部保存。"
|
||||
analyzeVideos: "启用对视频的检测"
|
||||
analyzeVideosDescription: "除了静止图像之外,还对视频进行分析。服务器负载会略微增加。"
|
||||
externalServiceInfo: "检测敏感媒体已分离至外部服务 (sensitive-detector)。若要使用,需额外部署 Sidecar 服务,并设置下方的连接 URL。未设定时将不会进行检测(视为非敏感媒体)。"
|
||||
apiUrl: "检测服务的连接 URL"
|
||||
apiUrlDescription: "sensitive-detector 服务的 base URL(如:http://localhost:3009)。若是连接至部署在专用网络上的服务,请在配置文件中的 allowedPrivateNetworks 里允许目标网络。若是使用了代理,请一并设置 proxyBypassHosts。留空则不进行敏感媒体检测。"
|
||||
apiKey: "API 密钥"
|
||||
apiKeyDescription: "若服务端有设置验证(Bearer token)则填写,未设置则留空。"
|
||||
timeout: "超时(毫秒)"
|
||||
timeoutDescription: "此为单次检测请求的超时时长。"
|
||||
maxImagesPerRequest: "单次检测请求最大图像数量"
|
||||
maxImagesPerRequestDescription: "此为在检测动画等多帧图像时,单次请求中可发送的图像数量上限。超出此值时动画将被拆分并按序发送。请勿将此值设为超出 sensitive-detector 侧的 maxParts 的值(默认:10),否则对应的分块将全被视为非敏感媒体。"
|
||||
_emailUnavailable:
|
||||
used: "已经被使用过"
|
||||
format: "无效的格式"
|
||||
@@ -2461,6 +2474,7 @@ _permissions:
|
||||
"read:admin:show-moderation-log": "查看管理日志"
|
||||
"read:admin:show-user": "查看用户的非公开信息"
|
||||
"write:admin:suspend-user": "冻结用户"
|
||||
"write:admin:unset-mfa": "解除用户的双重验证"
|
||||
"write:admin:unset-user-avatar": "删除用户头像"
|
||||
"write:admin:unset-user-banner": "删除用户横幅"
|
||||
"write:admin:unsuspend-user": "解除用户冻结"
|
||||
@@ -2586,7 +2600,7 @@ _widgetOptions:
|
||||
_jobQueue:
|
||||
sound: "播放音效"
|
||||
_rss:
|
||||
url: "RSS feed 的 URL"
|
||||
url: "RSS 订阅源网址"
|
||||
refreshIntervalSec: "更新间隔(秒)"
|
||||
maxEntries: "最大显示个数"
|
||||
_rssTicker:
|
||||
@@ -2976,6 +2990,7 @@ _moderationLogTypes:
|
||||
createAvatarDecoration: "新建头像挂件"
|
||||
updateAvatarDecoration: "更新头像挂件"
|
||||
deleteAvatarDecoration: "删除头像挂件"
|
||||
unsetMfa: "解除用户的双重验证"
|
||||
unsetUserAvatar: "清除用户头像"
|
||||
unsetUserBanner: "清除用户横幅"
|
||||
createSystemWebhook: "新建了 SystemWebhook"
|
||||
|
||||
+15
-11
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "misskey",
|
||||
"version": "2026.7.0-beta.1",
|
||||
"version": "2026.7.0-rc.0",
|
||||
"codename": "nasubi",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
@@ -8,17 +8,21 @@
|
||||
},
|
||||
"packageManager": "pnpm@11.11.0",
|
||||
"workspaces": [
|
||||
"packages/misskey-js",
|
||||
"packages/backend",
|
||||
"packages/frontend-shared",
|
||||
"packages/frontend",
|
||||
"packages/frontend-builder",
|
||||
"packages/frontend-embed",
|
||||
"packages/i18n",
|
||||
"packages/icons-subsetter",
|
||||
"packages/sw",
|
||||
"packages/misskey-js",
|
||||
"packages/misskey-js/generator",
|
||||
"packages/misskey-reversi",
|
||||
"packages/misskey-bubble-game",
|
||||
"packages/icons-subsetter",
|
||||
"packages/frontend-shared",
|
||||
"packages/frontend-builder",
|
||||
"packages/sw",
|
||||
"packages/backend",
|
||||
"packages/frontend",
|
||||
"packages/frontend-embed"
|
||||
"packages/misskey-world",
|
||||
"packages/frontend-misskey-world-engine",
|
||||
"packages-private/*"
|
||||
],
|
||||
"private": true,
|
||||
"scripts": {
|
||||
@@ -55,8 +59,8 @@
|
||||
"esbuild": "0.28.1",
|
||||
"execa": "9.6.1",
|
||||
"ignore-walk": "9.0.0",
|
||||
"js-yaml": "5.2.1",
|
||||
"tar": "7.5.20"
|
||||
"js-yaml": "5.2.2",
|
||||
"tar": "7.5.21"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "9.39.5",
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
# packages-private
|
||||
|
||||
`packages-private` は、CIなどで使用するためのユーティリティなどを格納する場所です。この配下にあるものは**プロダクションの `/packages`**の依存となるものではありません。
|
||||
|
||||
## パッケージ一覧
|
||||
|
||||
| パッケージ | 用途 |
|
||||
| --- | --- |
|
||||
| [`diagnostics-shared`](./diagnostics-shared) | 計測系パッケージが共有する統計・書式整形・V8 heap snapshot解析のユーティリティ |
|
||||
| [`diagnostics-backend`](./diagnostics-backend) | バックエンドのメモリ使用量をbase/headで比較し、PRコメント用のMarkdownを生成する |
|
||||
| [`diagnostics-frontend`](./diagnostics-frontend) | フロントエンドをブラウザで起動した際のメトリクスと生成されたchunk情報等をbase/headで比較する |
|
||||
| [`changelog-checker`](./changelog-checker) | `CHANGELOG.md` の追記内容を検証する |
|
||||
|
||||
## GitHub Actionsからの呼び出し方
|
||||
|
||||
ワークフロー側にロジックを持たせず、各パッケージのnpm scriptを呼ぶだけにしている。
|
||||
CLIに渡すパスはすべて呼び出し側のカレントディレクトリ基準で解決されるため、
|
||||
ワークフローからは `$GITHUB_WORKSPACE` / `$RUNNER_TEMP` を使った絶対パスを渡すこと。
|
||||
|
||||
```yaml
|
||||
- name: Generate report
|
||||
working-directory: head
|
||||
run: >-
|
||||
pnpm --filter diagnostics-frontend run render-md
|
||||
"$GITHUB_WORKSPACE/base" "$GITHUB_WORKSPACE/head"
|
||||
"$REPORT_DIR/base-bundle-stats.json" "$REPORT_DIR/head-bundle-stats.json"
|
||||
"$REPORT_DIR/base-browser.json" "$REPORT_DIR/head-browser.json"
|
||||
"$REPORT_DIR/report.md"
|
||||
```
|
||||
|
||||
base/head を比較する計測では、**head側のチェックアウトにあるハーネスで両方を計測する**
|
||||
(計測コードの差分ではなくビルド成果物の差分だけが結果に出るようにするため)。
|
||||
+7
-1
@@ -1,10 +1,16 @@
|
||||
import tsParser from '@typescript-eslint/parser';
|
||||
import sharedConfig from '../../packages/shared/eslint.config.js';
|
||||
|
||||
// eslint-disable-next-line import/no-default-export
|
||||
export default [
|
||||
...sharedConfig,
|
||||
{
|
||||
files: ['src/**/*.ts', 'src/**/*.tsx'],
|
||||
ignores: [
|
||||
'**/node_modules',
|
||||
],
|
||||
},
|
||||
{
|
||||
files: ['**/*.ts', '**/*.tsx'],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
parser: tsParser,
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"name": "changelog-checker",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"check": "tsx src/index.ts",
|
||||
"eslint": "eslint './**/*.{js,jsx,ts,tsx}'",
|
||||
"lint": "pnpm typecheck && pnpm eslint",
|
||||
"test": "vitest run",
|
||||
"test:coverage": "vitest run --coverage",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/mdast": "4.0.4",
|
||||
"@types/node": "26.1.1",
|
||||
"@vitest/coverage-v8": "4.1.10",
|
||||
"mdast-util-to-string": "4.0.0",
|
||||
"remark": "15.0.1",
|
||||
"remark-parse": "11.0.0",
|
||||
"tsx": "4.23.1",
|
||||
"unified": "11.0.5",
|
||||
"vitest": "4.1.10"
|
||||
}
|
||||
}
|
||||
+13
-7
@@ -33,16 +33,22 @@ export function checkNewRelease(base: Release[], head: Release[]): Result {
|
||||
return Result.ofFailed('Invalid release count.');
|
||||
}
|
||||
|
||||
const baseLatest = base[0];
|
||||
const headPrevious = head[releaseCountDiff];
|
||||
|
||||
if (baseLatest.releaseName !== headPrevious.releaseName) {
|
||||
return Result.ofFailed('Contains unexpected releases.');
|
||||
// 追加分を除いた残り (= base に既にあったリリース) が順序ごと一致することを確認する。
|
||||
// 先頭だけ比較していると、より古いリリースが書き換えられていても素通りしてしまう
|
||||
const existingReleases = head.slice(releaseCountDiff);
|
||||
for (let relIdx = 0; relIdx < base.length; relIdx++) {
|
||||
if (base[relIdx].releaseName !== existingReleases[relIdx].releaseName) {
|
||||
return Result.ofFailed(`Contains unexpected releases. base:${base[relIdx].releaseName}, head:${existingReleases[relIdx].releaseName}`);
|
||||
}
|
||||
}
|
||||
|
||||
return Result.ofSuccess();
|
||||
}
|
||||
|
||||
function isSameItems(base: string[], head: string[]) {
|
||||
return base.length === head.length && base.every((item, idx) => item === head[idx]);
|
||||
}
|
||||
|
||||
/**
|
||||
* topic -> developまたはtopic -> masterを想定したパターン。
|
||||
* head側の最新リリース配下に書き加えられているかをチェックする。
|
||||
@@ -78,9 +84,9 @@ export function checkNewTopic(base: Release[], head: Release[]): Result {
|
||||
return Result.ofFailed(`Category is different. base:${baseCategory.categoryName}, head:${headCategory.categoryName}`);
|
||||
}
|
||||
|
||||
if (baseCategory.items.length !== headCategory.items.length) {
|
||||
if (!isSameItems(baseCategory.items, headCategory.items)) {
|
||||
if (headLatest.releaseName !== headItem.releaseName) {
|
||||
// 最新リリース以外に追記されていた場合
|
||||
// 最新リリース以外が変更されていた場合
|
||||
return Result.ofFailed(`There is an error in the update history. expected additions:${headLatest.releaseName}, actual additions:${headItem.releaseName}`);
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -18,7 +18,7 @@ function abort(message?: string) {
|
||||
|
||||
function main() {
|
||||
if (!fs.existsSync('./CHANGELOG-base.md') || !fs.existsSync('./CHANGELOG-head.md')) {
|
||||
console.error('CHANGELOG-base.md or CHANGELOG-head.md is missing.');
|
||||
abort('CHANGELOG-base.md or CHANGELOG-head.md is missing.');
|
||||
return;
|
||||
}
|
||||
|
||||
+3
@@ -51,6 +51,9 @@ export function parseChangeLog(path: string): Release[] {
|
||||
// リリース
|
||||
release = new Release(toString(it));
|
||||
releases.push(release);
|
||||
// 直前のリリースのカテゴリを引き継ぐと、カテゴリ見出しの無いリスト項目が
|
||||
// 前のリリースに混入するのでリセットする
|
||||
category = null;
|
||||
} else if (isHeading(it) && it.depth === 3 && release) {
|
||||
// リリース配下のカテゴリ
|
||||
category = new ReleaseCategory(toString(it));
|
||||
+193
-107
@@ -3,52 +3,62 @@
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import {expect, suite, test} from "vitest";
|
||||
import {Release, ReleaseCategory} from "../src/parser";
|
||||
import {checkNewRelease, checkNewTopic} from "../src/checker";
|
||||
import { expect, describe, test } from 'vitest';
|
||||
import { Release, ReleaseCategory } from '../src/parser.js';
|
||||
import { checkNewRelease, checkNewTopic } from '../src/checker.js';
|
||||
|
||||
suite('checkNewRelease', () => {
|
||||
describe('checkNewRelease', () => {
|
||||
test('headに新しいリリースがある1', () => {
|
||||
const base = [new Release('2024.12.0')]
|
||||
const head = [new Release('2024.12.1'), new Release('2024.12.0')]
|
||||
const base = [new Release('2024.12.0')];
|
||||
const head = [new Release('2024.12.1'), new Release('2024.12.0')];
|
||||
|
||||
const result = checkNewRelease(base, head)
|
||||
const result = checkNewRelease(base, head);
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
test('headに新しいリリースがある2', () => {
|
||||
const base = [new Release('2024.12.0')]
|
||||
const head = [new Release('2024.12.2'), new Release('2024.12.1'), new Release('2024.12.0')]
|
||||
const base = [new Release('2024.12.0')];
|
||||
const head = [new Release('2024.12.2'), new Release('2024.12.1'), new Release('2024.12.0')];
|
||||
|
||||
const result = checkNewRelease(base, head)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
const result = checkNewRelease(base, head);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
test('リリースの数が同じ', () => {
|
||||
const base = [new Release('2024.12.0')]
|
||||
const head = [new Release('2024.12.0')]
|
||||
const base = [new Release('2024.12.0')];
|
||||
const head = [new Release('2024.12.0')];
|
||||
|
||||
const result = checkNewRelease(base, head)
|
||||
const result = checkNewRelease(base, head);
|
||||
|
||||
console.log(result.message)
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
console.log(result.message);
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
test('baseにあるリリースがheadにない', () => {
|
||||
const base = [new Release('2024.12.0')]
|
||||
const head = [new Release('2024.12.2'), new Release('2024.12.1')]
|
||||
const base = [new Release('2024.12.0')];
|
||||
const head = [new Release('2024.12.2'), new Release('2024.12.1')];
|
||||
|
||||
const result = checkNewRelease(base, head)
|
||||
const result = checkNewRelease(base, head);
|
||||
|
||||
console.log(result.message)
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
})
|
||||
console.log(result.message);
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
suite('checkNewTopic', () => {
|
||||
// 先頭だけ比較していると、より古いリリースの書き換えを見逃す
|
||||
test('追加分の直後は一致しているが、より古いリリースが書き換えられている', () => {
|
||||
const base = [new Release('2024.12.1'), new Release('2024.12.0')];
|
||||
const head = [new Release('2024.12.2'), new Release('2024.12.1'), new Release('2024.11.0')];
|
||||
|
||||
const result = checkNewRelease(base, head);
|
||||
|
||||
console.log(result.message);
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('checkNewTopic', () => {
|
||||
test('追記なし', () => {
|
||||
const base = [
|
||||
new Release('2024.12.1', [
|
||||
@@ -59,7 +69,7 @@ suite('checkNewTopic', () => {
|
||||
new ReleaseCategory('Client', [
|
||||
'feat3',
|
||||
'feat4',
|
||||
])
|
||||
]),
|
||||
]),
|
||||
new Release('2024.12.0', [
|
||||
new ReleaseCategory('Server', [
|
||||
@@ -69,9 +79,9 @@ suite('checkNewTopic', () => {
|
||||
new ReleaseCategory('Client', [
|
||||
'feat3',
|
||||
'feat4',
|
||||
])
|
||||
])
|
||||
]
|
||||
]),
|
||||
]),
|
||||
];
|
||||
|
||||
const head = [
|
||||
new Release('2024.12.1', [
|
||||
@@ -82,7 +92,7 @@ suite('checkNewTopic', () => {
|
||||
new ReleaseCategory('Client', [
|
||||
'feat3',
|
||||
'feat4',
|
||||
])
|
||||
]),
|
||||
]),
|
||||
new Release('2024.12.0', [
|
||||
new ReleaseCategory('Server', [
|
||||
@@ -92,14 +102,14 @@ suite('checkNewTopic', () => {
|
||||
new ReleaseCategory('Client', [
|
||||
'feat3',
|
||||
'feat4',
|
||||
])
|
||||
])
|
||||
]
|
||||
]),
|
||||
]),
|
||||
];
|
||||
|
||||
const result = checkNewTopic(base, head)
|
||||
const result = checkNewTopic(base, head);
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
test('最新バージョンにカテゴリを追加したときはエラーにならない', () => {
|
||||
const base = [
|
||||
@@ -117,9 +127,9 @@ suite('checkNewTopic', () => {
|
||||
new ReleaseCategory('Client', [
|
||||
'feat3',
|
||||
'feat4',
|
||||
])
|
||||
])
|
||||
]
|
||||
]),
|
||||
]),
|
||||
];
|
||||
|
||||
const head = [
|
||||
new Release('2024.12.1', [
|
||||
@@ -130,7 +140,7 @@ suite('checkNewTopic', () => {
|
||||
new ReleaseCategory('Client', [
|
||||
'feat3',
|
||||
'feat4',
|
||||
])
|
||||
]),
|
||||
]),
|
||||
new Release('2024.12.0', [
|
||||
new ReleaseCategory('Server', [
|
||||
@@ -140,14 +150,14 @@ suite('checkNewTopic', () => {
|
||||
new ReleaseCategory('Client', [
|
||||
'feat3',
|
||||
'feat4',
|
||||
])
|
||||
])
|
||||
]
|
||||
]),
|
||||
]),
|
||||
];
|
||||
|
||||
const result = checkNewTopic(base, head)
|
||||
const result = checkNewTopic(base, head);
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
test('最新バージョンからカテゴリを削除したときはエラーにならない', () => {
|
||||
const base = [
|
||||
@@ -159,7 +169,7 @@ suite('checkNewTopic', () => {
|
||||
new ReleaseCategory('Client', [
|
||||
'feat3',
|
||||
'feat4',
|
||||
])
|
||||
]),
|
||||
]),
|
||||
new Release('2024.12.0', [
|
||||
new ReleaseCategory('Server', [
|
||||
@@ -169,9 +179,9 @@ suite('checkNewTopic', () => {
|
||||
new ReleaseCategory('Client', [
|
||||
'feat3',
|
||||
'feat4',
|
||||
])
|
||||
])
|
||||
]
|
||||
]),
|
||||
]),
|
||||
];
|
||||
|
||||
const head = [
|
||||
new Release('2024.12.1', [
|
||||
@@ -188,14 +198,14 @@ suite('checkNewTopic', () => {
|
||||
new ReleaseCategory('Client', [
|
||||
'feat3',
|
||||
'feat4',
|
||||
])
|
||||
])
|
||||
]
|
||||
]),
|
||||
]),
|
||||
];
|
||||
|
||||
const result = checkNewTopic(base, head)
|
||||
const result = checkNewTopic(base, head);
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
test('最新バージョンに追記したときはエラーにならない', () => {
|
||||
const base = [
|
||||
@@ -210,8 +220,8 @@ suite('checkNewTopic', () => {
|
||||
'feat1',
|
||||
'feat2',
|
||||
]),
|
||||
])
|
||||
]
|
||||
]),
|
||||
];
|
||||
|
||||
const head = [
|
||||
new Release('2024.12.1', [
|
||||
@@ -226,13 +236,13 @@ suite('checkNewTopic', () => {
|
||||
'feat1',
|
||||
'feat2',
|
||||
]),
|
||||
])
|
||||
]
|
||||
]),
|
||||
];
|
||||
|
||||
const result = checkNewTopic(base, head)
|
||||
const result = checkNewTopic(base, head);
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
test('最新バージョンから削除したときはエラーにならない', () => {
|
||||
const base = [
|
||||
@@ -247,8 +257,8 @@ suite('checkNewTopic', () => {
|
||||
'feat1',
|
||||
'feat2',
|
||||
]),
|
||||
])
|
||||
]
|
||||
]),
|
||||
];
|
||||
|
||||
const head = [
|
||||
new Release('2024.12.1', [
|
||||
@@ -261,13 +271,13 @@ suite('checkNewTopic', () => {
|
||||
'feat1',
|
||||
'feat2',
|
||||
]),
|
||||
])
|
||||
]
|
||||
]),
|
||||
];
|
||||
|
||||
const result = checkNewTopic(base, head)
|
||||
const result = checkNewTopic(base, head);
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
test('古いバージョンにカテゴリを追加したときはエラーになる', () => {
|
||||
const base = [
|
||||
@@ -282,8 +292,8 @@ suite('checkNewTopic', () => {
|
||||
'feat1',
|
||||
'feat2',
|
||||
]),
|
||||
])
|
||||
]
|
||||
]),
|
||||
];
|
||||
|
||||
const head = [
|
||||
new Release('2024.12.1', [
|
||||
@@ -301,14 +311,14 @@ suite('checkNewTopic', () => {
|
||||
'feat1',
|
||||
'feat2',
|
||||
]),
|
||||
])
|
||||
]
|
||||
]),
|
||||
];
|
||||
|
||||
const result = checkNewTopic(base, head)
|
||||
const result = checkNewTopic(base, head);
|
||||
|
||||
console.log(result.message)
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
console.log(result.message);
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
test('古いバージョンからカテゴリを削除したときはエラーになる', () => {
|
||||
const base = [
|
||||
@@ -323,8 +333,8 @@ suite('checkNewTopic', () => {
|
||||
'feat1',
|
||||
'feat2',
|
||||
]),
|
||||
])
|
||||
]
|
||||
]),
|
||||
];
|
||||
|
||||
const head = [
|
||||
new Release('2024.12.1', [
|
||||
@@ -334,14 +344,14 @@ suite('checkNewTopic', () => {
|
||||
]),
|
||||
]),
|
||||
new Release('2024.12.0', [
|
||||
])
|
||||
]
|
||||
]),
|
||||
];
|
||||
|
||||
const result = checkNewTopic(base, head)
|
||||
const result = checkNewTopic(base, head);
|
||||
|
||||
console.log(result.message)
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
console.log(result.message);
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
test('古いバージョンに追記したときはエラーになる', () => {
|
||||
const base = [
|
||||
@@ -356,8 +366,8 @@ suite('checkNewTopic', () => {
|
||||
'feat1',
|
||||
'feat2',
|
||||
]),
|
||||
])
|
||||
]
|
||||
]),
|
||||
];
|
||||
|
||||
const head = [
|
||||
new Release('2024.12.1', [
|
||||
@@ -372,14 +382,14 @@ suite('checkNewTopic', () => {
|
||||
'feat2',
|
||||
'feat3',
|
||||
]),
|
||||
])
|
||||
]
|
||||
]),
|
||||
];
|
||||
|
||||
const result = checkNewTopic(base, head)
|
||||
const result = checkNewTopic(base, head);
|
||||
|
||||
console.log(result.message)
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
console.log(result.message);
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
test('古いバージョンから削除したときはエラーになる', () => {
|
||||
const base = [
|
||||
@@ -394,8 +404,8 @@ suite('checkNewTopic', () => {
|
||||
'feat1',
|
||||
'feat2',
|
||||
]),
|
||||
])
|
||||
]
|
||||
]),
|
||||
];
|
||||
|
||||
const head = [
|
||||
new Release('2024.12.1', [
|
||||
@@ -408,12 +418,88 @@ suite('checkNewTopic', () => {
|
||||
new ReleaseCategory('Server', [
|
||||
'feat1',
|
||||
]),
|
||||
])
|
||||
]
|
||||
]),
|
||||
];
|
||||
|
||||
const result = checkNewTopic(base, head)
|
||||
const result = checkNewTopic(base, head);
|
||||
|
||||
console.log(result.message)
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
})
|
||||
console.log(result.message);
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
// 件数が同じでも内容が変わっていれば履歴の書き換えなのでエラーにする
|
||||
test('古いバージョンの項目が書き換えられたときはエラーになる', () => {
|
||||
const base = [
|
||||
new Release('2024.12.1', [
|
||||
new ReleaseCategory('Server', ['feat1']),
|
||||
]),
|
||||
new Release('2024.12.0', [
|
||||
new ReleaseCategory('Server', ['feat1', 'feat2']),
|
||||
]),
|
||||
];
|
||||
|
||||
const head = [
|
||||
new Release('2024.12.1', [
|
||||
new ReleaseCategory('Server', ['feat1']),
|
||||
]),
|
||||
new Release('2024.12.0', [
|
||||
new ReleaseCategory('Server', ['feat1', 'feat2-rewritten']),
|
||||
]),
|
||||
];
|
||||
|
||||
const result = checkNewTopic(base, head);
|
||||
|
||||
console.log(result.message);
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
test('古いバージョンの項目が並べ替えられたときはエラーになる', () => {
|
||||
const base = [
|
||||
new Release('2024.12.1', [
|
||||
new ReleaseCategory('Server', ['feat1']),
|
||||
]),
|
||||
new Release('2024.12.0', [
|
||||
new ReleaseCategory('Server', ['feat1', 'feat2']),
|
||||
]),
|
||||
];
|
||||
|
||||
const head = [
|
||||
new Release('2024.12.1', [
|
||||
new ReleaseCategory('Server', ['feat1']),
|
||||
]),
|
||||
new Release('2024.12.0', [
|
||||
new ReleaseCategory('Server', ['feat2', 'feat1']),
|
||||
]),
|
||||
];
|
||||
|
||||
const result = checkNewTopic(base, head);
|
||||
|
||||
console.log(result.message);
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
// 最新リリースの書き換えは通常の編集なので許容する
|
||||
test('最新バージョンの項目を書き換えたときはエラーにならない', () => {
|
||||
const base = [
|
||||
new Release('2024.12.1', [
|
||||
new ReleaseCategory('Server', ['feat1']),
|
||||
]),
|
||||
new Release('2024.12.0', [
|
||||
new ReleaseCategory('Server', ['feat1']),
|
||||
]),
|
||||
];
|
||||
|
||||
const head = [
|
||||
new Release('2024.12.1', [
|
||||
new ReleaseCategory('Server', ['feat1-rewritten']),
|
||||
]),
|
||||
new Release('2024.12.0', [
|
||||
new ReleaseCategory('Server', ['feat1']),
|
||||
]),
|
||||
];
|
||||
|
||||
const result = checkNewTopic(base, head);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
});
|
||||
+4
-5
@@ -14,18 +14,17 @@
|
||||
"experimentalDecorators": true,
|
||||
"noImplicitReturns": true,
|
||||
"esModuleInterop": true,
|
||||
"typeRoots": [
|
||||
"./node_modules/@types"
|
||||
],
|
||||
"skipLibCheck": true,
|
||||
"types": ["node"],
|
||||
"lib": [
|
||||
"esnext"
|
||||
]
|
||||
},
|
||||
"include": [
|
||||
"src/**/*"
|
||||
"src/**/*",
|
||||
"test/**/*"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules",
|
||||
"test/**/*"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import tsParser from '@typescript-eslint/parser';
|
||||
import sharedConfig from '../../packages/shared/eslint.config.js';
|
||||
|
||||
// eslint-disable-next-line import/no-default-export
|
||||
export default [
|
||||
...sharedConfig,
|
||||
{
|
||||
ignores: [
|
||||
'**/node_modules',
|
||||
'**/__snapshots__',
|
||||
'test/fixtures',
|
||||
],
|
||||
},
|
||||
{
|
||||
files: ['**/*.ts', '**/*.tsx'],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
parser: tsParser,
|
||||
project: ['./tsconfig.json'],
|
||||
sourceType: 'module',
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"name": "diagnostics-backend",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"eslint": "eslint './**/*.{js,jsx,ts,tsx}'",
|
||||
"inspect": "tsx src/inspect.ts",
|
||||
"lint": "pnpm typecheck && pnpm eslint",
|
||||
"measure": "tsx src/measure-once.ts",
|
||||
"render-md": "tsx src/render-md.ts",
|
||||
"test": "vitest run",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"diagnostics-shared": "workspace:*",
|
||||
"execa": "9.6.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "26.1.1",
|
||||
"@types/pg": "8.20.0",
|
||||
"ioredis": "5.11.1",
|
||||
"pg": "8.22.0",
|
||||
"tsx": "4.23.1",
|
||||
"typescript": "5.9.3",
|
||||
"vitest": "4.1.10"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { copyFile, rm, writeFile } from 'node:fs/promises';
|
||||
import { join, resolve } from 'node:path';
|
||||
import { execa } from 'execa';
|
||||
import { readBooleanEnv, readIntegerEnv, readOptionalEnv } from 'diagnostics-shared/env';
|
||||
import { median } from 'diagnostics-shared/stats';
|
||||
import { summarizeHeapSnapshotDataSamples, defaultHeapSnapshotBreakdownTopN } from 'diagnostics-shared/heap-snapshot';
|
||||
import { resetState } from './db';
|
||||
import {
|
||||
clampHeapSnapshotRounds,
|
||||
selectRepresentativeHeapSnapshotRound,
|
||||
shouldCollectHeapSnapshot,
|
||||
} from './heap-snapshot-sampling';
|
||||
import { measureBackendMemory } from './measure';
|
||||
import { memoryPhases, type MemoryReport } from './types';
|
||||
|
||||
const heapSnapshotLabels = ['base', 'head'] as const;
|
||||
|
||||
type HeapSnapshotLabel = typeof heapSnapshotLabels[number];
|
||||
|
||||
export type CompareOptions = {
|
||||
baseDir: string;
|
||||
headDir: string;
|
||||
baseOutput: string;
|
||||
headOutput: string;
|
||||
};
|
||||
|
||||
const HEAP_SNAPSHOT_BREAKDOWN_TOP_N = readIntegerEnv('MK_MEMORY_HEAP_SNAPSHOT_BREAKDOWN_TOP_N', defaultHeapSnapshotBreakdownTopN, 1);
|
||||
// 成果物 (artifact) としてアップロードされるファイルの出力先。CIではworkspace直下を指す
|
||||
const HEAP_SNAPSHOT_OUTPUT_DIR = resolve(readOptionalEnv('MK_MEMORY_HEAP_SNAPSHOT_OUTPUT_DIR') ?? process.cwd());
|
||||
const HEAP_SNAPSHOT_WORK_DIRS = {
|
||||
base: join(HEAP_SNAPSHOT_OUTPUT_DIR, 'base-heap-snapshots'),
|
||||
head: join(HEAP_SNAPSHOT_OUTPUT_DIR, 'head-heap-snapshots'),
|
||||
};
|
||||
const HEAP_SNAPSHOT_OUTPUT_PATHS = {
|
||||
base: join(HEAP_SNAPSHOT_OUTPUT_DIR, 'base-heap-snapshot.heapsnapshot'),
|
||||
head: join(HEAP_SNAPSHOT_OUTPUT_DIR, 'head-heap-snapshot.heapsnapshot'),
|
||||
};
|
||||
|
||||
export function summarizeSamples(samples: MemoryReport['samples']) {
|
||||
const summary = {} as MemoryReport['summary'];
|
||||
|
||||
for (const phase of memoryPhases) {
|
||||
summary[phase] = {
|
||||
memoryUsage: {},
|
||||
};
|
||||
|
||||
const metricKeys = new Set<string>();
|
||||
for (const sample of samples) {
|
||||
for (const key of Object.keys(sample.phases[phase].memoryUsage)) {
|
||||
metricKeys.add(key);
|
||||
}
|
||||
}
|
||||
|
||||
for (const key of metricKeys) {
|
||||
const values = samples.map(sample => sample.phases[phase].memoryUsage[key]);
|
||||
summary[phase].memoryUsage[key] = median(values);
|
||||
}
|
||||
|
||||
const heapSnapshot = summarizeHeapSnapshotDataSamples(
|
||||
samples,
|
||||
sample => sample.phases[phase].heapSnapshot,
|
||||
{ breakdownTopN: HEAP_SNAPSHOT_BREAKDOWN_TOP_N },
|
||||
);
|
||||
if (heapSnapshot != null) summary[phase].heapSnapshot = heapSnapshot;
|
||||
}
|
||||
|
||||
return summary;
|
||||
}
|
||||
|
||||
type GenSampleOptions = {
|
||||
collectHeapSnapshot?: boolean;
|
||||
heapSnapshotSavePath?: string;
|
||||
};
|
||||
|
||||
async function genSample(label: string, repoDir: string, round: number, options: GenSampleOptions = {}) {
|
||||
process.stderr.write(`[${label}] Resetting database and Redis\n`);
|
||||
await resetState();
|
||||
|
||||
process.stderr.write(`[${label}] Running migrations\n`);
|
||||
// 出力はログとして流しつつ手元にも残す (失敗時にexecaが例外メッセージへ含めてくれる)
|
||||
await execa('pnpm', ['--filter', 'backend', 'migrate'], {
|
||||
cwd: repoDir,
|
||||
stdout: ['pipe', process.stderr],
|
||||
stderr: ['pipe', process.stderr],
|
||||
});
|
||||
|
||||
process.stderr.write(`[${label}] Measuring memory\n`);
|
||||
return await measureBackendMemory(resolve(repoDir, 'packages/backend'), {
|
||||
// warmupラウンド (round <= 0) は捨てるので、重いheap snapshotは取らない
|
||||
...(round <= 0 || options.collectHeapSnapshot === false ? { heapSnapshot: false } : {}),
|
||||
heapSnapshotSavePath: options.heapSnapshotSavePath ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
function heapSnapshotPath(label: HeapSnapshotLabel, round: number) {
|
||||
return join(HEAP_SNAPSHOT_WORK_DIRS[label], `round-${round}.heapsnapshot`);
|
||||
}
|
||||
|
||||
async function saveRepresentativeHeapSnapshot(label: HeapSnapshotLabel, samples: MemoryReport['samples'], summary: MemoryReport['summary']) {
|
||||
const round = selectRepresentativeHeapSnapshotRound(
|
||||
samples,
|
||||
summary.afterGc.heapSnapshot?.categories.total,
|
||||
sample => sample.phases.afterGc.heapSnapshot?.categories.total,
|
||||
);
|
||||
if (round == null) return;
|
||||
|
||||
await copyFile(heapSnapshotPath(label, round), HEAP_SNAPSHOT_OUTPUT_PATHS[label]);
|
||||
process.stderr.write(`Selected ${label} heap snapshot round ${round} for artifact\n`);
|
||||
await rm(HEAP_SNAPSHOT_WORK_DIRS[label], { recursive: true, force: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* base / head を交互に計測してJSONレポートを書き出す。
|
||||
* 交互にするのは、実行順やマシンの状態による偏りを両者に均等に載せるため。
|
||||
*/
|
||||
export async function compareBackendMemory(options: CompareOptions) {
|
||||
const rounds = readIntegerEnv('MK_MEMORY_COMPARE_ROUNDS', 5, 1);
|
||||
const warmupRounds = readIntegerEnv('MK_MEMORY_COMPARE_WARMUP_ROUNDS', 1, 0);
|
||||
const heapSnapshotsEnabled = readBooleanEnv('MK_MEMORY_HEAP_SNAPSHOT', false);
|
||||
const requestedHeapSnapshotRounds = heapSnapshotsEnabled
|
||||
? readIntegerEnv('MK_MEMORY_HEAP_SNAPSHOT_ROUNDS', rounds, 1)
|
||||
: 0;
|
||||
const heapSnapshotRounds = clampHeapSnapshotRounds(rounds, requestedHeapSnapshotRounds);
|
||||
const startedAt = new Date().toISOString();
|
||||
|
||||
for (const label of heapSnapshotLabels) {
|
||||
await rm(HEAP_SNAPSHOT_WORK_DIRS[label], { recursive: true, force: true });
|
||||
await rm(HEAP_SNAPSHOT_OUTPUT_PATHS[label], { force: true });
|
||||
}
|
||||
|
||||
const reports = {
|
||||
base: {
|
||||
dir: options.baseDir,
|
||||
samples: [] as MemoryReport['samples'],
|
||||
},
|
||||
head: {
|
||||
dir: options.headDir,
|
||||
samples: [] as MemoryReport['samples'],
|
||||
},
|
||||
};
|
||||
|
||||
for (let round = 1; round <= warmupRounds; round++) {
|
||||
process.stderr.write(`Starting warmup round ${round}/${warmupRounds}\n`);
|
||||
for (const label of heapSnapshotLabels) {
|
||||
await genSample(label, reports[label].dir, -round);
|
||||
}
|
||||
}
|
||||
|
||||
for (let round = 1; round <= rounds; round++) {
|
||||
const order = round % 2 === 1 ? ['base', 'head'] as const : ['head', 'base'] as const;
|
||||
process.stderr.write(`Starting measurement round ${round}/${rounds}: ${order.join(' -> ')}\n`);
|
||||
|
||||
for (const label of order) {
|
||||
const collectHeapSnapshot = shouldCollectHeapSnapshot(round, rounds, heapSnapshotRounds);
|
||||
const sample = await genSample(label, reports[label].dir, round, {
|
||||
collectHeapSnapshot,
|
||||
...(collectHeapSnapshot ? { heapSnapshotSavePath: heapSnapshotPath(label, round) } : {}),
|
||||
});
|
||||
reports[label].samples.push({
|
||||
...sample,
|
||||
round,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const summaries = {
|
||||
base: summarizeSamples(reports.base.samples),
|
||||
head: summarizeSamples(reports.head.samples),
|
||||
};
|
||||
for (const label of heapSnapshotLabels) {
|
||||
await saveRepresentativeHeapSnapshot(label, reports[label].samples, summaries[label]);
|
||||
}
|
||||
|
||||
for (const label of heapSnapshotLabels) {
|
||||
const report: MemoryReport = {
|
||||
timestamp: new Date().toISOString(),
|
||||
sampleCount: reports[label].samples.length,
|
||||
aggregation: 'median',
|
||||
comparison: {
|
||||
strategy: 'interleaved-pairs',
|
||||
rounds,
|
||||
warmupRounds,
|
||||
heapSnapshotRounds,
|
||||
startedAt,
|
||||
},
|
||||
summary: summaries[label],
|
||||
samples: reports[label].samples,
|
||||
};
|
||||
|
||||
await writeFile(label === 'base' ? options.baseOutput : options.headOutput, `${JSON.stringify(report, null, 2)}\n`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import Redis from 'ioredis';
|
||||
import pg from 'pg';
|
||||
|
||||
/**
|
||||
* 計測ラウンド間で状態を持ち越さないよう、テスト用DBを作り直しRedisを空にする。
|
||||
* 接続先はCIのテスト用サービス (.github/misskey/test.yml) と揃えてある。
|
||||
*/
|
||||
export async function resetState() {
|
||||
const postgres = new pg.Client({
|
||||
host: '127.0.0.1',
|
||||
port: 54312,
|
||||
database: 'postgres',
|
||||
user: 'postgres',
|
||||
});
|
||||
|
||||
await postgres.connect();
|
||||
try {
|
||||
await postgres.query('DROP DATABASE IF EXISTS "test-misskey" WITH (FORCE)');
|
||||
await postgres.query('CREATE DATABASE "test-misskey"');
|
||||
} finally {
|
||||
await postgres.end();
|
||||
}
|
||||
|
||||
const redis = new Redis({ host: '127.0.0.1', port: 56312 });
|
||||
try {
|
||||
await redis.flushall();
|
||||
} finally {
|
||||
redis.disconnect();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
export function clampHeapSnapshotRounds(totalRounds: number, requestedRounds: number) {
|
||||
return Math.min(totalRounds, requestedRounds);
|
||||
}
|
||||
|
||||
export function shouldCollectHeapSnapshot(round: number, totalRounds: number, requestedRounds: number) {
|
||||
const snapshotRounds = clampHeapSnapshotRounds(totalRounds, requestedRounds);
|
||||
return round > totalRounds - snapshotRounds;
|
||||
}
|
||||
|
||||
/**
|
||||
* 中央値に最も近いラウンドを代表として選ぶ。外れ値のスナップショットを成果物にしないため。
|
||||
*/
|
||||
export function selectRepresentativeHeapSnapshotRound<T extends { round: number }>(
|
||||
samples: T[],
|
||||
medianTotal: number | null | undefined,
|
||||
getTotal: (sample: T) => number | null | undefined,
|
||||
) {
|
||||
if (medianTotal == null || !Number.isFinite(medianTotal)) return null;
|
||||
|
||||
let selected: { round: number; distance: number } | null = null;
|
||||
for (const sample of samples) {
|
||||
const total = getTotal(sample);
|
||||
if (total == null || !Number.isFinite(total)) continue;
|
||||
|
||||
const distance = Math.abs(total - medianTotal);
|
||||
if (selected == null || distance < selected.distance || (distance === selected.distance && sample.round < selected.round)) {
|
||||
selected = { round: sample.round, distance };
|
||||
}
|
||||
}
|
||||
|
||||
return selected?.round ?? null;
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { resolve } from 'node:path';
|
||||
import { compareBackendMemory } from './compare';
|
||||
|
||||
const [baseDirArg, headDirArg, baseOutputArg, headOutputArg] = process.argv.slice(2);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||
if (baseDirArg == null || headDirArg == null || baseOutputArg == null || headOutputArg == null) {
|
||||
console.error('Usage: inspect <baseDir> <headDir> <baseOutput> <headOutput>');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
try {
|
||||
await compareBackendMemory({
|
||||
baseDir: resolve(baseDirArg),
|
||||
headDir: resolve(headDirArg),
|
||||
baseOutput: resolve(baseOutputArg),
|
||||
headOutput: resolve(headOutputArg),
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { resolve } from 'node:path';
|
||||
import { readOptionalEnv } from 'diagnostics-shared/env';
|
||||
import { measureBackendMemory } from './measure';
|
||||
|
||||
// ローカルデバッグ用: バックエンド1回分の計測結果をJSONで出力する
|
||||
const [backendDirArg] = process.argv.slice(2);
|
||||
|
||||
if (backendDirArg == null) {
|
||||
console.error('Usage: measure <backendDir>');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
try {
|
||||
const sample = await measureBackendMemory(resolve(backendDirArg), {
|
||||
heapSnapshotSavePath: readOptionalEnv('MK_MEMORY_HEAP_SNAPSHOT_SAVE_PATH'),
|
||||
});
|
||||
console.log(JSON.stringify(sample, null, 2));
|
||||
} catch (err) {
|
||||
console.error(JSON.stringify({
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
timestamp: new Date().toISOString(),
|
||||
}));
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import * as fs from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { readBooleanEnv, readIntegerEnv } from 'diagnostics-shared/env';
|
||||
import { analyzeHeapSnapshot, defaultHeapSnapshotBreakdownTopN, type HeapSnapshotData } from 'diagnostics-shared/heap-snapshot';
|
||||
import { getMemoryUsage, getSmapsRollupMemoryUsage } from './proc';
|
||||
import {
|
||||
forkBackendServer,
|
||||
getRuntimeMemoryUsage,
|
||||
requestHeapSnapshot,
|
||||
shutdownBackendServer,
|
||||
triggerGc,
|
||||
waitForServerReady,
|
||||
} from './server';
|
||||
import { measureMemoryUntilStable } from './stability';
|
||||
import type { MemorySample } from '../types';
|
||||
|
||||
export type MeasureBackendMemoryOptions = {
|
||||
/** heap snapshotを取得するか (既定: MK_MEMORY_HEAP_SNAPSHOT) */
|
||||
heapSnapshot?: boolean;
|
||||
/** 取得したheap snapshotの保存先。未指定なら解析後に破棄する */
|
||||
heapSnapshotSavePath?: string | null;
|
||||
heapSnapshotBreakdownTopN?: number;
|
||||
heapSnapshotTimeoutMs?: number;
|
||||
startupTimeoutMs?: number;
|
||||
ipcTimeoutMs?: number;
|
||||
};
|
||||
|
||||
function resolveOptions(options: MeasureBackendMemoryOptions) {
|
||||
return {
|
||||
heapSnapshot: options.heapSnapshot ?? readBooleanEnv('MK_MEMORY_HEAP_SNAPSHOT', false),
|
||||
heapSnapshotSavePath: options.heapSnapshotSavePath ?? null,
|
||||
heapSnapshotBreakdownTopN: options.heapSnapshotBreakdownTopN ?? readIntegerEnv('MK_MEMORY_HEAP_SNAPSHOT_BREAKDOWN_TOP_N', defaultHeapSnapshotBreakdownTopN, 1),
|
||||
heapSnapshotTimeoutMs: options.heapSnapshotTimeoutMs ?? readIntegerEnv('MK_MEMORY_HEAP_SNAPSHOT_TIMEOUT_MS', 120000, 1),
|
||||
startupTimeoutMs: options.startupTimeoutMs ?? readIntegerEnv('MK_MEMORY_STARTUP_TIMEOUT_MS', 120000, 1),
|
||||
ipcTimeoutMs: options.ipcTimeoutMs ?? readIntegerEnv('MK_MEMORY_IPC_TIMEOUT_MS', 30000, 1),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* バックエンドを1回起動し、GC後のメモリ使用量を計測して1サンプル分の結果を返す。
|
||||
*/
|
||||
export async function measureBackendMemory(backendDir: string, options: MeasureBackendMemoryOptions = {}): Promise<MemorySample> {
|
||||
const settings = resolveOptions(options);
|
||||
const serverProcess = forkBackendServer(backendDir);
|
||||
|
||||
// 起動完了メッセージを取りこぼさないよう、他のハンドラより先に待ち受ける
|
||||
const serverReady = waitForServerReady(serverProcess, settings.startupTimeoutMs);
|
||||
|
||||
serverProcess.stdout?.on('data', (data) => {
|
||||
process.stderr.write(`[server stdout] ${data}`);
|
||||
});
|
||||
|
||||
serverProcess.stderr?.on('data', (data) => {
|
||||
process.stderr.write(`[server stderr] ${data}`);
|
||||
});
|
||||
|
||||
serverProcess.on('error', (err) => {
|
||||
process.stderr.write(`[server error] ${err}\n`);
|
||||
});
|
||||
|
||||
// 途中で失敗しても子プロセスを残さない。残すと次のラウンドがポート衝突で落ちる
|
||||
try {
|
||||
const startupStartTime = Date.now();
|
||||
await serverReady;
|
||||
|
||||
const startupTime = Date.now() - startupStartTime;
|
||||
process.stderr.write(`Server started in ${startupTime}ms\n`);
|
||||
|
||||
await triggerGc(serverProcess, settings.ipcTimeoutMs);
|
||||
|
||||
const pid = serverProcess.pid!;
|
||||
const stableSmapsRollup = await measureMemoryUntilStable(() => getSmapsRollupMemoryUsage(pid));
|
||||
const afterGc = {
|
||||
memoryUsage: {
|
||||
...await getMemoryUsage(pid),
|
||||
...stableSmapsRollup.memoryUsage,
|
||||
...await getRuntimeMemoryUsage(serverProcess, settings.ipcTimeoutMs),
|
||||
},
|
||||
stability: stableSmapsRollup.stability,
|
||||
};
|
||||
process.stderr.write(`Memory ${afterGc.stability.converged ? 'stabilized' : 'did not stabilize'} after ${afterGc.stability.readingCount} readings over ${Math.round(afterGc.stability.elapsedMs)}ms\n`);
|
||||
|
||||
const heapSnapshotAfterGc = await getHeapSnapshotStatistics(serverProcess, settings);
|
||||
|
||||
return {
|
||||
timestamp: new Date().toISOString(),
|
||||
phases: {
|
||||
afterGc: {
|
||||
memoryUsage: afterGc.memoryUsage,
|
||||
memoryStability: afterGc.stability,
|
||||
heapSnapshot: heapSnapshotAfterGc,
|
||||
},
|
||||
},
|
||||
};
|
||||
} finally {
|
||||
await shutdownBackendServer(serverProcess);
|
||||
}
|
||||
}
|
||||
|
||||
async function getHeapSnapshotStatistics(
|
||||
serverProcess: ReturnType<typeof forkBackendServer>,
|
||||
settings: ReturnType<typeof resolveOptions>,
|
||||
): Promise<HeapSnapshotData | null> {
|
||||
if (!settings.heapSnapshot) return null;
|
||||
|
||||
const snapshotPath = join(tmpdir(), `misskey-backend-heap-${process.pid}-${serverProcess.pid}-${Date.now()}.heapsnapshot`);
|
||||
const writtenPath = await requestHeapSnapshot(serverProcess, snapshotPath, settings.heapSnapshotTimeoutMs);
|
||||
|
||||
try {
|
||||
if (settings.heapSnapshotSavePath != null && settings.heapSnapshotSavePath !== '') {
|
||||
await fs.mkdir(dirname(settings.heapSnapshotSavePath), { recursive: true });
|
||||
await fs.copyFile(writtenPath, settings.heapSnapshotSavePath);
|
||||
}
|
||||
|
||||
const snapshot = JSON.parse(await fs.readFile(writtenPath, 'utf-8'));
|
||||
return analyzeHeapSnapshot(snapshot, { breakdownTopN: settings.heapSnapshotBreakdownTopN });
|
||||
} finally {
|
||||
// 数百MBになることがあるため、解析後は必ず消す
|
||||
await fs.unlink(writtenPath).catch(err => {
|
||||
process.stderr.write(`Failed to delete heap snapshot ${writtenPath}: ${err.message}\n`);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import * as fs from 'node:fs/promises';
|
||||
|
||||
export const procStatusKeys = ['VmPeak', 'VmSize', 'VmHWM', 'VmRSS', 'VmData', 'VmStk', 'VmExe', 'VmLib', 'VmPTE', 'VmSwap'] as const;
|
||||
export const smapsRollupKeys = ['Pss', 'Shared_Clean', 'Shared_Dirty', 'Private_Clean', 'Private_Dirty', 'Swap', 'SwapPss'] as const;
|
||||
|
||||
/**
|
||||
* `/proc` 配下の `Key: 1234 kB` 形式のファイルから指定キーを取り出す。
|
||||
* 1つでも欠けていると以降の集計が静かに壊れるため、見つからなければ例外にする。
|
||||
*/
|
||||
export function parseMemoryFile<KS extends readonly string[]>(content: string, keys: KS, path: string): Record<KS[number], number> {
|
||||
const result = {} as Record<KS[number], number>;
|
||||
for (const _key of keys) {
|
||||
const key = _key as KS[number];
|
||||
const match = content.match(new RegExp(`${key}:\\s+(\\d+)\\s+kB`));
|
||||
if (match) {
|
||||
result[key] = parseInt(match[1], 10);
|
||||
} else {
|
||||
throw new Error(`Failed to parse ${key} from ${path}`);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function bytesToKiB(value: number) {
|
||||
return Math.round(value / 1024);
|
||||
}
|
||||
|
||||
export async function getMemoryUsage(pid: number) {
|
||||
const path = `/proc/${pid}/status`;
|
||||
const status = await fs.readFile(path, 'utf-8');
|
||||
return parseMemoryFile(status, procStatusKeys, path);
|
||||
}
|
||||
|
||||
export async function getSmapsRollupMemoryUsage(pid: number) {
|
||||
const path = `/proc/${pid}/smaps_rollup`;
|
||||
const smapsRollup = await fs.readFile(path, 'utf-8');
|
||||
return parseMemoryFile(smapsRollup, smapsRollupKeys, path);
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { fork, type ChildProcess } from 'node:child_process';
|
||||
import { join } from 'node:path';
|
||||
import { bytesToKiB } from './proc';
|
||||
|
||||
type GcMessage = 'gc ok' | 'gc unavailable';
|
||||
type RuntimeMemoryUsageMessage = {
|
||||
type: 'memory usage';
|
||||
value: NodeJS.MemoryUsage;
|
||||
};
|
||||
type HeapSnapshotMessage = {
|
||||
type: 'heap snapshot';
|
||||
path?: string;
|
||||
};
|
||||
type HeapSnapshotErrorMessage = {
|
||||
type: 'heap snapshot error';
|
||||
message: string;
|
||||
};
|
||||
type HeapSnapshotResponseMessage = HeapSnapshotMessage | HeapSnapshotErrorMessage;
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return value != null && typeof value === 'object';
|
||||
}
|
||||
|
||||
function isGcMessage(message: unknown): message is GcMessage {
|
||||
return message === 'gc ok' || message === 'gc unavailable';
|
||||
}
|
||||
|
||||
function isRuntimeMemoryUsageMessage(message: unknown): message is RuntimeMemoryUsageMessage {
|
||||
return isRecord(message) && message.type === 'memory usage' && isRecord(message.value);
|
||||
}
|
||||
|
||||
function isHeapSnapshotResponseMessage(message: unknown): message is HeapSnapshotResponseMessage {
|
||||
if (!isRecord(message)) return false;
|
||||
if (message.type === 'heap snapshot') return true;
|
||||
return message.type === 'heap snapshot error' && typeof message.message === 'string';
|
||||
}
|
||||
|
||||
export function waitForMessage<T>(serverProcess: ChildProcess, predicate: (message: unknown) => message is T, description: string, timeout: number) {
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
const cleanup = () => {
|
||||
globalThis.clearTimeout(timer);
|
||||
serverProcess.off('message', onMessage);
|
||||
serverProcess.off('exit', onExit);
|
||||
serverProcess.off('error', onError);
|
||||
serverProcess.off('disconnect', onDisconnect);
|
||||
};
|
||||
|
||||
const timer = globalThis.setTimeout(() => {
|
||||
cleanup();
|
||||
reject(new Error(`Timed out waiting for ${description}`));
|
||||
}, timeout);
|
||||
|
||||
const onMessage = (message: unknown) => {
|
||||
if (!predicate(message)) return;
|
||||
cleanup();
|
||||
resolve(message);
|
||||
};
|
||||
|
||||
// 子が死んだ場合、待ち続けてもメッセージは来ない。
|
||||
// タイムアウトまで待って誤解を招くエラーを出すより、理由を添えて即座に失敗させる
|
||||
const onExit = (code: number | null, signal: string | null) => {
|
||||
cleanup();
|
||||
reject(new Error(`Server exited (code=${code}, signal=${signal}) while waiting for ${description}`));
|
||||
};
|
||||
|
||||
const onError = (err: Error) => {
|
||||
cleanup();
|
||||
reject(new Error(`Server errored while waiting for ${description}: ${err.message}`));
|
||||
};
|
||||
|
||||
const onDisconnect = () => {
|
||||
cleanup();
|
||||
reject(new Error(`Server IPC channel closed while waiting for ${description}`));
|
||||
};
|
||||
|
||||
serverProcess.on('message', onMessage);
|
||||
serverProcess.once('exit', onExit);
|
||||
serverProcess.once('error', onError);
|
||||
serverProcess.once('disconnect', onDisconnect);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* ビルド済みバックエンドを子プロセスとして起動する。
|
||||
* execArgv は親から引き継がず `--expose-gc` のみを渡す: 親は tsx 経由で動くため、
|
||||
* 引き継ぐと計測対象プロセスにTSローダーが載ってしまいメモリ量が歪む。
|
||||
*/
|
||||
export function forkBackendServer(backendDir: string) {
|
||||
return fork(join(backendDir, 'built/entry.js'), [], {
|
||||
cwd: backendDir,
|
||||
env: {
|
||||
...process.env,
|
||||
NODE_ENV: 'production',
|
||||
MK_DISABLE_CLUSTERING: '1',
|
||||
MK_ONLY_SERVER: '1',
|
||||
MK_NO_DAEMONS: '1',
|
||||
},
|
||||
stdio: ['pipe', 'pipe', 'pipe', 'ipc'],
|
||||
execArgv: ['--expose-gc'],
|
||||
});
|
||||
}
|
||||
|
||||
export function waitForServerReady(serverProcess: ChildProcess, timeout: number) {
|
||||
return waitForMessage(
|
||||
serverProcess,
|
||||
(message): message is 'ok' => message === 'ok',
|
||||
'server startup',
|
||||
timeout,
|
||||
);
|
||||
}
|
||||
|
||||
export async function triggerGc(serverProcess: ChildProcess, timeout: number) {
|
||||
// 送信前にlistenerを張らないと、応答を取りこぼす可能性がある
|
||||
const ok = waitForMessage(serverProcess, isGcMessage, 'GC completion', timeout);
|
||||
|
||||
serverProcess.send('gc');
|
||||
|
||||
const message = await ok;
|
||||
if (message === 'gc unavailable') {
|
||||
throw new Error('GC is unavailable. Start the process with --expose-gc to enable this feature.');
|
||||
}
|
||||
}
|
||||
|
||||
export async function getRuntimeMemoryUsage(serverProcess: ChildProcess, timeout: number) {
|
||||
const response = waitForMessage(
|
||||
serverProcess,
|
||||
isRuntimeMemoryUsageMessage,
|
||||
'memory usage',
|
||||
timeout,
|
||||
);
|
||||
|
||||
serverProcess.send('memory usage');
|
||||
|
||||
const message = await response;
|
||||
const memoryUsage = message.value;
|
||||
|
||||
// /proc 由来の値と単位を揃える
|
||||
return {
|
||||
HeapTotal: bytesToKiB(memoryUsage.heapTotal),
|
||||
HeapUsed: bytesToKiB(memoryUsage.heapUsed),
|
||||
External: bytesToKiB(memoryUsage.external),
|
||||
ArrayBuffers: bytesToKiB(memoryUsage.arrayBuffers),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* heap snapshotの書き出しを依頼し、実際に書かれたパスを返す。
|
||||
*/
|
||||
export async function requestHeapSnapshot(serverProcess: ChildProcess, snapshotPath: string, timeout: number) {
|
||||
const response = waitForMessage(
|
||||
serverProcess,
|
||||
isHeapSnapshotResponseMessage,
|
||||
'heap snapshot',
|
||||
timeout,
|
||||
);
|
||||
|
||||
serverProcess.send({
|
||||
type: 'heap snapshot',
|
||||
path: snapshotPath,
|
||||
});
|
||||
|
||||
const message = await response;
|
||||
if (message.type === 'heap snapshot error') {
|
||||
throw new Error(`Failed to write heap snapshot: ${message.message}`);
|
||||
}
|
||||
|
||||
return typeof message.path === 'string' ? message.path : snapshotPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* SIGTERMで終了を促し、一定時間で落ちなければSIGKILLする。
|
||||
*/
|
||||
export async function shutdownBackendServer(serverProcess: ChildProcess) {
|
||||
// 既に終了しているなら 'exit' はもう発火しないので、待つと無駄に10秒止まる
|
||||
if (serverProcess.exitCode != null || serverProcess.signalCode != null) return;
|
||||
|
||||
await new Promise<void>(resolve => {
|
||||
let forceTimer: NodeJS.Timeout | undefined;
|
||||
const termTimer = globalThis.setTimeout(() => {
|
||||
serverProcess.kill('SIGKILL');
|
||||
// SIGKILLは無視できないので通常はここで 'exit' が来る。
|
||||
// D状態などで落ちない場合に計測全体を止めないよう、待ち時間には上限を設ける
|
||||
forceTimer = globalThis.setTimeout(resolve, 5000);
|
||||
}, 10000);
|
||||
|
||||
serverProcess.once('exit', () => {
|
||||
globalThis.clearTimeout(termTimer);
|
||||
if (forceTimer != null) globalThis.clearTimeout(forceTimer);
|
||||
resolve();
|
||||
});
|
||||
|
||||
serverProcess.kill('SIGTERM');
|
||||
});
|
||||
}
|
||||
+4
@@ -38,6 +38,10 @@ function getMaxAbsoluteSlopes<T extends Record<string, number>>(readings: { elap
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* メモリ使用量が落ち着くまで繰り返し読み取る。
|
||||
* 起動直後は遅延初期化でじわじわ増え続けるため、直近 `windowSize` 件の傾きが十分小さくなるまで待つ。
|
||||
*/
|
||||
export async function measureMemoryUntilStable<T extends Record<string, number>>(
|
||||
readMemoryUsage: () => Promise<T>,
|
||||
timer: MemoryStabilityTimer = defaultTimer,
|
||||
@@ -0,0 +1,30 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { readFile, writeFile } from 'node:fs/promises';
|
||||
import { resolve } from 'node:path';
|
||||
import { readRequiredEnv } from 'diagnostics-shared/env';
|
||||
import { renderMemoryReportMarkdown } from './report/markdown';
|
||||
import type { MemoryReport } from './types';
|
||||
|
||||
async function main() {
|
||||
const [baseFileArg, headFileArg, outputFileArg] = process.argv.slice(2);
|
||||
if (baseFileArg == null || headFileArg == null || outputFileArg == null) {
|
||||
throw new Error('Usage: render-md <baseReport.json> <headReport.json> <output.md>');
|
||||
}
|
||||
|
||||
const base = JSON.parse(await readFile(resolve(baseFileArg), 'utf8')) as MemoryReport;
|
||||
const head = JSON.parse(await readFile(resolve(headFileArg), 'utf8')) as MemoryReport;
|
||||
|
||||
await writeFile(resolve(outputFileArg), renderMemoryReportMarkdown(base, head, {
|
||||
baseHeapSnapshotUrl: readRequiredEnv('MK_MEMORY_HEAP_SNAPSHOT_ARTIFACT_URL_BASE'),
|
||||
headHeapSnapshotUrl: readRequiredEnv('MK_MEMORY_HEAP_SNAPSHOT_ARTIFACT_URL_HEAD'),
|
||||
}));
|
||||
}
|
||||
|
||||
await main().catch(err => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,174 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { formatKiBAsMb } from 'diagnostics-shared/format';
|
||||
import { renderHeapSnapshotTable, type HeapSnapshotReport } from 'diagnostics-shared/heap-snapshot';
|
||||
import { renderMetricComparisonTable } from 'diagnostics-shared/metric-table';
|
||||
import {
|
||||
independentDeltaSummary,
|
||||
isOutsideObservedNoise,
|
||||
type IndependentDeltaSummary,
|
||||
} from 'diagnostics-shared/stats';
|
||||
import type { MemoryPhase, MemoryReport } from '../types';
|
||||
|
||||
export type RenderMemoryReportOptions = {
|
||||
baseHeapSnapshotUrl: string;
|
||||
headHeapSnapshotUrl: string;
|
||||
};
|
||||
|
||||
const memoryReportPhases = [
|
||||
{
|
||||
key: 'afterGc',
|
||||
title: 'After GC',
|
||||
},
|
||||
] as const satisfies readonly { key: MemoryPhase; title: string }[];
|
||||
|
||||
const memoryMetrics = [
|
||||
'HeapUsed',
|
||||
'Pss',
|
||||
'USS',
|
||||
'External',
|
||||
] as const;
|
||||
|
||||
type MemoryMetric = typeof memoryMetrics[number];
|
||||
|
||||
const memoryColorThresholdKiB = 100;
|
||||
|
||||
function formatMemoryMetricName(metric: MemoryMetric) {
|
||||
return metric === 'Pss' ? 'PSS' : metric;
|
||||
}
|
||||
|
||||
function getMemoryValueFromSample(sample: MemoryReport['samples'][number], phase: MemoryPhase, metric: MemoryMetric) {
|
||||
const memoryUsage = sample.phases[phase].memoryUsage;
|
||||
// USSは直接取れないのでPrivateの合算で近似する
|
||||
if (metric !== 'USS') return memoryUsage[metric];
|
||||
return memoryUsage.Private_Clean + memoryUsage.Private_Dirty;
|
||||
}
|
||||
|
||||
function summarizeMemoryMetric(base: MemoryReport, head: MemoryReport, phase: MemoryPhase, metric: MemoryMetric) {
|
||||
return independentDeltaSummary(
|
||||
base.samples,
|
||||
head.samples,
|
||||
sample => getMemoryValueFromSample(sample, phase, metric),
|
||||
);
|
||||
}
|
||||
|
||||
function getDeltaPercent(summary: IndependentDeltaSummary) {
|
||||
if (summary.baseMedian === 0) return null;
|
||||
return summary.delta * 100 / summary.baseMedian;
|
||||
}
|
||||
|
||||
function renderMainTableForPhase(base: MemoryReport, head: MemoryReport, phase: MemoryPhase) {
|
||||
return renderMetricComparisonTable(
|
||||
base.samples,
|
||||
head.samples,
|
||||
memoryMetrics.map(metric => ({
|
||||
label: `**${formatMemoryMetricName(metric)}**`,
|
||||
getValue: sample => getMemoryValueFromSample(sample, phase, metric),
|
||||
formatValue: formatKiBAsMb,
|
||||
absoluteThreshold: memoryColorThresholdKiB,
|
||||
})),
|
||||
{ onlySignificantChanges: true },
|
||||
);
|
||||
}
|
||||
|
||||
function toHeapSnapshotReport(report: MemoryReport): HeapSnapshotReport | null {
|
||||
const summary = report.summary.afterGc.heapSnapshot;
|
||||
if (summary == null) return null;
|
||||
|
||||
return {
|
||||
summary,
|
||||
samples: report.samples.flatMap(sample => {
|
||||
const data = sample.phases.afterGc.heapSnapshot;
|
||||
return data == null ? [] : [{ round: sample.round, data }];
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function renderHeapSnapshotSection(base: MemoryReport, head: MemoryReport) {
|
||||
const baseHeapSnapshotReport = toHeapSnapshotReport(base);
|
||||
const headHeapSnapshotReport = toHeapSnapshotReport(head);
|
||||
if (baseHeapSnapshotReport == null || headHeapSnapshotReport == null) return null;
|
||||
|
||||
const table = renderHeapSnapshotTable(baseHeapSnapshotReport, headHeapSnapshotReport);
|
||||
|
||||
const lines = [
|
||||
'### V8 Heap Snapshot Statistics',
|
||||
'',
|
||||
table,
|
||||
'',
|
||||
];
|
||||
|
||||
// Sankeyはノイズが多く読み取りづらかったため現在は無効。復活させる余地を残して残置する
|
||||
for (const graph of [
|
||||
//renderHeapSnapshotSankey(baseHeapSnapshotReport, 'Base'),
|
||||
//renderHeapSnapshotSankey(headHeapSnapshotReport, 'Head'),
|
||||
] as (string | null)[]) {
|
||||
if (graph == null) continue;
|
||||
lines.push(graph);
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function countNonConvergedMemorySamples(base: MemoryReport, head: MemoryReport) {
|
||||
return [base, head]
|
||||
.flatMap(report => report.samples)
|
||||
.filter(sample => memoryReportPhases.some(phase => !sample.phases[phase.key].memoryStability.converged))
|
||||
.length;
|
||||
}
|
||||
|
||||
export function renderMemoryReportMarkdown(base: MemoryReport, head: MemoryReport, options: RenderMemoryReportOptions) {
|
||||
const lines = [
|
||||
'## ⚙️ Backend Diagnostics Report',
|
||||
'',
|
||||
];
|
||||
|
||||
//const summary = measurementSummary(base, head);
|
||||
//if (summary != null) {
|
||||
// lines.push(summary);
|
||||
// lines.push('');
|
||||
//}
|
||||
|
||||
for (const phase of memoryReportPhases) {
|
||||
lines.push(`### Memory: ${phase.title}`);
|
||||
lines.push(renderMainTableForPhase(base, head, phase.key));
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
lines.push('');
|
||||
|
||||
const nonConvergedSamples = countNonConvergedMemorySamples(base, head);
|
||||
if (nonConvergedSamples > 0) {
|
||||
const noun = nonConvergedSamples === 1 ? 'sample' : 'samples';
|
||||
lines.push(`⚠️ **Measurement warning**: ${nonConvergedSamples} memory ${noun} did not converge.`);
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
const heapSnapshotSection = renderHeapSnapshotSection(base, head);
|
||||
if (heapSnapshotSection != null) {
|
||||
lines.push(heapSnapshotSection);
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
lines.push(`Download representative heap snapshot: [base](${options.baseHeapSnapshotUrl}) / [head](${options.headHeapSnapshotUrl})`);
|
||||
lines.push('');
|
||||
|
||||
const warningMetric = 'Pss';
|
||||
const warningSummary = summarizeMemoryMetric(base, head, 'afterGc', warningMetric);
|
||||
const warningDiffPercent = getDeltaPercent(warningSummary);
|
||||
if (
|
||||
warningSummary.delta > 0 &&
|
||||
warningDiffPercent != null &&
|
||||
warningDiffPercent > 5 &&
|
||||
isOutsideObservedNoise(warningSummary)
|
||||
) {
|
||||
lines.push(`⚠️ **Warning**: Memory usage (${formatMemoryMetricName(warningMetric)}) has increased by more than 5% and exceeds the observed sample noise. Please verify this is not an unintended change.`);
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
return `${lines.join('\n')}\n`;
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import type { HeapSnapshotData } from 'diagnostics-shared/heap-snapshot';
|
||||
|
||||
/** 計測フェーズ。将来的に増やせるようリスト化してある */
|
||||
export const memoryPhases = ['afterGc'] as const;
|
||||
|
||||
export type MemoryPhase = typeof memoryPhases[number];
|
||||
|
||||
export type MemoryStability = {
|
||||
converged: boolean;
|
||||
readingCount: number;
|
||||
elapsedMs: number;
|
||||
maxAbsoluteSlopesKiBPerSecond: Record<string, number> | null;
|
||||
};
|
||||
|
||||
/** バックエンドを1回起動して得られる計測結果 */
|
||||
export type MemorySample = {
|
||||
timestamp: string;
|
||||
phases: Record<MemoryPhase, {
|
||||
/** /proc 由来の値はKiB、ランタイム由来の値もKiBに揃えてある */
|
||||
memoryUsage: Record<string, number>;
|
||||
memoryStability: MemoryStability;
|
||||
heapSnapshot: HeapSnapshotData | null;
|
||||
}>;
|
||||
};
|
||||
|
||||
/** base / head それぞれについて出力されるJSONレポート */
|
||||
export type MemoryReport = {
|
||||
timestamp: string;
|
||||
sampleCount: number;
|
||||
aggregation: string;
|
||||
comparison?: {
|
||||
strategy: string;
|
||||
rounds: number;
|
||||
warmupRounds: number;
|
||||
heapSnapshotRounds?: number;
|
||||
startedAt: string;
|
||||
};
|
||||
summary: Record<MemoryPhase, {
|
||||
memoryUsage: Record<string, number>;
|
||||
heapSnapshot?: HeapSnapshotData;
|
||||
}>;
|
||||
samples: (MemorySample & {
|
||||
round: number;
|
||||
})[];
|
||||
};
|
||||
@@ -0,0 +1,31 @@
|
||||
## ⚙️ Backend Diagnostics Report
|
||||
|
||||
### Memory: After GC
|
||||
| Metric | @ Base | @ Head | Δ | MAD |
|
||||
| --- | ---: | ---: | ---: | ---: |
|
||||
| **HeapUsed** | 152 MB <br> ± 1 MB | 168 MB <br> ± 1 MB | $\color{orange}{\text{+16~MB}}$<br>$\color{orange}{\text{+10.5\\%}}$ | 1.4 MB |
|
||||
| **PSS** | 202 MB <br> ± 1 MB | 218 MB <br> ± 1 MB | $\color{orange}{\text{+16~MB}}$<br>$\color{orange}{\text{+7.9\\%}}$ | 1.4 MB |
|
||||
| **USS** | 184 MB <br> ± 1 MB | 200 MB <br> ± 1 MB | $\color{orange}{\text{+16~MB}}$<br>$\color{orange}{\text{+8.7\\%}}$ | 1.4 MB |
|
||||
|
||||
<small><i>Only metrics showing significant changes are displayed.</i></small>
|
||||
|
||||
|
||||
### V8 Heap Snapshot Statistics
|
||||
|
||||
| Metric | @ Base | @ Head | Δ | MAD |
|
||||
| --- | ---: | ---: | ---: | ---: |
|
||||
| $\color{gray}{\rule{8pt}{8pt}}$ **Total** | 40 MB <br> ± 200 KB | 44 MB <br> ± 200 KB | $\color{orange}{\text{+3.2 MB}}$<br>$\color{orange}{\text{+7.9\\%}}$ | 283 KB |
|
||||
| | | | | |
|
||||
| $\color{orange}{\rule{8pt}{8pt}}$ **Code** | 4.7 MB | 5.1 MB | $\color{orange}{\text{+376 KB}}$ | 33 KB |
|
||||
| $\color{red}{\rule{8pt}{8pt}}$ **Strings** | 4.4 MB | 4.8 MB | $\color{orange}{\text{+352 KB}}$ | 31 KB |
|
||||
| $\color{cyan}{\rule{8pt}{8pt}}$ **JS arrays** | 4.1 MB | 4.5 MB | $\color{orange}{\text{+328 KB}}$ | 29 KB |
|
||||
| $\color{green}{\rule{8pt}{8pt}}$ **Typed arrays** | 3.8 MB | 4.1 MB | $\color{orange}{\text{+304 KB}}$ | 27 KB |
|
||||
| $\color{yellow}{\rule{8pt}{8pt}}$ **System objects** | 3.5 MB | 3.8 MB | $\color{orange}{\text{+280 KB}}$ | 25 KB |
|
||||
| $\color{violet}{\rule{8pt}{8pt}}$ **Other JS objs** | 3.2 MB | 3.5 MB | $\color{orange}{\text{+256 KB}}$ | 23 KB |
|
||||
| $\color{pink}{\rule{8pt}{8pt}}$ **Other non-JS objs** | 2.9 MB | 3.2 MB | $\color{orange}{\text{+232 KB}}$ | 21 KB |
|
||||
|
||||
|
||||
Download representative heap snapshot: [base](https://example.invalid/base) / [head](https://example.invalid/head)
|
||||
|
||||
⚠️ **Warning**: Memory usage (PSS) has increased by more than 5% and exceeds the observed sample noise. Please verify this is not an unintended change.
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
{
|
||||
"timestamp": "2026-07-18T07:57:17.653Z",
|
||||
"sampleCount": 3,
|
||||
"aggregation": "median",
|
||||
"comparison": {
|
||||
"strategy": "interleaved-pairs",
|
||||
"rounds": 3,
|
||||
"warmupRounds": 1,
|
||||
"startedAt": "2026-07-18T07:57:17.653Z"
|
||||
},
|
||||
"summary": {
|
||||
"afterGc": {
|
||||
"memoryUsage": {
|
||||
"VmRSS": 202000,
|
||||
"Pss": 202000,
|
||||
"Private_Clean": 2020,
|
||||
"Private_Dirty": 182000,
|
||||
"HeapUsed": 152000,
|
||||
"HeapTotal": 170000,
|
||||
"External": 8200,
|
||||
"ArrayBuffers": 2000
|
||||
},
|
||||
"heapSnapshot": {
|
||||
"categories": {
|
||||
"total": 40400000,
|
||||
"code": 4747000,
|
||||
"strings": 4444000,
|
||||
"jsArrays": 4141000,
|
||||
"typedArrays": 3838000,
|
||||
"systemObjects": 3535000,
|
||||
"otherJsObjects": 3232000,
|
||||
"otherNonJsObjects": 2929000
|
||||
},
|
||||
"nodeCounts": {
|
||||
"total": 404000,
|
||||
"code": 47470,
|
||||
"strings": 44440,
|
||||
"jsArrays": 41410,
|
||||
"typedArrays": 38380,
|
||||
"systemObjects": 35350,
|
||||
"otherJsObjects": 32320,
|
||||
"otherNonJsObjects": 29290
|
||||
},
|
||||
"breakdowns": {}
|
||||
}
|
||||
}
|
||||
},
|
||||
"samples": [
|
||||
{
|
||||
"round": 1,
|
||||
"timestamp": "2026-07-18T07:57:17.652Z",
|
||||
"phases": {
|
||||
"afterGc": {
|
||||
"memoryUsage": {
|
||||
"VmRSS": 201000,
|
||||
"Pss": 201000,
|
||||
"Private_Clean": 2010,
|
||||
"Private_Dirty": 181000,
|
||||
"HeapUsed": 151000,
|
||||
"HeapTotal": 170000,
|
||||
"External": 8100,
|
||||
"ArrayBuffers": 2000
|
||||
},
|
||||
"memoryStability": {
|
||||
"converged": true,
|
||||
"readingCount": 3,
|
||||
"elapsedMs": 4000,
|
||||
"maxAbsoluteSlopesKiBPerSecond": {
|
||||
"Pss": 10,
|
||||
"Private_Dirty": 5
|
||||
}
|
||||
},
|
||||
"heapSnapshot": {
|
||||
"categories": {
|
||||
"total": 40200000,
|
||||
"code": 4723500,
|
||||
"strings": 4422000,
|
||||
"jsArrays": 4120500,
|
||||
"typedArrays": 3819000,
|
||||
"systemObjects": 3517500,
|
||||
"otherJsObjects": 3216000,
|
||||
"otherNonJsObjects": 2914500
|
||||
},
|
||||
"nodeCounts": {
|
||||
"total": 402000,
|
||||
"code": 47235,
|
||||
"strings": 44220,
|
||||
"jsArrays": 41205,
|
||||
"typedArrays": 38190,
|
||||
"systemObjects": 35175,
|
||||
"otherJsObjects": 32160,
|
||||
"otherNonJsObjects": 29145
|
||||
},
|
||||
"breakdowns": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"round": 2,
|
||||
"timestamp": "2026-07-18T07:57:17.653Z",
|
||||
"phases": {
|
||||
"afterGc": {
|
||||
"memoryUsage": {
|
||||
"VmRSS": 202000,
|
||||
"Pss": 202000,
|
||||
"Private_Clean": 2020,
|
||||
"Private_Dirty": 182000,
|
||||
"HeapUsed": 152000,
|
||||
"HeapTotal": 170000,
|
||||
"External": 8200,
|
||||
"ArrayBuffers": 2000
|
||||
},
|
||||
"memoryStability": {
|
||||
"converged": true,
|
||||
"readingCount": 3,
|
||||
"elapsedMs": 4000,
|
||||
"maxAbsoluteSlopesKiBPerSecond": {
|
||||
"Pss": 10,
|
||||
"Private_Dirty": 5
|
||||
}
|
||||
},
|
||||
"heapSnapshot": {
|
||||
"categories": {
|
||||
"total": 40400000,
|
||||
"code": 4747000,
|
||||
"strings": 4444000,
|
||||
"jsArrays": 4141000,
|
||||
"typedArrays": 3838000,
|
||||
"systemObjects": 3535000,
|
||||
"otherJsObjects": 3232000,
|
||||
"otherNonJsObjects": 2929000
|
||||
},
|
||||
"nodeCounts": {
|
||||
"total": 404000,
|
||||
"code": 47470,
|
||||
"strings": 44440,
|
||||
"jsArrays": 41410,
|
||||
"typedArrays": 38380,
|
||||
"systemObjects": 35350,
|
||||
"otherJsObjects": 32320,
|
||||
"otherNonJsObjects": 29290
|
||||
},
|
||||
"breakdowns": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"round": 3,
|
||||
"timestamp": "2026-07-18T07:57:17.653Z",
|
||||
"phases": {
|
||||
"afterGc": {
|
||||
"memoryUsage": {
|
||||
"VmRSS": 203000,
|
||||
"Pss": 203000,
|
||||
"Private_Clean": 2030,
|
||||
"Private_Dirty": 183000,
|
||||
"HeapUsed": 153000,
|
||||
"HeapTotal": 170000,
|
||||
"External": 8300,
|
||||
"ArrayBuffers": 2000
|
||||
},
|
||||
"memoryStability": {
|
||||
"converged": true,
|
||||
"readingCount": 3,
|
||||
"elapsedMs": 4000,
|
||||
"maxAbsoluteSlopesKiBPerSecond": {
|
||||
"Pss": 10,
|
||||
"Private_Dirty": 5
|
||||
}
|
||||
},
|
||||
"heapSnapshot": {
|
||||
"categories": {
|
||||
"total": 40600000,
|
||||
"code": 4770500,
|
||||
"strings": 4466000,
|
||||
"jsArrays": 4161500,
|
||||
"typedArrays": 3857000,
|
||||
"systemObjects": 3552500,
|
||||
"otherJsObjects": 3248000,
|
||||
"otherNonJsObjects": 2943500
|
||||
},
|
||||
"nodeCounts": {
|
||||
"total": 406000,
|
||||
"code": 47705,
|
||||
"strings": 44660,
|
||||
"jsArrays": 41615,
|
||||
"typedArrays": 38570,
|
||||
"systemObjects": 35525,
|
||||
"otherJsObjects": 32480,
|
||||
"otherNonJsObjects": 29435
|
||||
},
|
||||
"breakdowns": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
{
|
||||
"timestamp": "2026-07-18T07:57:17.654Z",
|
||||
"sampleCount": 3,
|
||||
"aggregation": "median",
|
||||
"comparison": {
|
||||
"strategy": "interleaved-pairs",
|
||||
"rounds": 3,
|
||||
"warmupRounds": 1,
|
||||
"startedAt": "2026-07-18T07:57:17.654Z"
|
||||
},
|
||||
"summary": {
|
||||
"afterGc": {
|
||||
"memoryUsage": {
|
||||
"VmRSS": 218000,
|
||||
"Pss": 218000,
|
||||
"Private_Clean": 2020,
|
||||
"Private_Dirty": 198000,
|
||||
"HeapUsed": 168000,
|
||||
"HeapTotal": 186000,
|
||||
"External": 8200,
|
||||
"ArrayBuffers": 2000
|
||||
},
|
||||
"heapSnapshot": {
|
||||
"categories": {
|
||||
"total": 43600000,
|
||||
"code": 5123000,
|
||||
"strings": 4796000,
|
||||
"jsArrays": 4469000,
|
||||
"typedArrays": 4142000,
|
||||
"systemObjects": 3815000,
|
||||
"otherJsObjects": 3488000,
|
||||
"otherNonJsObjects": 3161000
|
||||
},
|
||||
"nodeCounts": {
|
||||
"total": 436000,
|
||||
"code": 51230,
|
||||
"strings": 47960,
|
||||
"jsArrays": 44690,
|
||||
"typedArrays": 41420,
|
||||
"systemObjects": 38150,
|
||||
"otherJsObjects": 34880,
|
||||
"otherNonJsObjects": 31610
|
||||
},
|
||||
"breakdowns": {}
|
||||
}
|
||||
}
|
||||
},
|
||||
"samples": [
|
||||
{
|
||||
"round": 1,
|
||||
"timestamp": "2026-07-18T07:57:17.654Z",
|
||||
"phases": {
|
||||
"afterGc": {
|
||||
"memoryUsage": {
|
||||
"VmRSS": 217000,
|
||||
"Pss": 217000,
|
||||
"Private_Clean": 2010,
|
||||
"Private_Dirty": 197000,
|
||||
"HeapUsed": 167000,
|
||||
"HeapTotal": 186000,
|
||||
"External": 8100,
|
||||
"ArrayBuffers": 2000
|
||||
},
|
||||
"memoryStability": {
|
||||
"converged": true,
|
||||
"readingCount": 3,
|
||||
"elapsedMs": 4000,
|
||||
"maxAbsoluteSlopesKiBPerSecond": {
|
||||
"Pss": 10,
|
||||
"Private_Dirty": 5
|
||||
}
|
||||
},
|
||||
"heapSnapshot": {
|
||||
"categories": {
|
||||
"total": 43400000,
|
||||
"code": 5099500,
|
||||
"strings": 4774000,
|
||||
"jsArrays": 4448500,
|
||||
"typedArrays": 4123000,
|
||||
"systemObjects": 3797500,
|
||||
"otherJsObjects": 3472000,
|
||||
"otherNonJsObjects": 3146500
|
||||
},
|
||||
"nodeCounts": {
|
||||
"total": 434000,
|
||||
"code": 50995,
|
||||
"strings": 47740,
|
||||
"jsArrays": 44485,
|
||||
"typedArrays": 41230,
|
||||
"systemObjects": 37975,
|
||||
"otherJsObjects": 34720,
|
||||
"otherNonJsObjects": 31465
|
||||
},
|
||||
"breakdowns": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"round": 2,
|
||||
"timestamp": "2026-07-18T07:57:17.654Z",
|
||||
"phases": {
|
||||
"afterGc": {
|
||||
"memoryUsage": {
|
||||
"VmRSS": 218000,
|
||||
"Pss": 218000,
|
||||
"Private_Clean": 2020,
|
||||
"Private_Dirty": 198000,
|
||||
"HeapUsed": 168000,
|
||||
"HeapTotal": 186000,
|
||||
"External": 8200,
|
||||
"ArrayBuffers": 2000
|
||||
},
|
||||
"memoryStability": {
|
||||
"converged": true,
|
||||
"readingCount": 3,
|
||||
"elapsedMs": 4000,
|
||||
"maxAbsoluteSlopesKiBPerSecond": {
|
||||
"Pss": 10,
|
||||
"Private_Dirty": 5
|
||||
}
|
||||
},
|
||||
"heapSnapshot": {
|
||||
"categories": {
|
||||
"total": 43600000,
|
||||
"code": 5123000,
|
||||
"strings": 4796000,
|
||||
"jsArrays": 4469000,
|
||||
"typedArrays": 4142000,
|
||||
"systemObjects": 3815000,
|
||||
"otherJsObjects": 3488000,
|
||||
"otherNonJsObjects": 3161000
|
||||
},
|
||||
"nodeCounts": {
|
||||
"total": 436000,
|
||||
"code": 51230,
|
||||
"strings": 47960,
|
||||
"jsArrays": 44690,
|
||||
"typedArrays": 41420,
|
||||
"systemObjects": 38150,
|
||||
"otherJsObjects": 34880,
|
||||
"otherNonJsObjects": 31610
|
||||
},
|
||||
"breakdowns": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"round": 3,
|
||||
"timestamp": "2026-07-18T07:57:17.654Z",
|
||||
"phases": {
|
||||
"afterGc": {
|
||||
"memoryUsage": {
|
||||
"VmRSS": 219000,
|
||||
"Pss": 219000,
|
||||
"Private_Clean": 2030,
|
||||
"Private_Dirty": 199000,
|
||||
"HeapUsed": 169000,
|
||||
"HeapTotal": 186000,
|
||||
"External": 8300,
|
||||
"ArrayBuffers": 2000
|
||||
},
|
||||
"memoryStability": {
|
||||
"converged": true,
|
||||
"readingCount": 3,
|
||||
"elapsedMs": 4000,
|
||||
"maxAbsoluteSlopesKiBPerSecond": {
|
||||
"Pss": 10,
|
||||
"Private_Dirty": 5
|
||||
}
|
||||
},
|
||||
"heapSnapshot": {
|
||||
"categories": {
|
||||
"total": 43800000,
|
||||
"code": 5146500,
|
||||
"strings": 4818000,
|
||||
"jsArrays": 4489500,
|
||||
"typedArrays": 4161000,
|
||||
"systemObjects": 3832500,
|
||||
"otherJsObjects": 3504000,
|
||||
"otherNonJsObjects": 3175500
|
||||
},
|
||||
"nodeCounts": {
|
||||
"total": 438000,
|
||||
"code": 51465,
|
||||
"strings": 48180,
|
||||
"jsArrays": 44895,
|
||||
"typedArrays": 41610,
|
||||
"systemObjects": 38325,
|
||||
"otherJsObjects": 35040,
|
||||
"otherNonJsObjects": 31755
|
||||
},
|
||||
"breakdowns": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { describe, expect, test } from 'vitest';
|
||||
import {
|
||||
clampHeapSnapshotRounds,
|
||||
selectRepresentativeHeapSnapshotRound,
|
||||
shouldCollectHeapSnapshot,
|
||||
} from '../src/heap-snapshot-sampling';
|
||||
|
||||
describe('heap snapshot sampling', () => {
|
||||
test('collects only the final requested rounds', () => {
|
||||
const selected = Array.from({ length: 15 }, (_, index) => index + 1)
|
||||
.filter(round => shouldCollectHeapSnapshot(round, 15, 3));
|
||||
expect(selected).toStrictEqual([13, 14, 15]);
|
||||
});
|
||||
|
||||
test('clamps the requested count to the measured round count', () => {
|
||||
expect(clampHeapSnapshotRounds(5, 20)).toBe(5);
|
||||
expect(Array.from({ length: 5 }, (_, index) => index + 1)
|
||||
.filter(round => shouldCollectHeapSnapshot(round, 5, 20)))
|
||||
.toStrictEqual([1, 2, 3, 4, 5]);
|
||||
});
|
||||
|
||||
test('collects no snapshots when the effective count is zero', () => {
|
||||
expect(Array.from({ length: 5 }, (_, index) => index + 1)
|
||||
.filter(round => shouldCollectHeapSnapshot(round, 5, 0)))
|
||||
.toStrictEqual([]);
|
||||
});
|
||||
|
||||
test('selects the snapshot nearest the median and ignores missing rounds', () => {
|
||||
const samples = [
|
||||
{ round: 12, total: null },
|
||||
{ round: 13, total: 100 },
|
||||
{ round: 14, total: 110 },
|
||||
{ round: 15, total: 130 },
|
||||
];
|
||||
expect(selectRepresentativeHeapSnapshotRound(samples, 110, sample => sample.total)).toBe(14);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,130 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import { expect, test } from 'vitest';
|
||||
import { pairedDeltaSummary } from 'diagnostics-shared/stats';
|
||||
import { renderMemoryReportMarkdown } from '../src/report/markdown';
|
||||
import type { MemoryReport } from '../src/types';
|
||||
|
||||
const fixturesDir = join(import.meta.dirname, 'fixtures');
|
||||
|
||||
async function loadFixture(name: string) {
|
||||
return JSON.parse(await readFile(join(fixturesDir, `${name}.json`), 'utf8')) as MemoryReport;
|
||||
}
|
||||
|
||||
function replacePssSamples(report: MemoryReport, values: number[]) {
|
||||
const templates = structuredClone(report.samples);
|
||||
report.samples = values.map((Pss, index) => {
|
||||
const sample = structuredClone(templates[index % templates.length]);
|
||||
sample.round = index + 1;
|
||||
sample.phases.afterGc.memoryUsage.Pss = Pss;
|
||||
const privateClean = sample.phases.afterGc.memoryUsage.Private_Clean;
|
||||
sample.phases.afterGc.memoryUsage.Private_Dirty = Pss - privateClean;
|
||||
return sample;
|
||||
});
|
||||
report.sampleCount = report.samples.length;
|
||||
return report;
|
||||
}
|
||||
|
||||
function findMetricRow(markdown: string, metric: string) {
|
||||
const row = markdown.split('\n').find(line => line.startsWith(`| **${metric}**`));
|
||||
if (row === undefined) throw new Error(`expected memory report to contain a ${metric} row`);
|
||||
return row;
|
||||
}
|
||||
|
||||
/**
|
||||
* 出力をゴールデンファイルで固定する。
|
||||
* 意図的に変更したときは `vitest -u` で更新し、__snapshots__ の差分もレビューすること。
|
||||
*/
|
||||
test('renders the backend memory report', async () => {
|
||||
const markdown = renderMemoryReportMarkdown(await loadFixture('base'), await loadFixture('head'), {
|
||||
baseHeapSnapshotUrl: 'https://example.invalid/base',
|
||||
headHeapSnapshotUrl: 'https://example.invalid/head',
|
||||
});
|
||||
|
||||
await expect(markdown).toMatchFileSnapshot('./__snapshots__/render-md.md');
|
||||
});
|
||||
|
||||
test('throws when filtering leaves fewer than two heap snapshots per side', async () => {
|
||||
const base = await loadFixture('base');
|
||||
const head = await loadFixture('head');
|
||||
for (const report of [base, head]) {
|
||||
for (const sample of report.samples.slice(0, -1)) {
|
||||
sample.phases.afterGc.heapSnapshot = null;
|
||||
}
|
||||
}
|
||||
|
||||
expect(() => renderMemoryReportMarkdown(base, head, {
|
||||
baseHeapSnapshotUrl: 'https://example.invalid/base',
|
||||
headHeapSnapshotUrl: 'https://example.invalid/head',
|
||||
})).toThrow('At least two samples per side are required');
|
||||
});
|
||||
|
||||
test('omits a paired-looking PSS delta that stays within observed noise', async () => {
|
||||
const base = replacePssSamples(await loadFixture('base'), [290_000, 292_900, 295_800, 298_700, 301_600]);
|
||||
const head = replacePssSamples(await loadFixture('head'), [292_900, 296_300, 298_700, 301_600, 290_000]);
|
||||
|
||||
expect(pairedDeltaSummary(
|
||||
base.samples,
|
||||
head.samples,
|
||||
sample => sample.phases.afterGc.memoryUsage.Pss,
|
||||
).median).toBe(2_900);
|
||||
|
||||
const markdown = renderMemoryReportMarkdown(base, head, {
|
||||
baseHeapSnapshotUrl: 'https://example.invalid/base',
|
||||
headHeapSnapshotUrl: 'https://example.invalid/head',
|
||||
});
|
||||
|
||||
expect(markdown).not.toContain('| **PSS** |');
|
||||
expect(markdown).toContain('<small><i>Only metrics showing significant changes are displayed.</i></small>');
|
||||
expect(markdown).not.toContain('⚠️ **Warning**: Memory usage (PSS)');
|
||||
});
|
||||
|
||||
test('keeps the convergence warning without suppressing table colour or the PSS warning', async () => {
|
||||
const base = await loadFixture('base');
|
||||
const head = await loadFixture('head');
|
||||
base.samples[0].phases.afterGc.memoryStability.converged = false;
|
||||
|
||||
const markdown = renderMemoryReportMarkdown(base, head, {
|
||||
baseHeapSnapshotUrl: 'https://example.invalid/base',
|
||||
headHeapSnapshotUrl: 'https://example.invalid/head',
|
||||
});
|
||||
const memorySection = markdown.slice(0, markdown.indexOf('### V8 Heap Snapshot Statistics'));
|
||||
|
||||
expect(memorySection).not.toContain('inconclusive');
|
||||
expect(findMetricRow(markdown, 'PSS')).toContain('\\color{orange}');
|
||||
expect(markdown).toContain('⚠️ **Measurement warning**: 1 memory sample did not converge.');
|
||||
expect(markdown).not.toContain('results are marked inconclusive');
|
||||
expect(markdown).toContain('⚠️ **Warning**: Memory usage (PSS)');
|
||||
});
|
||||
|
||||
test('throws for an undersampled memory comparison', async () => {
|
||||
const base = await loadFixture('base');
|
||||
const head = await loadFixture('head');
|
||||
base.samples = base.samples.slice(0, 1);
|
||||
head.samples = head.samples.slice(0, 1);
|
||||
|
||||
expect(() => renderMemoryReportMarkdown(base, head, {
|
||||
baseHeapSnapshotUrl: 'https://example.invalid/base',
|
||||
headHeapSnapshotUrl: 'https://example.invalid/head',
|
||||
})).toThrow('At least two samples per side are required');
|
||||
});
|
||||
|
||||
test('renders an unavailable percentage when the base median is zero', async () => {
|
||||
const base = await loadFixture('base');
|
||||
const head = await loadFixture('head');
|
||||
for (const sample of base.samples) sample.phases.afterGc.memoryUsage.External = 0;
|
||||
|
||||
const markdown = renderMemoryReportMarkdown(base, head, {
|
||||
baseHeapSnapshotUrl: 'https://example.invalid/base',
|
||||
headHeapSnapshotUrl: 'https://example.invalid/head',
|
||||
});
|
||||
|
||||
expect(findMetricRow(markdown, 'External')).toContain('<br>-');
|
||||
expect(markdown).toContain('| Metric | @ Base | @ Head | Δ | MAD |');
|
||||
expect(markdown).not.toContain('| Metric | @ Base | @ Head | Δ | MAD | Result |');
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { EventEmitter } from 'node:events';
|
||||
import type { ChildProcess } from 'node:child_process';
|
||||
import { describe, expect, test } from 'vitest';
|
||||
import { waitForMessage } from '../src/measure/server';
|
||||
|
||||
/** waitForMessage が使うのは message/exit/error/disconnect の購読だけなので EventEmitter で足りる */
|
||||
function createFakeServer() {
|
||||
return new EventEmitter() as unknown as ChildProcess;
|
||||
}
|
||||
|
||||
function isPing(message: unknown): message is 'ping' {
|
||||
return message === 'ping';
|
||||
}
|
||||
|
||||
describe('waitForMessage', () => {
|
||||
test('resolves with the first matching message', async () => {
|
||||
const server = createFakeServer();
|
||||
const received = waitForMessage(server, isPing, 'ping', 1_000);
|
||||
|
||||
server.emit('message', 'noise');
|
||||
server.emit('message', 'ping');
|
||||
|
||||
await expect(received).resolves.toBe('ping');
|
||||
});
|
||||
|
||||
// 子が死んだあとメッセージは来ないので、タイムアウトまで待たずに理由を添えて失敗させる
|
||||
test('rejects immediately when the server exits', async () => {
|
||||
const server = createFakeServer();
|
||||
const received = waitForMessage(server, isPing, 'ping', 60_000);
|
||||
|
||||
server.emit('exit', 1, null);
|
||||
|
||||
await expect(received).rejects.toThrow(/Server exited \(code=1, signal=null\) while waiting for ping/);
|
||||
});
|
||||
|
||||
test('rejects immediately when the server errors', async () => {
|
||||
const server = createFakeServer();
|
||||
const received = waitForMessage(server, isPing, 'ping', 60_000);
|
||||
|
||||
server.emit('error', new Error('spawn failed'));
|
||||
|
||||
await expect(received).rejects.toThrow(/spawn failed/);
|
||||
});
|
||||
|
||||
test('rejects immediately when the IPC channel closes', async () => {
|
||||
const server = createFakeServer();
|
||||
const received = waitForMessage(server, isPing, 'ping', 60_000);
|
||||
|
||||
server.emit('disconnect');
|
||||
|
||||
await expect(received).rejects.toThrow(/IPC channel closed/);
|
||||
});
|
||||
|
||||
test('rejects on timeout', async () => {
|
||||
const server = createFakeServer();
|
||||
await expect(waitForMessage(server, isPing, 'ping', 1)).rejects.toThrow(/Timed out waiting for ping/);
|
||||
});
|
||||
|
||||
// 待機が終わった後もリスナーが残っていると、ラウンドを重ねるごとに積み上がる
|
||||
test('removes every listener once settled', async () => {
|
||||
const server = createFakeServer();
|
||||
const emitter = server as unknown as EventEmitter;
|
||||
const received = waitForMessage(server, isPing, 'ping', 1_000);
|
||||
|
||||
server.emit('message', 'ping');
|
||||
await received;
|
||||
|
||||
for (const event of ['message', 'exit', 'error', 'disconnect']) {
|
||||
expect(emitter.listenerCount(event)).toBe(0);
|
||||
}
|
||||
});
|
||||
});
|
||||
+14
-15
@@ -3,9 +3,8 @@
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { measureMemoryUntilStable } from './memory-stability-util.mts';
|
||||
import { expect, test } from 'vitest';
|
||||
import { measureMemoryUntilStable } from '../src/measure/stability';
|
||||
|
||||
function createTimer() {
|
||||
let elapsedMs = 0;
|
||||
@@ -35,9 +34,9 @@ test('adopts the latest reading once Pss and Private_Dirty slopes converge', asy
|
||||
];
|
||||
const { result, readCount } = await measure(readings);
|
||||
|
||||
assert.equal(readCount, 3);
|
||||
assert.deepEqual(result.memoryUsage, readings[2]);
|
||||
assert.deepEqual(result.stability, {
|
||||
expect(readCount).toBe(3);
|
||||
expect(result.memoryUsage).toStrictEqual(readings[2]);
|
||||
expect(result.stability).toStrictEqual({
|
||||
converged: true,
|
||||
readingCount: 3,
|
||||
elapsedMs: 4000,
|
||||
@@ -58,9 +57,9 @@ test('uses only the latest readings when determining convergence', async () => {
|
||||
];
|
||||
const { result, readCount } = await measure(readings);
|
||||
|
||||
assert.equal(readCount, 5);
|
||||
assert.equal(result.stability.converged, true);
|
||||
assert.deepEqual(result.stability.maxAbsoluteSlopesKiBPerSecond, {
|
||||
expect(readCount).toBe(5);
|
||||
expect(result.stability.converged).toBe(true);
|
||||
expect(result.stability.maxAbsoluteSlopesKiBPerSecond).toStrictEqual({
|
||||
Pss: 20,
|
||||
Private_Dirty: 10,
|
||||
});
|
||||
@@ -77,9 +76,9 @@ test('bounds the wait and reports the latest slopes when memory does not converg
|
||||
];
|
||||
const { result, readCount } = await measure(readings);
|
||||
|
||||
assert.equal(readCount, 6);
|
||||
assert.deepEqual(result.memoryUsage, readings[5]);
|
||||
assert.deepEqual(result.stability, {
|
||||
expect(readCount).toBe(6);
|
||||
expect(result.memoryUsage).toStrictEqual(readings[5]);
|
||||
expect(result.stability).toStrictEqual({
|
||||
converged: false,
|
||||
readingCount: 6,
|
||||
elapsedMs: 10000,
|
||||
@@ -101,9 +100,9 @@ test('does not treat opposing adjacent slopes as convergence', async () => {
|
||||
];
|
||||
const { result, readCount } = await measure(readings);
|
||||
|
||||
assert.equal(readCount, 6);
|
||||
assert.equal(result.stability.converged, false);
|
||||
assert.deepEqual(result.stability.maxAbsoluteSlopesKiBPerSecond, {
|
||||
expect(readCount).toBe(6);
|
||||
expect(result.stability.converged).toBe(false);
|
||||
expect(result.stability.maxAbsoluteSlopesKiBPerSecond).toStrictEqual({
|
||||
Pss: 300,
|
||||
Private_Dirty: 0,
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"compilerOptions": {
|
||||
"target": "ESNext",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true,
|
||||
"strictFunctionTypes": true,
|
||||
"strictNullChecks": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"noEmit": true,
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": [
|
||||
"src/**/*.ts",
|
||||
"test/**/*.ts"
|
||||
],
|
||||
"exclude": []
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import tsParser from '@typescript-eslint/parser';
|
||||
import sharedConfig from '../../packages/shared/eslint.config.js';
|
||||
|
||||
// eslint-disable-next-line import/no-default-export
|
||||
export default [
|
||||
...sharedConfig,
|
||||
{
|
||||
ignores: [
|
||||
'**/node_modules',
|
||||
'**/__snapshots__',
|
||||
'test/fixtures',
|
||||
],
|
||||
},
|
||||
{
|
||||
files: ['**/*.ts', '**/*.tsx'],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
parser: tsParser,
|
||||
project: ['./tsconfig.json'],
|
||||
sourceType: 'module',
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "diagnostics-frontend",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"eslint": "eslint './**/*.{js,jsx,ts,tsx}'",
|
||||
"inspect-browser": "tsx src/inspect-browser.ts",
|
||||
"lint": "pnpm typecheck && pnpm eslint",
|
||||
"render-browser-html": "tsx src/render-browser-html.ts",
|
||||
"render-md": "tsx src/render-md.ts",
|
||||
"test": "vitest run",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"diagnostics-shared": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "26.1.1",
|
||||
"frontend": "workspace:*",
|
||||
"playwright": "1.61.1",
|
||||
"tsx": "4.23.1",
|
||||
"typescript": "5.9.3",
|
||||
"vitest": "4.1.10"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { writeFile } from 'node:fs/promises';
|
||||
import { chromium } from 'playwright';
|
||||
import { enableNetworkTracking, type NetworkTracker } from './network';
|
||||
import type { Browser, BrowserContext, CDPSession, Page } from 'playwright';
|
||||
import type { BrowserDiagnostics, BrowserMeasurement, NetworkRequest, TabMemory, WebSocketConnection } from './types';
|
||||
|
||||
export type HeadlessChromeOptions = {
|
||||
scenarioTimeoutMs: number;
|
||||
baseUrl: string;
|
||||
};
|
||||
|
||||
export class HeadlessChromeController {
|
||||
private readonly diagnostics = {
|
||||
pageErrorCount: 0,
|
||||
console: {} as Record<string, number | undefined>,
|
||||
};
|
||||
private readonly browser: Browser;
|
||||
private readonly context: BrowserContext;
|
||||
public readonly page: Page;
|
||||
private readonly cdp: CDPSession;
|
||||
private networkTracker: NetworkTracker | null = null;
|
||||
|
||||
private constructor(
|
||||
browser: Browser,
|
||||
context: BrowserContext,
|
||||
page: Page,
|
||||
cdp: CDPSession,
|
||||
options: HeadlessChromeOptions,
|
||||
) {
|
||||
this.browser = browser;
|
||||
this.context = context;
|
||||
this.page = page;
|
||||
this.cdp = cdp;
|
||||
this.page.setDefaultTimeout(options.scenarioTimeoutMs);
|
||||
this.page.setDefaultNavigationTimeout(options.scenarioTimeoutMs);
|
||||
this.page.on('pageerror', () => {
|
||||
this.diagnostics.pageErrorCount++;
|
||||
});
|
||||
this.page.on('console', message => {
|
||||
const type = message.type();
|
||||
this.diagnostics.console[type] = (this.diagnostics.console[type] ?? 0) + 1;
|
||||
});
|
||||
}
|
||||
|
||||
public get networkRequests(): NetworkRequest[] {
|
||||
return this.networkTracker?.networkRequests ?? [];
|
||||
}
|
||||
|
||||
public get webSocketConnections(): WebSocketConnection[] {
|
||||
return this.networkTracker?.webSocketConnections ?? [];
|
||||
}
|
||||
|
||||
static async create(label: string, options: HeadlessChromeOptions): Promise<HeadlessChromeController> {
|
||||
process.stderr.write(`[${label}] Launching Playwright Chromium\n`);
|
||||
const browser = await chromium.launch({
|
||||
channel: 'chromium',
|
||||
headless: true,
|
||||
args: [
|
||||
'--disable-gpu',
|
||||
'--disable-dev-shm-usage',
|
||||
'--disable-background-networking',
|
||||
'--disable-default-apps',
|
||||
'--disable-extensions',
|
||||
'--disable-sync',
|
||||
'--metrics-recording-only',
|
||||
'--no-first-run',
|
||||
'--no-default-browser-check',
|
||||
'--no-sandbox',
|
||||
],
|
||||
});
|
||||
|
||||
try {
|
||||
const context = await browser.newContext({
|
||||
baseURL: options.baseUrl,
|
||||
locale: 'en-US',
|
||||
});
|
||||
await context.addInitScript(() => {
|
||||
// @ts-expect-error Test-only runtime hint consumed by Misskey frontend code.
|
||||
window.isPlaywright = true;
|
||||
});
|
||||
|
||||
const page = await context.newPage();
|
||||
const cdp = await context.newCDPSession(page);
|
||||
return new HeadlessChromeController(browser, context, page, cdp, options);
|
||||
} catch (error) {
|
||||
await browser.close().catch(() => undefined);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
static async with<T>(label: string, options: HeadlessChromeOptions, callback: (browser: HeadlessChromeController) => T | Promise<T>): Promise<T> {
|
||||
const browser = await HeadlessChromeController.create(label, options);
|
||||
try {
|
||||
return await callback(browser);
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
}
|
||||
|
||||
public async enableNetworkTracking() {
|
||||
this.networkTracker = await enableNetworkTracking(this.cdp);
|
||||
}
|
||||
|
||||
public async waitForNetworkDetails() {
|
||||
await this.networkTracker?.waitForDetails();
|
||||
}
|
||||
|
||||
public async evaluate<T>(expression: string, timeoutMs = 30_000): Promise<T> {
|
||||
return await Promise.race([
|
||||
this.page.evaluate(expression),
|
||||
new Promise<never>((_, reject) => setTimeout(() => reject(new Error(`Playwright evaluate timed out after ${timeoutMs}ms`)), timeoutMs).unref()),
|
||||
]) as T;
|
||||
}
|
||||
|
||||
public collectDiagnostics(): BrowserDiagnostics {
|
||||
return {
|
||||
pageErrorCount: this.diagnostics.pageErrorCount,
|
||||
console: {
|
||||
log: this.diagnostics.console.log ?? 0,
|
||||
warning: this.diagnostics.console.warning ?? 0,
|
||||
error: this.diagnostics.console.error ?? 0,
|
||||
info: this.diagnostics.console.info ?? 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
public async collectPerformance(): Promise<BrowserMeasurement['performance']> {
|
||||
const cdpMetricsResult = await this.cdp.send('Performance.getMetrics');
|
||||
const cdpMetrics = Object.fromEntries(cdpMetricsResult.metrics.map(metric => [metric.name, metric.value]));
|
||||
const runtimeHeap = await this.cdp.send('Runtime.getHeapUsage').catch(() => undefined);
|
||||
const tabMemory = await this.collectTabMemory();
|
||||
const webVitals = await this.evaluate<BrowserMeasurement['performance']['webVitals']>(`(() => {
|
||||
const navigation = performance.getEntriesByType('navigation')[0];
|
||||
const paintEntries = Object.fromEntries(performance.getEntriesByType('paint').map(entry => [entry.name, entry.startTime]));
|
||||
const longTasks = performance.getEntriesByType('longtask');
|
||||
const resourceEntries = performance.getEntriesByType('resource');
|
||||
return {
|
||||
firstPaintMs: paintEntries['first-paint'],
|
||||
firstContentfulPaintMs: paintEntries['first-contentful-paint'],
|
||||
domContentLoadedEventEndMs: navigation?.domContentLoadedEventEnd,
|
||||
loadEventEndMs: navigation?.loadEventEnd,
|
||||
longTaskCount: longTasks.length,
|
||||
longTaskDurationMs: longTasks.reduce((sum, entry) => sum + entry.duration, 0),
|
||||
maxLongTaskDurationMs: longTasks.reduce((max, entry) => Math.max(max, entry.duration), 0),
|
||||
resourceEntryCount: resourceEntries.length,
|
||||
domElements: document.getElementsByTagName('*').length,
|
||||
};
|
||||
})()`);
|
||||
|
||||
return {
|
||||
cdpMetrics,
|
||||
runtimeHeap,
|
||||
tabMemory,
|
||||
webVitals,
|
||||
};
|
||||
}
|
||||
|
||||
public async collectTabMemory(): Promise<TabMemory> {
|
||||
const userAgentSpecificMemory = await this.evaluate<{ bytes?: number }>(`(async () => {
|
||||
const measureMemory = performance.measureUserAgentSpecificMemory;
|
||||
if (typeof measureMemory !== 'function') return {};
|
||||
const result = await measureMemory.call(performance);
|
||||
return { bytes: result.bytes };
|
||||
})()`, 60_000);
|
||||
|
||||
const userAgentSpecificBytes = userAgentSpecificMemory?.bytes;
|
||||
if (!Number.isFinite(userAgentSpecificBytes)) {
|
||||
throw new Error('performance.measureUserAgentSpecificMemory() did not return finite bytes');
|
||||
}
|
||||
|
||||
return {
|
||||
totalBytes: userAgentSpecificBytes as number,
|
||||
};
|
||||
}
|
||||
|
||||
public async takeHeapSnapshot(savePath?: string) {
|
||||
const chunks: string[] = [];
|
||||
const onChunk = (params: { chunk: string }) => {
|
||||
chunks.push(params.chunk);
|
||||
};
|
||||
this.cdp.on('HeapProfiler.addHeapSnapshotChunk', onChunk);
|
||||
|
||||
let content: string;
|
||||
try {
|
||||
await this.cdp.send('HeapProfiler.enable');
|
||||
await this.cdp.send('HeapProfiler.collectGarbage');
|
||||
await this.cdp.send('HeapProfiler.takeHeapSnapshot', { reportProgress: false });
|
||||
content = chunks.join('');
|
||||
} finally {
|
||||
// 外さないとラウンドごとに積み上がり、古い配列にチャンクを流し続ける
|
||||
this.cdp.off('HeapProfiler.addHeapSnapshotChunk', onChunk);
|
||||
}
|
||||
|
||||
if (savePath != null) {
|
||||
await writeFile(savePath, content);
|
||||
}
|
||||
|
||||
return JSON.parse(content);
|
||||
}
|
||||
|
||||
public async close() {
|
||||
await this.cdp.detach().catch(() => undefined);
|
||||
await this.context.close().catch(() => undefined);
|
||||
await this.browser.close().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { median } from 'diagnostics-shared/stats';
|
||||
import type { BrowserDiagnostics } from './types';
|
||||
|
||||
export function summarizeBrowserDiagnostics(samples: BrowserDiagnostics[]): BrowserDiagnostics {
|
||||
const medianOf = (select: (sample: BrowserDiagnostics) => number) => median(samples.map(select));
|
||||
|
||||
return {
|
||||
pageErrorCount: medianOf(sample => sample.pageErrorCount),
|
||||
console: {
|
||||
log: medianOf(sample => sample.console.log),
|
||||
warning: medianOf(sample => sample.console.warning),
|
||||
error: medianOf(sample => sample.console.error),
|
||||
info: medianOf(sample => sample.console.info),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import type { CDPSession } from 'playwright';
|
||||
import type { NetworkRequest, NetworkSummary, WebSocketConnection } from './types';
|
||||
|
||||
function normalizeHeaders(headers: Record<string, unknown> | undefined) {
|
||||
if (headers == null) return undefined;
|
||||
const normalized = {} as Record<string, string>;
|
||||
for (const [key, value] of Object.entries(headers)) {
|
||||
normalized[key] = String(value);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function webSocketFramePayloadBytes(frame: { opcode?: number; payloadData?: string } | undefined) {
|
||||
if (frame?.payloadData == null) return 0;
|
||||
// opcode 1 はテキストフレームで、それ以外はbase64エンコードされたバイナリとして届く
|
||||
if (frame.opcode === 1) return Buffer.byteLength(frame.payloadData, 'utf8');
|
||||
return Buffer.byteLength(frame.payloadData, 'base64');
|
||||
}
|
||||
|
||||
export type NetworkTracker = {
|
||||
networkRequests: NetworkRequest[];
|
||||
webSocketConnections: WebSocketConnection[];
|
||||
/** CDPへの追加問い合わせ (postDataの取得) が全て決着するまで待つ */
|
||||
waitForDetails: () => Promise<void>;
|
||||
};
|
||||
|
||||
/**
|
||||
* CDPのNetworkドメインを有効化し、リクエスト/WebSocketの生ログを収集し続けるトラッカーを返す。
|
||||
*/
|
||||
export async function enableNetworkTracking(cdp: CDPSession): Promise<NetworkTracker> {
|
||||
const networkRequests: NetworkRequest[] = [];
|
||||
const webSocketConnections: WebSocketConnection[] = [];
|
||||
const requests = new Map<string, NetworkRequest>();
|
||||
const webSockets = new Map<string, WebSocketConnection>();
|
||||
const pendingDetailReads: Promise<void>[] = [];
|
||||
|
||||
const readRequestBody = (row: NetworkRequest) => {
|
||||
if (!row.hasRequestBody || row.requestBody != null) return;
|
||||
const pending = cdp.send('Network.getRequestPostData', {
|
||||
requestId: row.requestId,
|
||||
}).then(result => {
|
||||
row.requestBody = result.postData;
|
||||
}).catch(() => {
|
||||
// Some requests expose hasPostData but no longer have retrievable body data.
|
||||
});
|
||||
pendingDetailReads.push(pending);
|
||||
};
|
||||
|
||||
cdp.on('Network.requestWillBeSent', params => {
|
||||
if (params.request?.url == null) return;
|
||||
const row: NetworkRequest = {
|
||||
requestId: params.requestId,
|
||||
url: params.request.url,
|
||||
method: params.request.method ?? 'GET',
|
||||
resourceType: params.type ?? 'Other',
|
||||
startedAt: params.timestamp ?? 0,
|
||||
documentUrl: params.documentURL,
|
||||
requestHeaders: normalizeHeaders(params.request.headers),
|
||||
requestBody: typeof params.request.postData === 'string' ? params.request.postData : undefined,
|
||||
hasRequestBody: params.request.hasPostData === true || typeof params.request.postData === 'string',
|
||||
encodedDataLength: 0,
|
||||
decodedBodyLength: 0,
|
||||
fromDiskCache: false,
|
||||
fromServiceWorker: false,
|
||||
finished: false,
|
||||
failed: false,
|
||||
};
|
||||
requests.set(params.requestId, row);
|
||||
networkRequests.push(row);
|
||||
});
|
||||
|
||||
cdp.on('Network.webSocketCreated', params => {
|
||||
if (params.requestId == null || params.url == null) return;
|
||||
const row: WebSocketConnection = {
|
||||
requestId: params.requestId,
|
||||
url: params.url,
|
||||
// Network.webSocketCreated はtimestampを持たないので、closedAt との差分は取れない
|
||||
createdAt: 0,
|
||||
sentFrameCount: 0,
|
||||
receivedFrameCount: 0,
|
||||
sentBytes: 0,
|
||||
receivedBytes: 0,
|
||||
errorCount: 0,
|
||||
};
|
||||
webSockets.set(params.requestId, row);
|
||||
webSocketConnections.push(row);
|
||||
});
|
||||
|
||||
cdp.on('Network.webSocketWillSendHandshakeRequest', params => {
|
||||
const row = webSockets.get(params.requestId);
|
||||
if (row == null) return;
|
||||
row.handshakeRequestHeaders = normalizeHeaders(params.request?.headers);
|
||||
});
|
||||
|
||||
cdp.on('Network.webSocketHandshakeResponseReceived', params => {
|
||||
const row = webSockets.get(params.requestId);
|
||||
if (row == null) return;
|
||||
row.handshakeResponseStatus = params.response?.status;
|
||||
row.handshakeResponseStatusText = params.response?.statusText;
|
||||
row.handshakeResponseHeaders = normalizeHeaders(params.response?.headers);
|
||||
});
|
||||
|
||||
cdp.on('Network.webSocketFrameSent', params => {
|
||||
const row = webSockets.get(params.requestId);
|
||||
if (row == null) return;
|
||||
row.sentFrameCount += 1;
|
||||
row.sentBytes += webSocketFramePayloadBytes(params.response);
|
||||
});
|
||||
|
||||
cdp.on('Network.webSocketFrameReceived', params => {
|
||||
const row = webSockets.get(params.requestId);
|
||||
if (row == null) return;
|
||||
row.receivedFrameCount += 1;
|
||||
row.receivedBytes += webSocketFramePayloadBytes(params.response);
|
||||
});
|
||||
|
||||
cdp.on('Network.webSocketFrameError', params => {
|
||||
const row = webSockets.get(params.requestId);
|
||||
if (row == null) return;
|
||||
row.errorCount += 1;
|
||||
});
|
||||
|
||||
cdp.on('Network.webSocketClosed', params => {
|
||||
const row = webSockets.get(params.requestId);
|
||||
if (row == null) return;
|
||||
row.closedAt = params.timestamp ?? 0;
|
||||
});
|
||||
|
||||
cdp.on('Network.responseReceived', params => {
|
||||
const row = requests.get(params.requestId);
|
||||
if (row == null) return;
|
||||
row.status = params.response?.status;
|
||||
row.statusText = params.response?.statusText;
|
||||
row.mimeType = params.response?.mimeType;
|
||||
row.responseHeaders = normalizeHeaders(params.response?.headers);
|
||||
row.protocol = params.response?.protocol;
|
||||
row.remoteIPAddress = params.response?.remoteIPAddress;
|
||||
row.remotePort = params.response?.remotePort;
|
||||
row.requestHeaders ??= normalizeHeaders(params.response?.requestHeaders);
|
||||
row.fromDiskCache = params.response?.fromDiskCache === true;
|
||||
row.fromServiceWorker = params.response?.fromServiceWorker === true;
|
||||
});
|
||||
|
||||
cdp.on('Network.dataReceived', params => {
|
||||
const row = requests.get(params.requestId);
|
||||
if (row == null) return;
|
||||
row.decodedBodyLength += params.dataLength ?? 0;
|
||||
row.encodedDataLength += params.encodedDataLength ?? 0;
|
||||
});
|
||||
|
||||
cdp.on('Network.loadingFinished', params => {
|
||||
const row = requests.get(params.requestId);
|
||||
if (row == null) return;
|
||||
row.finished = true;
|
||||
row.encodedDataLength = Math.max(row.encodedDataLength, params.encodedDataLength ?? 0);
|
||||
readRequestBody(row);
|
||||
});
|
||||
|
||||
cdp.on('Network.loadingFailed', params => {
|
||||
const row = requests.get(params.requestId);
|
||||
if (row == null) return;
|
||||
row.failed = true;
|
||||
row.finished = true;
|
||||
row.errorText = params.errorText;
|
||||
readRequestBody(row);
|
||||
});
|
||||
|
||||
await cdp.send('Network.enable');
|
||||
await cdp.send('Network.setCacheDisabled', { cacheDisabled: true });
|
||||
await cdp.send('Network.setBypassServiceWorker', { bypass: true });
|
||||
await cdp.send('Page.enable');
|
||||
await cdp.send('Runtime.enable');
|
||||
await cdp.send('Performance.enable');
|
||||
|
||||
return {
|
||||
networkRequests,
|
||||
webSocketConnections,
|
||||
waitForDetails: async () => {
|
||||
// 待っている最中にさらにpendingが増えることがあるので、増えなくなるまで繰り返す
|
||||
let settledCount = 0;
|
||||
while (settledCount < pendingDetailReads.length) {
|
||||
const pending = pendingDetailReads.slice(settledCount);
|
||||
settledCount = pendingDetailReads.length;
|
||||
await Promise.allSettled(pending);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function isMeasurableRequest(row: NetworkRequest) {
|
||||
return !row.url.startsWith('data:') && !row.url.startsWith('blob:') && !row.url.startsWith('devtools:');
|
||||
}
|
||||
|
||||
export function summarizeNetwork(requestRows: NetworkRequest[], baseUrl: string, webSocketRows?: WebSocketConnection[]): NetworkSummary {
|
||||
const origin = new URL(baseUrl).origin;
|
||||
const rows = requestRows.filter(isMeasurableRequest);
|
||||
const byResourceType = {} as NetworkSummary['byResourceType'];
|
||||
|
||||
for (const row of rows) {
|
||||
const summary = byResourceType[row.resourceType] ?? {
|
||||
requests: 0,
|
||||
encodedBytes: 0,
|
||||
decodedBodyBytes: 0,
|
||||
};
|
||||
summary.requests += 1;
|
||||
summary.encodedBytes += row.encodedDataLength;
|
||||
summary.decodedBodyBytes += row.decodedBodyLength;
|
||||
byResourceType[row.resourceType] = summary;
|
||||
}
|
||||
|
||||
function isSameOrigin(url: string) {
|
||||
try {
|
||||
return new URL(url).origin === origin;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
requestCount: rows.length,
|
||||
webSocketConnectionCount: webSocketRows == null
|
||||
? rows.filter(row => row.resourceType === 'WebSocket').length
|
||||
: webSocketRows.length,
|
||||
webSocketSentBytes: webSocketRows?.reduce((sum, row) => sum + row.sentBytes, 0) ?? 0,
|
||||
webSocketReceivedBytes: webSocketRows?.reduce((sum, row) => sum + row.receivedBytes, 0) ?? 0,
|
||||
finishedRequestCount: rows.filter(row => row.finished).length,
|
||||
failedRequestCount: rows.filter(row => row.failed).length,
|
||||
cachedRequestCount: rows.filter(row => row.fromDiskCache).length,
|
||||
serviceWorkerRequestCount: rows.filter(row => row.fromServiceWorker).length,
|
||||
totalEncodedBytes: rows.reduce((sum, row) => sum + row.encodedDataLength, 0),
|
||||
totalDecodedBodyBytes: rows.reduce((sum, row) => sum + row.decodedBodyLength, 0),
|
||||
sameOriginEncodedBytes: rows
|
||||
.filter(row => isSameOrigin(row.url))
|
||||
.reduce((sum, row) => sum + row.encodedDataLength, 0),
|
||||
thirdPartyEncodedBytes: rows
|
||||
.filter(row => !isSameOrigin(row.url))
|
||||
.reduce((sum, row) => sum + row.encodedDataLength, 0),
|
||||
byResourceType,
|
||||
largestRequests: rows
|
||||
.toSorted((a, b) => b.encodedDataLength - a.encodedDataLength)
|
||||
.slice(0, 15)
|
||||
.map(row => ({
|
||||
url: row.url,
|
||||
method: row.method,
|
||||
resourceType: row.resourceType,
|
||||
status: row.status,
|
||||
encodedBytes: row.encodedDataLength,
|
||||
decodedBodyBytes: row.decodedBodyLength,
|
||||
})),
|
||||
failedRequests: rows
|
||||
.filter(row => row.failed)
|
||||
.map(row => ({
|
||||
url: row.url,
|
||||
method: row.method,
|
||||
resourceType: row.resourceType,
|
||||
errorText: row.errorText,
|
||||
status: row.status,
|
||||
})),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
/** 差分HTMLは単体のファイルとして配布されるので、CSSも埋め込みで持つ */
|
||||
export const networkDiffHtmlStyles = ` :root {
|
||||
color-scheme: light dark;
|
||||
--bg: #f7f7f8;
|
||||
--fg: #202124;
|
||||
--muted: #5f6368;
|
||||
--card: #ffffff;
|
||||
--border: #dfe1e5;
|
||||
--added: #137333;
|
||||
--added-bg: #e6f4ea;
|
||||
--removed: #a50e0e;
|
||||
--removed-bg: #fce8e6;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--bg: #111315;
|
||||
--fg: #e8eaed;
|
||||
--muted: #bdc1c6;
|
||||
--card: #1b1d20;
|
||||
--border: #3c4043;
|
||||
--added-bg: #17351f;
|
||||
--removed-bg: #3c1f1d;
|
||||
}
|
||||
}
|
||||
body {
|
||||
margin: 0;
|
||||
font: 14px/1.5 system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--fg);
|
||||
}
|
||||
main {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 24px;
|
||||
}
|
||||
h1 {
|
||||
font-size: 24px;
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
h2 {
|
||||
font-size: 18px;
|
||||
margin: 32px 0 8px;
|
||||
}
|
||||
.meta {
|
||||
color: var(--muted);
|
||||
margin: 0 0 24px;
|
||||
}
|
||||
.summary {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
|
||||
gap: 12px;
|
||||
margin: 24px 0;
|
||||
}
|
||||
.summary > div, .request, table {
|
||||
background: var(--card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
}
|
||||
.summary > div {
|
||||
padding: 14px;
|
||||
}
|
||||
.label {
|
||||
display: block;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
.summary strong {
|
||||
display: block;
|
||||
font-size: 24px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
.added-text {
|
||||
color: var(--added);
|
||||
}
|
||||
.removed-text {
|
||||
color: var(--removed);
|
||||
}
|
||||
table {
|
||||
border-collapse: collapse;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
th, td {
|
||||
border-bottom: 1px solid var(--border);
|
||||
padding: 8px 10px;
|
||||
text-align: left;
|
||||
}
|
||||
th {
|
||||
color: var(--muted);
|
||||
font-weight: 600;
|
||||
}
|
||||
.num {
|
||||
text-align: right;
|
||||
}
|
||||
.requests {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
.request {
|
||||
padding: 14px;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.request.added {
|
||||
border-left: 4px solid var(--added);
|
||||
}
|
||||
.request.removed {
|
||||
border-left: 4px solid var(--removed);
|
||||
}
|
||||
.request header {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.badge, .method, .type, .status {
|
||||
border-radius: 999px;
|
||||
padding: 2px 8px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.added .badge {
|
||||
background: var(--added-bg);
|
||||
color: var(--added);
|
||||
}
|
||||
.removed .badge {
|
||||
background: var(--removed-bg);
|
||||
color: var(--removed);
|
||||
}
|
||||
.method, .type, .status {
|
||||
background: color-mix(in srgb, var(--muted) 14%, transparent);
|
||||
color: var(--fg);
|
||||
}
|
||||
.url {
|
||||
display: block;
|
||||
margin: 8px 0 12px;
|
||||
color: inherit;
|
||||
font-family: ui-monospace, SFMono-Regular, Consolas, "Liberation Mono", monospace;
|
||||
}
|
||||
dl {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
||||
gap: 8px 16px;
|
||||
margin: 0 0 12px;
|
||||
}
|
||||
dl div {
|
||||
min-width: 0;
|
||||
}
|
||||
dt {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
dd {
|
||||
margin: 0;
|
||||
}
|
||||
details {
|
||||
margin-top: 8px;
|
||||
}
|
||||
summary {
|
||||
cursor: pointer;
|
||||
color: var(--muted);
|
||||
}
|
||||
pre {
|
||||
white-space: pre-wrap;
|
||||
overflow-x: auto;
|
||||
background: color-mix(in srgb, var(--muted) 10%, transparent);
|
||||
border-radius: 6px;
|
||||
padding: 10px;
|
||||
}
|
||||
.empty {
|
||||
color: var(--muted);
|
||||
}`;
|
||||
@@ -0,0 +1,255 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { formatBytes, formatNumber } from 'diagnostics-shared/format';
|
||||
import { type Raw, html, joinHtml, raw } from 'diagnostics-shared/html';
|
||||
import { networkDiffHtmlStyles } from './html-styles';
|
||||
import type { BrowserMeasurementSample, BrowserMetricsReport, NetworkRequest } from '../types';
|
||||
|
||||
type DiffDirection = 'added' | 'removed';
|
||||
|
||||
type RequestDiff = {
|
||||
direction: DiffDirection;
|
||||
round: number;
|
||||
baseCount: number;
|
||||
headCount: number;
|
||||
request: NetworkRequest;
|
||||
};
|
||||
|
||||
function isHttpRequest(request: NetworkRequest) {
|
||||
try {
|
||||
const { protocol } = new URL(request.url);
|
||||
return protocol === 'http:' || protocol === 'https:';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function requestKey(request: NetworkRequest) {
|
||||
// URLに現れない文字で区切らないと、区切り文字を含むURLが別のキーと衝突しうる
|
||||
return [
|
||||
request.method,
|
||||
request.resourceType,
|
||||
request.url,
|
||||
].join('\u0000');
|
||||
}
|
||||
|
||||
function groupRequests(requests: NetworkRequest[] | undefined) {
|
||||
const grouped = new Map<string, NetworkRequest[]>();
|
||||
for (const request of requests ?? []) {
|
||||
if (!isHttpRequest(request)) continue;
|
||||
const key = requestKey(request);
|
||||
const rows = grouped.get(key) ?? [];
|
||||
rows.push(request);
|
||||
grouped.set(key, rows);
|
||||
}
|
||||
return grouped;
|
||||
}
|
||||
|
||||
function byRound(samples: BrowserMeasurementSample[]) {
|
||||
return new Map(samples.map(sample => [sample.round, sample]));
|
||||
}
|
||||
|
||||
/**
|
||||
* 同じラウンドどうしで、同一 (method, resourceType, URL) のリクエスト本数を突き合わせる。
|
||||
* 本数が増えていればhead側の増分を added、減っていればbase側の余りを removed として扱う。
|
||||
*/
|
||||
function diffRound(round: number, baseSample: BrowserMeasurementSample | undefined, headSample: BrowserMeasurementSample | undefined) {
|
||||
const baseRequests = groupRequests(baseSample?.networkRequests);
|
||||
const headRequests = groupRequests(headSample?.networkRequests);
|
||||
const keys = [...new Set([
|
||||
...baseRequests.keys(),
|
||||
...headRequests.keys(),
|
||||
])].toSorted();
|
||||
const diffs: RequestDiff[] = [];
|
||||
|
||||
for (const key of keys) {
|
||||
const baseRows = baseRequests.get(key) ?? [];
|
||||
const headRows = headRequests.get(key) ?? [];
|
||||
if (headRows.length > baseRows.length) {
|
||||
for (const request of headRows.slice(baseRows.length)) {
|
||||
diffs.push({
|
||||
direction: 'added',
|
||||
round,
|
||||
baseCount: baseRows.length,
|
||||
headCount: headRows.length,
|
||||
request,
|
||||
});
|
||||
}
|
||||
} else if (baseRows.length > headRows.length) {
|
||||
for (const request of baseRows.slice(headRows.length)) {
|
||||
diffs.push({
|
||||
direction: 'removed',
|
||||
round,
|
||||
baseCount: baseRows.length,
|
||||
headCount: headRows.length,
|
||||
request,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return diffs;
|
||||
}
|
||||
|
||||
function diffReports(base: BrowserMetricsReport, head: BrowserMetricsReport) {
|
||||
const baseSamples = byRound(base.samples);
|
||||
const headSamples = byRound(head.samples);
|
||||
const rounds = [...new Set([
|
||||
...baseSamples.keys(),
|
||||
...headSamples.keys(),
|
||||
])].toSorted((a, b) => a - b);
|
||||
return rounds.flatMap(round => diffRound(round, baseSamples.get(round), headSamples.get(round)));
|
||||
}
|
||||
|
||||
function formatMaybeJson(value: string | undefined) {
|
||||
if (value == null || value === '') return null;
|
||||
try {
|
||||
return JSON.stringify(JSON.parse(value), null, '\t');
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
function formatHeaders(headers: Record<string, string> | undefined) {
|
||||
if (headers == null || Object.keys(headers).length === 0) return null;
|
||||
return JSON.stringify(headers, null, '\t');
|
||||
}
|
||||
|
||||
function countBy<T extends string>(diffs: RequestDiff[], getKey: (diff: RequestDiff) => T) {
|
||||
const counts = new Map<T, number>();
|
||||
for (const diff of diffs) {
|
||||
counts.set(getKey(diff), (counts.get(getKey(diff)) ?? 0) + 1);
|
||||
}
|
||||
return [...counts].toSorted((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]));
|
||||
}
|
||||
|
||||
function renderSummary(base: BrowserMetricsReport, head: BrowserMetricsReport, diffs: RequestDiff[]): Raw {
|
||||
const added = diffs.filter(diff => diff.direction === 'added').length;
|
||||
const removed = diffs.filter(diff => diff.direction === 'removed').length;
|
||||
const typeCounts = countBy(diffs, diff => diff.request.resourceType);
|
||||
const typeRows = joinHtml(typeCounts.map(([type, count]) => html`
|
||||
<tr>
|
||||
<td>${type}</td>
|
||||
<td class="num">${formatNumber(count)}</td>
|
||||
</tr>`), '');
|
||||
|
||||
return html`
|
||||
<section class="summary">
|
||||
<div>
|
||||
<span class="label">Base samples</span>
|
||||
<strong>${formatNumber(base.sampleCount)}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span class="label">Head samples</span>
|
||||
<strong>${formatNumber(head.sampleCount)}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span class="label">Added in Head</span>
|
||||
<strong class="added-text">${formatNumber(added)}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span class="label">Removed in Head</span>
|
||||
<strong class="removed-text">${formatNumber(removed)}</strong>
|
||||
</div>
|
||||
</section>
|
||||
${typeCounts.length === 0 ? raw('') : html`
|
||||
<section>
|
||||
<h2>Diffs by Resource Type</h2>
|
||||
<table>
|
||||
<thead><tr><th>Type</th><th>Diff requests</th></tr></thead>
|
||||
<tbody>${typeRows}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>`}`;
|
||||
}
|
||||
|
||||
function renderDetails(title: string, content: string | null, open = false): Raw {
|
||||
if (content == null || content === '') return raw('');
|
||||
return html`
|
||||
<details${open ? raw(' open') : raw('')}>
|
||||
<summary>${title}</summary>
|
||||
<pre>${content}</pre>
|
||||
</details>`;
|
||||
}
|
||||
|
||||
function renderRequest(diff: RequestDiff): Raw {
|
||||
const { request } = diff;
|
||||
const requestBody = formatMaybeJson(request.requestBody);
|
||||
const requestHeaders = formatHeaders(request.requestHeaders);
|
||||
const responseHeaders = formatHeaders(request.responseHeaders);
|
||||
const bodyNote = requestBody == null && request.hasRequestBody === true
|
||||
? html`<p class="empty">Request body was present but could not be retrieved from CDP.</p>`
|
||||
: raw('');
|
||||
|
||||
return html`
|
||||
<article class="request ${diff.direction}">
|
||||
<header>
|
||||
<span class="badge">${diff.direction === 'added' ? 'Added in Head' : 'Removed in Head'}</span>
|
||||
<span class="method">${request.method}</span>
|
||||
<span class="type">${request.resourceType}</span>
|
||||
<span class="status">${request.status ?? '-'}</span>
|
||||
</header>
|
||||
<a class="url" href="${request.url}">${request.url}</a>
|
||||
<dl>
|
||||
<div><dt>Round</dt><dd>${formatNumber(diff.round)}</dd></div>
|
||||
<div><dt>Base count</dt><dd>${formatNumber(diff.baseCount)}</dd></div>
|
||||
<div><dt>Head count</dt><dd>${formatNumber(diff.headCount)}</dd></div>
|
||||
<div><dt>Encoded</dt><dd>${formatBytes(request.encodedDataLength ?? 0)}</dd></div>
|
||||
<div><dt>Decoded body</dt><dd>${formatBytes(request.decodedBodyLength ?? 0)}</dd></div>
|
||||
<div><dt>MIME</dt><dd>${request.mimeType ?? '-'}</dd></div>
|
||||
<div><dt>Protocol</dt><dd>${request.protocol ?? '-'}</dd></div>
|
||||
<div><dt>Remote</dt><dd>${request.remoteIPAddress == null ? '-' : `${request.remoteIPAddress}:${request.remotePort ?? ''}`}</dd></div>
|
||||
<div><dt>Failed</dt><dd>${request.failed ? (request.errorText ?? 'yes') : 'no'}</dd></div>
|
||||
</dl>
|
||||
${bodyNote}
|
||||
${renderDetails('Request body', requestBody, requestBody != null)}
|
||||
${renderDetails('Request headers', requestHeaders)}
|
||||
${renderDetails('Response headers', responseHeaders)}
|
||||
</article>`;
|
||||
}
|
||||
|
||||
function renderRound(round: number, diffs: RequestDiff[]): Raw {
|
||||
const added = diffs.filter(diff => diff.direction === 'added').length;
|
||||
const removed = diffs.filter(diff => diff.direction === 'removed').length;
|
||||
return html`
|
||||
<section>
|
||||
<h2>Round ${formatNumber(round)}</h2>
|
||||
<p>${formatNumber(added)} added, ${formatNumber(removed)} removed</p>
|
||||
<div class="requests">
|
||||
${joinHtml(diffs.map(renderRequest), '\n')}
|
||||
</div>
|
||||
</section>`;
|
||||
}
|
||||
|
||||
export function renderBrowserDiagnosticsHtml(base: BrowserMetricsReport, head: BrowserMetricsReport) {
|
||||
const diffs = diffReports(base, head);
|
||||
const rounds = [...new Set(diffs.map(diff => diff.round))].toSorted((a, b) => a - b);
|
||||
const generatedAt = new Date().toISOString();
|
||||
const content = diffs.length === 0
|
||||
? html`<section><p>No added or removed HTTP(S) requests were found in paired samples.</p></section>`
|
||||
: joinHtml(rounds.map(round => renderRound(round, diffs.filter(diff => diff.round === round))), '\n');
|
||||
|
||||
return String(html`<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Frontend Browser Network Request Diff</title>
|
||||
<style>
|
||||
${raw(networkDiffHtmlStyles)}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<h1>Frontend Browser Network Request Diff</h1>
|
||||
<p class="meta">Generated at ${generatedAt}. Requests are compared per paired round by method, resource type, and exact URL. Bodies are shown for added/removed request instances when CDP exposes them.</p>
|
||||
${renderSummary(base, head, diffs)}
|
||||
${content}
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
`);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { closeUserSetupDialog, postNote, registerUser, resetState, signupThroughUi, visitHome } from '../../../../packages/frontend/test/e2e/shared';
|
||||
import { sleep } from './server';
|
||||
import type { HeadlessChromeController } from './controller';
|
||||
|
||||
export const scenarioDescription = 'fresh browser signup, first timeline note, after the note becomes visible';
|
||||
|
||||
/**
|
||||
* 各ラウンドを同じ初期状態から始めるため、DBを消して管理者だけ作り直す。
|
||||
*/
|
||||
export async function prepareInstance(baseUrl: string) {
|
||||
await resetState(baseUrl);
|
||||
await registerUser(baseUrl, 'admin', 'admin1234', true);
|
||||
}
|
||||
|
||||
export async function runSignupAndPostScenario(chrome: HeadlessChromeController, baseUrl: string) {
|
||||
const page = chrome.page;
|
||||
const noteText = `Frontend browser metrics ${Date.now()}`;
|
||||
|
||||
await visitHome(page, baseUrl);
|
||||
await signupThroughUi(page, { username: 'alice', password: 'password' });
|
||||
await closeUserSetupDialog(page);
|
||||
await postNote(page, noteText, 10_000);
|
||||
|
||||
// 投稿直後の非同期処理が落ち着いてから計測したいので少し待つ
|
||||
await sleep(1000);
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { spawn, spawnSync, type ChildProcess } from 'node:child_process';
|
||||
|
||||
export function sleep(ms: number) {
|
||||
return new Promise(resolvePromise => setTimeout(resolvePromise, ms));
|
||||
}
|
||||
|
||||
function commandName(command: string) {
|
||||
if (process.platform !== 'win32') return command;
|
||||
if (command === 'pnpm') return 'pnpm.cmd';
|
||||
return command;
|
||||
}
|
||||
|
||||
/**
|
||||
* 計測対象のリポジトリで Misskey テストサーバーを起動する。
|
||||
* POSIXでは detached にして、後で子孫プロセスごとまとめて落とせるようにする。
|
||||
*/
|
||||
export function startServer(label: string, repoDir: string) {
|
||||
process.stderr.write(`[${label}] Starting Misskey test server\n`);
|
||||
const child = spawn(commandName('pnpm'), ['start:test'], {
|
||||
cwd: repoDir,
|
||||
env: process.env,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
detached: process.platform !== 'win32',
|
||||
});
|
||||
child.stdout.on('data', data => process.stderr.write(`[server:${label}] ${data}`));
|
||||
child.stderr.on('data', data => process.stderr.write(`[server:${label}] ${data}`));
|
||||
return child;
|
||||
}
|
||||
|
||||
const serverStartupTimeoutMs = 120_000;
|
||||
|
||||
function hasExited(child: ChildProcess) {
|
||||
return child.exitCode != null || child.signalCode != null;
|
||||
}
|
||||
|
||||
export async function waitForServer(baseUrl: string, child: ChildProcess) {
|
||||
const startedAt = Date.now();
|
||||
while (Date.now() - startedAt < serverStartupTimeoutMs) {
|
||||
if (child.exitCode != null) throw new Error(`Misskey server exited early with code ${child.exitCode}`);
|
||||
if (child.signalCode != null) throw new Error(`Misskey server exited early with signal ${child.signalCode}`);
|
||||
try {
|
||||
// 応答が返らないままだとfetchが待ち続け、外側の120秒の上限を超えてしまう
|
||||
const remainingMs = serverStartupTimeoutMs - (Date.now() - startedAt);
|
||||
const response = await fetch(`${baseUrl}/`, {
|
||||
redirect: 'manual',
|
||||
signal: AbortSignal.timeout(remainingMs),
|
||||
});
|
||||
if (response.status < 500) return;
|
||||
} catch {
|
||||
// 中断・接続拒否いずれもまだ起動中とみなしてリトライする
|
||||
}
|
||||
await sleep(1_000);
|
||||
}
|
||||
throw new Error(`Timed out waiting for ${baseUrl}`);
|
||||
}
|
||||
|
||||
export async function stopServer(child: ChildProcess) {
|
||||
if (hasExited(child)) return;
|
||||
|
||||
if (process.platform === 'win32') {
|
||||
spawnSync('taskkill', ['/pid', String(child.pid), '/t', '/f'], { stdio: 'ignore' });
|
||||
} else if (child.pid != null) {
|
||||
try {
|
||||
// プロセスグループごと落とさないと pnpm 配下の node が残る
|
||||
process.kill(-child.pid, 'SIGTERM');
|
||||
} catch {
|
||||
child.kill('SIGTERM');
|
||||
}
|
||||
}
|
||||
|
||||
await new Promise<void>(resolvePromise => {
|
||||
if (hasExited(child)) {
|
||||
resolvePromise();
|
||||
return;
|
||||
}
|
||||
|
||||
const forceKillTimer = setTimeout(() => {
|
||||
// 猶予の間に終了していれば、PIDが再利用されて無関係のプロセスを撃つ恐れがある
|
||||
if (!hasExited(child) && child.pid != null) {
|
||||
try {
|
||||
if (process.platform === 'win32') {
|
||||
spawnSync('taskkill', ['/pid', String(child.pid), '/t', '/f'], { stdio: 'ignore' });
|
||||
} else {
|
||||
process.kill(-child.pid, 'SIGKILL');
|
||||
}
|
||||
} catch {
|
||||
child.kill('SIGKILL');
|
||||
}
|
||||
}
|
||||
resolvePromise();
|
||||
}, 10_000);
|
||||
forceKillTimer.unref();
|
||||
|
||||
child.once('exit', () => {
|
||||
clearTimeout(forceKillTimer);
|
||||
resolvePromise();
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { finiteMedian } from 'diagnostics-shared/stats';
|
||||
import { summarizeHeapSnapshotDataSamples } from 'diagnostics-shared/heap-snapshot';
|
||||
import { summarizeBrowserDiagnostics } from './diagnostics';
|
||||
import type { BrowserMeasurement, BrowserMeasurementSample, BrowserMetricsReport, NetworkSummary } from './types';
|
||||
|
||||
export type SummarizeOptions = {
|
||||
url: string;
|
||||
heapSnapshotBreakdownTopN: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* 中央値に最も近いサンプルを1つ選ぶ。
|
||||
* largestRequests のように中央値を取れない項目は、代表サンプルの値をそのまま採用する。
|
||||
*/
|
||||
export function selectRepresentativeSample(samples: BrowserMeasurementSample[], getValue: (sample: BrowserMeasurementSample) => number) {
|
||||
const medianValue = finiteMedian(samples.map(getValue), 0);
|
||||
let selected: { sample: BrowserMeasurementSample; distance: number } | null = null;
|
||||
|
||||
for (const sample of samples) {
|
||||
const value = getValue(sample);
|
||||
if (!Number.isFinite(value)) continue;
|
||||
const distance = Math.abs(value - medianValue);
|
||||
if (selected == null || distance < selected.distance || (distance === selected.distance && sample.round < selected.sample.round)) {
|
||||
selected = {
|
||||
sample,
|
||||
distance,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return selected?.sample ?? samples[0];
|
||||
}
|
||||
|
||||
function summarizeResourceType(samples: BrowserMeasurementSample[], resourceType: string) {
|
||||
return {
|
||||
requests: finiteMedian(samples.map(sample => sample.network.byResourceType[resourceType]?.requests), 0),
|
||||
encodedBytes: finiteMedian(samples.map(sample => sample.network.byResourceType[resourceType]?.encodedBytes), 0),
|
||||
decodedBodyBytes: finiteMedian(samples.map(sample => sample.network.byResourceType[resourceType]?.decodedBodyBytes), 0),
|
||||
};
|
||||
}
|
||||
|
||||
export function summarizeNetworkSamples(samples: BrowserMeasurementSample[]): NetworkSummary {
|
||||
const resourceTypes = new Set<string>();
|
||||
for (const sample of samples) {
|
||||
for (const resourceType of Object.keys(sample.network.byResourceType)) {
|
||||
resourceTypes.add(resourceType);
|
||||
}
|
||||
}
|
||||
|
||||
const representative = selectRepresentativeSample(samples, sample => sample.network.totalEncodedBytes);
|
||||
const byResourceType = {} as NetworkSummary['byResourceType'];
|
||||
for (const resourceType of resourceTypes) {
|
||||
byResourceType[resourceType] = summarizeResourceType(samples, resourceType);
|
||||
}
|
||||
|
||||
return {
|
||||
requestCount: finiteMedian(samples.map(sample => sample.network.requestCount), 0),
|
||||
webSocketConnectionCount: finiteMedian(samples.map(sample => sample.network.webSocketConnectionCount), 0),
|
||||
webSocketSentBytes: finiteMedian(samples.map(sample => sample.network.webSocketSentBytes), 0),
|
||||
webSocketReceivedBytes: finiteMedian(samples.map(sample => sample.network.webSocketReceivedBytes), 0),
|
||||
finishedRequestCount: finiteMedian(samples.map(sample => sample.network.finishedRequestCount), 0),
|
||||
failedRequestCount: finiteMedian(samples.map(sample => sample.network.failedRequestCount), 0),
|
||||
cachedRequestCount: finiteMedian(samples.map(sample => sample.network.cachedRequestCount), 0),
|
||||
serviceWorkerRequestCount: finiteMedian(samples.map(sample => sample.network.serviceWorkerRequestCount), 0),
|
||||
totalEncodedBytes: finiteMedian(samples.map(sample => sample.network.totalEncodedBytes), 0),
|
||||
totalDecodedBodyBytes: finiteMedian(samples.map(sample => sample.network.totalDecodedBodyBytes), 0),
|
||||
sameOriginEncodedBytes: finiteMedian(samples.map(sample => sample.network.sameOriginEncodedBytes), 0),
|
||||
thirdPartyEncodedBytes: finiteMedian(samples.map(sample => sample.network.thirdPartyEncodedBytes), 0),
|
||||
byResourceType,
|
||||
largestRequests: representative.network.largestRequests,
|
||||
failedRequests: representative.network.failedRequests,
|
||||
};
|
||||
}
|
||||
|
||||
export function summarizePerformanceSamples(samples: BrowserMeasurementSample[]): BrowserMeasurement['performance'] {
|
||||
const cdpMetricKeys = new Set<string>();
|
||||
for (const sample of samples) {
|
||||
for (const key of Object.keys(sample.performance.cdpMetrics)) {
|
||||
cdpMetricKeys.add(key);
|
||||
}
|
||||
}
|
||||
|
||||
const cdpMetrics = {} as Record<string, number>;
|
||||
for (const key of cdpMetricKeys) {
|
||||
cdpMetrics[key] = finiteMedian(samples.map(sample => sample.performance.cdpMetrics[key]), 0);
|
||||
}
|
||||
|
||||
const webVitalKeys = [
|
||||
'firstPaintMs',
|
||||
'firstContentfulPaintMs',
|
||||
'domContentLoadedEventEndMs',
|
||||
'loadEventEndMs',
|
||||
'longTaskCount',
|
||||
'longTaskDurationMs',
|
||||
'maxLongTaskDurationMs',
|
||||
'resourceEntryCount',
|
||||
'domElements',
|
||||
] as const satisfies (keyof BrowserMeasurement['performance']['webVitals'])[];
|
||||
|
||||
const webVitals = {} as BrowserMeasurement['performance']['webVitals'];
|
||||
for (const key of webVitalKeys) {
|
||||
webVitals[key] = finiteMedian(samples.map(sample => sample.performance.webVitals[key]), 0);
|
||||
}
|
||||
|
||||
return {
|
||||
cdpMetrics,
|
||||
runtimeHeap: {
|
||||
usedSize: finiteMedian(samples.map(sample => sample.performance.runtimeHeap?.usedSize), 0),
|
||||
totalSize: finiteMedian(samples.map(sample => sample.performance.runtimeHeap?.totalSize), 0),
|
||||
},
|
||||
tabMemory: {
|
||||
totalBytes: finiteMedian(samples.map(sample => sample.performance.tabMemory.totalBytes), 0),
|
||||
},
|
||||
webVitals,
|
||||
};
|
||||
}
|
||||
|
||||
export function summarizeHeapSnapshotSamples(samples: BrowserMeasurementSample[], breakdownTopN: number) {
|
||||
const summary = summarizeHeapSnapshotDataSamples(
|
||||
samples,
|
||||
sample => sample.heapSnapshot,
|
||||
{ breakdownTopN },
|
||||
);
|
||||
if (summary == null) throw new Error('No heap snapshot samples');
|
||||
return summary;
|
||||
}
|
||||
|
||||
export function summarizeSamples(label: 'base' | 'head', samples: BrowserMeasurementSample[], options: SummarizeOptions): BrowserMetricsReport {
|
||||
if (samples.length === 0) throw new Error(`No browser metric samples for ${label}`);
|
||||
const representative = selectRepresentativeSample(samples, sample => sample.network.totalEncodedBytes);
|
||||
const summary: BrowserMeasurement = {
|
||||
label,
|
||||
timestamp: new Date().toISOString(),
|
||||
url: options.url,
|
||||
scenario: representative.scenario,
|
||||
diagnostics: summarizeBrowserDiagnostics(samples.map(sample => sample.diagnostics)),
|
||||
durationMs: finiteMedian(samples.map(sample => sample.durationMs), 0),
|
||||
network: summarizeNetworkSamples(samples),
|
||||
performance: summarizePerformanceSamples(samples),
|
||||
heapSnapshot: summarizeHeapSnapshotSamples(samples, options.heapSnapshotBreakdownTopN),
|
||||
};
|
||||
|
||||
return {
|
||||
label,
|
||||
timestamp: new Date().toISOString(),
|
||||
url: options.url,
|
||||
scenario: representative.scenario,
|
||||
sampleCount: samples.length,
|
||||
aggregation: 'median',
|
||||
summary,
|
||||
samples,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import type { HeapSnapshotData } from 'diagnostics-shared/heap-snapshot';
|
||||
|
||||
// NOTE: (AI向け)
|
||||
// NetworkRequest がリクエスト本文と要求・応答ヘッダを保持し、APIキーやパスワード、Cookieなどの認証情報も含まれ得るが、
|
||||
// workflow上で作られるMisskeyアカウントはテスト用アカウントであり、誰かが実際に使用するプライベートなアカウントではないため、
|
||||
// それらの情報が第三者から閲覧できても何の問題もなく、脆弱性ではない。
|
||||
|
||||
export type NetworkRequest = {
|
||||
requestId: string;
|
||||
url: string;
|
||||
method: string;
|
||||
resourceType: string;
|
||||
startedAt: number;
|
||||
documentUrl?: string;
|
||||
requestHeaders?: Record<string, string>;
|
||||
requestBody?: string;
|
||||
hasRequestBody: boolean;
|
||||
status?: number;
|
||||
statusText?: string;
|
||||
mimeType?: string;
|
||||
responseHeaders?: Record<string, string>;
|
||||
protocol?: string;
|
||||
remoteIPAddress?: string;
|
||||
remotePort?: number;
|
||||
encodedDataLength: number;
|
||||
decodedBodyLength: number;
|
||||
fromDiskCache: boolean;
|
||||
fromServiceWorker: boolean;
|
||||
finished: boolean;
|
||||
failed: boolean;
|
||||
errorText?: string;
|
||||
};
|
||||
|
||||
export type WebSocketConnection = {
|
||||
requestId: string;
|
||||
url: string;
|
||||
createdAt: number;
|
||||
handshakeRequestHeaders?: Record<string, string>;
|
||||
handshakeResponseStatus?: number;
|
||||
handshakeResponseStatusText?: string;
|
||||
handshakeResponseHeaders?: Record<string, string>;
|
||||
closedAt?: number;
|
||||
sentFrameCount: number;
|
||||
receivedFrameCount: number;
|
||||
sentBytes: number;
|
||||
receivedBytes: number;
|
||||
errorCount: number;
|
||||
};
|
||||
|
||||
export type NetworkSummary = {
|
||||
requestCount: number;
|
||||
webSocketConnectionCount: number;
|
||||
webSocketSentBytes: number;
|
||||
webSocketReceivedBytes: number;
|
||||
finishedRequestCount: number;
|
||||
failedRequestCount: number;
|
||||
cachedRequestCount: number;
|
||||
serviceWorkerRequestCount: number;
|
||||
totalEncodedBytes: number;
|
||||
totalDecodedBodyBytes: number;
|
||||
sameOriginEncodedBytes: number;
|
||||
thirdPartyEncodedBytes: number;
|
||||
byResourceType: Record<string, {
|
||||
requests: number;
|
||||
encodedBytes: number;
|
||||
decodedBodyBytes: number;
|
||||
}>;
|
||||
largestRequests: {
|
||||
url: string;
|
||||
method: string;
|
||||
resourceType: string;
|
||||
status?: number;
|
||||
encodedBytes: number;
|
||||
decodedBodyBytes: number;
|
||||
}[];
|
||||
failedRequests: {
|
||||
url: string;
|
||||
method: string;
|
||||
resourceType: string;
|
||||
errorText?: string;
|
||||
status?: number;
|
||||
}[];
|
||||
};
|
||||
|
||||
export type TabMemory = {
|
||||
totalBytes: number;
|
||||
};
|
||||
|
||||
export type BrowserDiagnostics = {
|
||||
pageErrorCount: number;
|
||||
console: Record<'log' | 'warning' | 'error' | 'info', number>;
|
||||
};
|
||||
|
||||
export type BrowserMeasurement = {
|
||||
label: string;
|
||||
timestamp: string;
|
||||
url: string;
|
||||
scenario: string;
|
||||
diagnostics: BrowserDiagnostics;
|
||||
durationMs: number;
|
||||
network: NetworkSummary;
|
||||
performance: {
|
||||
cdpMetrics: Record<string, number>;
|
||||
runtimeHeap?: {
|
||||
usedSize: number;
|
||||
totalSize: number;
|
||||
};
|
||||
tabMemory: TabMemory;
|
||||
webVitals: {
|
||||
firstPaintMs?: number;
|
||||
firstContentfulPaintMs?: number;
|
||||
domContentLoadedEventEndMs?: number;
|
||||
loadEventEndMs?: number;
|
||||
longTaskCount: number;
|
||||
longTaskDurationMs: number;
|
||||
maxLongTaskDurationMs: number;
|
||||
resourceEntryCount: number;
|
||||
domElements: number;
|
||||
};
|
||||
};
|
||||
heapSnapshot: HeapSnapshotData;
|
||||
};
|
||||
|
||||
export type BrowserMeasurementSample = BrowserMeasurement & {
|
||||
round: number;
|
||||
/** ラウンド単位のリクエスト差分HTMLを描くために生ログを丸ごと保持する */
|
||||
networkRequests: NetworkRequest[];
|
||||
};
|
||||
|
||||
export type BrowserMetricsReport = {
|
||||
label: string;
|
||||
timestamp: string;
|
||||
url: string;
|
||||
scenario: string;
|
||||
sampleCount: number;
|
||||
aggregation: 'median';
|
||||
summary: BrowserMeasurement;
|
||||
samples: BrowserMeasurementSample[];
|
||||
};
|
||||
@@ -0,0 +1,218 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import {
|
||||
calcAndFormatDeltaBytes,
|
||||
calcAndFormatDeltaPercentInMdTable,
|
||||
escapeMdTableCell,
|
||||
formatBytes,
|
||||
} from 'diagnostics-shared/format';
|
||||
import type { CollectedBundleReport, FileEntry } from './manifest';
|
||||
|
||||
/**
|
||||
* この差分以下のチャンクは個別に出さず `(other)` にまとめる。
|
||||
* ハッシュ文字列の揺れ等でサイズが数バイト動くだけの行がノイズになるため。
|
||||
*/
|
||||
const smallDeltaThreshold = 5;
|
||||
|
||||
/** diff表に個別行として出す上限。これを超えた分は `(other)` に集約する */
|
||||
const diffRowLimit = 30;
|
||||
|
||||
function entryDisplayName(entry: FileEntry | undefined) {
|
||||
if (entry == null) return '';
|
||||
return entry.displayName || entry.file;
|
||||
}
|
||||
|
||||
export function getChunkComparisonRows(keys: string[], base: Partial<Record<string, FileEntry>>, head: Partial<Record<string, FileEntry>>) {
|
||||
return keys.map(key => {
|
||||
const baseEntry = base[key];
|
||||
const headEntry = head[key];
|
||||
const baseSize = baseEntry?.size ?? 0;
|
||||
const headSize = headEntry?.size ?? 0;
|
||||
return {
|
||||
key,
|
||||
name: entryDisplayName(baseEntry ?? headEntry),
|
||||
baseFile: baseEntry?.file,
|
||||
headFile: headEntry?.file,
|
||||
baseSize,
|
||||
headSize,
|
||||
changeType: baseEntry == null ? 'added' : headEntry == null ? 'removed' : baseSize !== headSize ? 'updated' : 'unchanged',
|
||||
sortSize: Math.max(baseSize, headSize),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export type ChunkComparisonRow = ReturnType<typeof getChunkComparisonRows>[number];
|
||||
|
||||
export type ChunkAggregate = {
|
||||
baseSize: number;
|
||||
headSize: number;
|
||||
baseCount: number;
|
||||
headCount: number;
|
||||
};
|
||||
|
||||
export function sumChunkSizes(chunks: FileEntry[]) {
|
||||
return chunks.reduce((sum, chunk) => sum + chunk.size, 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* 比較キーを持たない (= base/head で対応付けできない) チャンクの合計。
|
||||
*/
|
||||
export function generatedAggregate(base: FileEntry[], head: FileEntry[]): ChunkAggregate {
|
||||
const baseGenerated = base.filter(chunk => chunk.comparisonKey == null);
|
||||
const headGenerated = head.filter(chunk => chunk.comparisonKey == null);
|
||||
return {
|
||||
baseSize: sumChunkSizes(baseGenerated),
|
||||
headSize: sumChunkSizes(headGenerated),
|
||||
baseCount: baseGenerated.length,
|
||||
headCount: headGenerated.length,
|
||||
};
|
||||
}
|
||||
|
||||
export function hasSmallDelta(row: ChunkComparisonRow) {
|
||||
return Math.abs(row.headSize - row.baseSize) <= smallDeltaThreshold;
|
||||
}
|
||||
|
||||
export function comparisonRowsAggregate(rows: ChunkComparisonRow[]): ChunkAggregate {
|
||||
return {
|
||||
baseSize: rows.reduce((sum, row) => sum + row.baseSize, 0),
|
||||
headSize: rows.reduce((sum, row) => sum + row.headSize, 0),
|
||||
baseCount: rows.filter(row => row.baseFile != null).length,
|
||||
headCount: rows.filter(row => row.headFile != null).length,
|
||||
};
|
||||
}
|
||||
|
||||
export function comparableMap(chunks: FileEntry[]) {
|
||||
const entries: [string, FileEntry][] = [];
|
||||
for (const chunk of chunks) {
|
||||
if (chunk.comparisonKey != null) entries.push([chunk.comparisonKey, chunk]);
|
||||
}
|
||||
return Object.fromEntries(entries);
|
||||
}
|
||||
|
||||
export function summarizeChunkChanges(rows: ChunkComparisonRow[]) {
|
||||
return {
|
||||
updated: rows.filter((row) => row.changeType === 'updated').length,
|
||||
added: rows.filter((row) => row.changeType === 'added').length,
|
||||
removed: rows.filter((row) => row.changeType === 'removed').length,
|
||||
};
|
||||
}
|
||||
|
||||
export function formatChunkChangeSummary(label: string, summary: ReturnType<typeof summarizeChunkChanges>) {
|
||||
return `${label} (${summary.updated} updated, ${summary.added} added, ${summary.removed} removed)`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 差分の絶対値が大きい順。同着は増加側・元サイズ・名前の順で決定的に並べる。
|
||||
*/
|
||||
export function compareChunkComparisonRows(a: ChunkComparisonRow, b: ChunkComparisonRow) {
|
||||
return Math.abs(b.headSize - b.baseSize) - Math.abs(a.headSize - a.baseSize)
|
||||
|| (b.headSize - b.baseSize) - (a.headSize - a.baseSize)
|
||||
|| b.sortSize - a.sortSize
|
||||
|| a.name.localeCompare(b.name);
|
||||
}
|
||||
|
||||
export function chunkFileDisplay(row: ChunkComparisonRow) {
|
||||
if (row.baseFile == null) return row.headFile ?? '';
|
||||
if (row.headFile == null || row.baseFile === row.headFile) return row.baseFile;
|
||||
return `${row.baseFile} → ${row.headFile}`;
|
||||
}
|
||||
|
||||
export function chunkMarkdownTable(
|
||||
rows: ChunkComparisonRow[],
|
||||
total?: { baseSize: number; headSize: number },
|
||||
generated?: ChunkAggregate,
|
||||
other?: ChunkAggregate,
|
||||
) {
|
||||
const hasGenerated = generated != null && (generated.baseCount > 0 || generated.headCount > 0);
|
||||
const hasOther = other != null && (other.baseCount > 0 || other.headCount > 0);
|
||||
if (rows.length === 0 && total == null && !hasGenerated && !hasOther) return '_No data_';
|
||||
|
||||
const lines = [
|
||||
'| Chunk | Base | Head | Δ | Δ (%) |', // nbspにすること
|
||||
'| --- | ---: | ---: | ---: | ---: |',
|
||||
];
|
||||
if (total != null) {
|
||||
lines.push(`| (total) | ${formatBytes(total.baseSize)} | ${formatBytes(total.headSize)} | ${calcAndFormatDeltaBytes(total.baseSize, total.headSize, 1000)} | ${calcAndFormatDeltaPercentInMdTable(total.baseSize, total.headSize, 0.1)} |`);
|
||||
lines.push('| | | | | |');
|
||||
}
|
||||
|
||||
for (const row of rows) {
|
||||
const chunkFile = chunkFileDisplay(row);
|
||||
if (row.changeType === 'added') {
|
||||
lines.push(`| <details><summary>\`${escapeMdTableCell(row.name)}\`</summary> \`${escapeMdTableCell(chunkFile)}\` </details> | ${formatBytes(row.baseSize)} | ${formatBytes(row.headSize)} | ${calcAndFormatDeltaBytes(row.baseSize, row.headSize, 1000)} | $\\color{orange}{\\text{( + )}}$ |`);
|
||||
} else if (row.changeType === 'removed') {
|
||||
lines.push(`| <details><summary>\`${escapeMdTableCell(row.name)}\`</summary> \`${escapeMdTableCell(chunkFile)}\` </details> | ${formatBytes(row.baseSize)} | ${formatBytes(row.headSize)} | ${calcAndFormatDeltaBytes(row.baseSize, row.headSize, 1000)} | $\\color{green}{\\text{( - )}}$ |`);
|
||||
} else {
|
||||
lines.push(`| <details><summary>\`${escapeMdTableCell(row.name)}\`</summary> \`${escapeMdTableCell(chunkFile)}\` </details> | ${formatBytes(row.baseSize)} | ${formatBytes(row.headSize)} | ${calcAndFormatDeltaBytes(row.baseSize, row.headSize, 1000)} | ${calcAndFormatDeltaPercentInMdTable(row.baseSize, row.headSize, 0.1)} |`);
|
||||
}
|
||||
}
|
||||
if (hasGenerated) {
|
||||
// eslint-disable-next-line no-irregular-whitespace
|
||||
lines.push(`| (other generated chunks) | ${formatBytes(generated.baseSize)} | ${formatBytes(generated.headSize)} | ${calcAndFormatDeltaBytes(generated.baseSize, generated.headSize, 1000)} | ${calcAndFormatDeltaPercentInMdTable(generated.baseSize, generated.headSize, 0.1)} |`); // nbspにすること
|
||||
}
|
||||
if (hasOther) {
|
||||
lines.push(`| (other) | ${formatBytes(other.baseSize)} | ${formatBytes(other.headSize)} | ${calcAndFormatDeltaBytes(other.baseSize, other.headSize, 1000)} | ${calcAndFormatDeltaPercentInMdTable(other.baseSize, other.headSize, 0.1)} |`);
|
||||
}
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
export function renderFrontendChunkReport(base: CollectedBundleReport, head: CollectedBundleReport) {
|
||||
const baseComparable = base.comparableChunks;
|
||||
const headComparable = head.comparableChunks;
|
||||
const allChunkKeys = [...new Set([...Object.keys(baseComparable), ...Object.keys(headComparable)])];
|
||||
const allComparisonRows = getChunkComparisonRows(allChunkKeys, baseComparable, headComparable);
|
||||
|
||||
const changedRows = allComparisonRows.filter((row) => row.changeType !== 'unchanged');
|
||||
const diffSummary = summarizeChunkChanges(changedRows);
|
||||
const diffTotal = {
|
||||
baseSize: sumChunkSizes(base.chunks),
|
||||
headSize: sumChunkSizes(head.chunks),
|
||||
};
|
||||
const diffGenerated = generatedAggregate(base.chunks, head.chunks);
|
||||
const largeDeltaRows = changedRows.filter(row => !hasSmallDelta(row)).sort(compareChunkComparisonRows);
|
||||
const diffRows = largeDeltaRows.slice(0, diffRowLimit);
|
||||
// 表示上限で切り捨てた行も `(other)` に含める。落とすと合計が実際の変化量と合わなくなる
|
||||
const diffOther = comparisonRowsAggregate([
|
||||
...changedRows.filter(hasSmallDelta),
|
||||
...largeDeltaRows.slice(diffRowLimit),
|
||||
]);
|
||||
|
||||
const baseStartupFiles = new Set(base.startupFiles);
|
||||
const headStartupFiles = new Set(head.startupFiles);
|
||||
const baseStartupChunks = base.chunks.filter(chunk => baseStartupFiles.has(chunk.file));
|
||||
const headStartupChunks = head.chunks.filter(chunk => headStartupFiles.has(chunk.file));
|
||||
const baseStartupComparable = comparableMap(baseStartupChunks);
|
||||
const headStartupComparable = comparableMap(headStartupChunks);
|
||||
const startupKeys = [...new Set([...Object.keys(baseStartupComparable), ...Object.keys(headStartupComparable)])];
|
||||
const startupComparisonRows = getChunkComparisonRows(startupKeys, baseStartupComparable, headStartupComparable);
|
||||
const startupSummary = summarizeChunkChanges(startupComparisonRows);
|
||||
const startupOther = comparisonRowsAggregate(startupComparisonRows.filter(hasSmallDelta));
|
||||
const startupRows = startupComparisonRows.filter(row => !hasSmallDelta(row)).sort(compareChunkComparisonRows);
|
||||
const startupTotal = {
|
||||
baseSize: sumChunkSizes(baseStartupChunks),
|
||||
headSize: sumChunkSizes(headStartupChunks),
|
||||
};
|
||||
const startupGenerated = generatedAggregate(baseStartupChunks, headStartupChunks);
|
||||
|
||||
return [
|
||||
'<details>',
|
||||
`<summary>${formatChunkChangeSummary('Chunk size diff', diffSummary)}</summary>`,
|
||||
'',
|
||||
chunkMarkdownTable(diffRows, diffTotal, diffGenerated, diffOther),
|
||||
'',
|
||||
'</details>',
|
||||
'',
|
||||
'<details>',
|
||||
`<summary>${formatChunkChangeSummary('Startup chunk size', startupSummary)}</summary>`,
|
||||
'',
|
||||
chunkMarkdownTable(startupRows, startupTotal, startupGenerated, startupOther),
|
||||
'',
|
||||
'_Startup chunks are the Vite entry for \`src/_boot_.ts\` and its static imports._',
|
||||
'',
|
||||
'</details>',
|
||||
'',
|
||||
].join('\n');
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { promises as fs } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
/**
|
||||
* Windows の `\` 区切りを `/` に揃える。manifest 側のキーが常に `/` 区切りのため、
|
||||
* 実ファイルパスと突き合わせる前に正規化する必要がある。
|
||||
*/
|
||||
export function normalizePath(filePath: string) {
|
||||
return filePath.split(path.sep).join('/');
|
||||
}
|
||||
|
||||
export async function fileExists(filePath: string) {
|
||||
try {
|
||||
await fs.access(filePath);
|
||||
return true;
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return false;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function fileSize(filePath: string) {
|
||||
const stat = await fs.stat(filePath);
|
||||
return stat.size;
|
||||
}
|
||||
|
||||
export async function* traverseDirectory(dir: string): AsyncGenerator<string> {
|
||||
for (const entry of await fs.readdir(dir, { withFileTypes: true })) {
|
||||
const fullPath = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
yield* traverseDirectory(fullPath);
|
||||
} else if (entry.isFile()) {
|
||||
yield fullPath;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { promises as fs } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileExists, fileSize, normalizePath, traverseDirectory } from './fs-utils';
|
||||
|
||||
export type BundleManifestChunk = {
|
||||
file: string;
|
||||
src?: string;
|
||||
name?: string;
|
||||
isEntry?: boolean;
|
||||
imports?: string[];
|
||||
};
|
||||
|
||||
export type BundleManifest = Record<string, BundleManifestChunk>;
|
||||
|
||||
/**
|
||||
* 比較対象とするロケール。ロケール別チャンクは全ロケール分だと数が多すぎるため、
|
||||
* 代表として ja-JP のみを見る。
|
||||
*/
|
||||
const locale = 'ja-JP';
|
||||
|
||||
/**
|
||||
* `src` を持たないチャンクのうち、名前がビルド間で安定していて比較可能なもの。
|
||||
*/
|
||||
const stableNamedChunks = new Set(['vue', 'i18n']);
|
||||
|
||||
export type FileEntry = {
|
||||
comparisonKey: string | null;
|
||||
displayName: string;
|
||||
file: string;
|
||||
manifestKeys: string[];
|
||||
size: number;
|
||||
};
|
||||
|
||||
export type CollectedBundleReport = {
|
||||
manifest: BundleManifest;
|
||||
chunks: FileEntry[];
|
||||
comparableChunks: Record<string, FileEntry>;
|
||||
chunksByManifestKey: Record<string, FileEntry>;
|
||||
startupFiles: string[];
|
||||
};
|
||||
|
||||
export function findEntryKey(manifest: BundleManifest) {
|
||||
const entries = Object.entries(manifest);
|
||||
return entries.find(([key, chunk]) => key === 'src/_boot_.ts' || chunk.src === 'src/_boot_.ts')?.[0]
|
||||
?? entries.find(([, chunk]) => chunk.name === 'entry' && chunk.isEntry)?.[0]
|
||||
?? entries.find(([, chunk]) => chunk.isEntry)?.[0]
|
||||
?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* ビルド間で安定するチャンク識別子。出力ファイル名はハッシュ付きで毎回変わるため、
|
||||
* これが取れないチャンクは base/head の対応付けができない。
|
||||
*/
|
||||
export function stableChunkKey(chunk: BundleManifestChunk) {
|
||||
if (chunk.src != null) return `src:${normalizePath(chunk.src)}`;
|
||||
if (chunk.name != null && stableNamedChunks.has(chunk.name)) return `named:${chunk.name}`;
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 起動時に必ず読み込まれるチャンク (entry とその静的 import) の manifest キーを集める。
|
||||
*/
|
||||
export function collectStartupManifestKeys(manifest: BundleManifest) {
|
||||
const entryKey = findEntryKey(manifest);
|
||||
const keys = new Set<string>();
|
||||
if (entryKey == null) throw new Error('Unable to find frontend startup entry in Vite manifest.');
|
||||
|
||||
function visit(key: string, importedBy?: string) {
|
||||
if (keys.has(key)) return;
|
||||
const chunk = manifest[key];
|
||||
const importContext = importedBy == null ? '' : ` imported by "${importedBy}"`;
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||
if (chunk == null) throw new Error(`Startup manifest key "${key}"${importContext} is missing.`);
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||
if (chunk.file == null || chunk.file.length === 0) throw new Error(`Startup manifest key "${key}"${importContext} has no output file.`);
|
||||
if (!chunk.file.endsWith('.js')) throw new Error(`Startup manifest key "${key}"${importContext} resolves to non-JavaScript output "${chunk.file}".`);
|
||||
keys.add(key);
|
||||
for (const importKey of chunk.imports ?? []) visit(importKey, key);
|
||||
}
|
||||
|
||||
visit(entryKey);
|
||||
return keys;
|
||||
}
|
||||
|
||||
/**
|
||||
* manifest 上の出力パスを実ファイルへ解決する。`scripts/` 配下はロケール別に
|
||||
* 複製されて出力されるため、代表ロケールのものへ読み替える。
|
||||
*/
|
||||
export async function resolveBuiltFile(outDir: string, file: string) {
|
||||
if (file.startsWith('scripts/')) {
|
||||
const localizedFile = file.slice('scripts/'.length);
|
||||
const localizedPath = path.join(outDir, locale, localizedFile);
|
||||
if (await fileExists(localizedPath)) {
|
||||
return {
|
||||
absolutePath: localizedPath,
|
||||
relativePath: `${locale}/${localizedFile}`,
|
||||
};
|
||||
}
|
||||
|
||||
throw new Error(`Expected ${locale} localized chunk for ${file}, but ${localizedPath} was not found.`);
|
||||
}
|
||||
return {
|
||||
absolutePath: path.join(outDir, file),
|
||||
relativePath: file,
|
||||
};
|
||||
}
|
||||
|
||||
export async function collectBundleReport(repoDir: string): Promise<CollectedBundleReport> {
|
||||
const outDir = path.join(repoDir, 'built/_frontend_vite_');
|
||||
const manifestPath = path.join(outDir, 'manifest.json');
|
||||
const manifest = JSON.parse(await fs.readFile(manifestPath, 'utf8')) as BundleManifest;
|
||||
const chunksByFile = new Map<string, FileEntry>();
|
||||
const comparableChunks = new Map<string, FileEntry>();
|
||||
const chunksByManifestKey = new Map<string, FileEntry>();
|
||||
|
||||
for (const [manifestKey, chunk] of Object.entries(manifest)) {
|
||||
if (!chunk.file.endsWith('.js')) continue;
|
||||
const builtFile = await resolveBuiltFile(outDir, chunk.file);
|
||||
const comparisonKey = stableChunkKey(chunk);
|
||||
let entry = chunksByFile.get(builtFile.relativePath);
|
||||
if (entry == null) {
|
||||
entry = {
|
||||
comparisonKey,
|
||||
displayName: chunk.src ?? chunk.name ?? manifestKey,
|
||||
file: builtFile.relativePath,
|
||||
manifestKeys: [manifestKey],
|
||||
size: await fileSize(builtFile.absolutePath),
|
||||
};
|
||||
chunksByFile.set(entry.file, entry);
|
||||
} else if (entry.comparisonKey !== comparisonKey) {
|
||||
throw new Error(`Conflicting identities for ${entry.file}`);
|
||||
} else {
|
||||
entry.manifestKeys.push(manifestKey);
|
||||
}
|
||||
chunksByManifestKey.set(manifestKey, entry);
|
||||
if (comparisonKey != null) {
|
||||
const existing = comparableChunks.get(comparisonKey);
|
||||
if (existing != null && existing.file !== entry.file) {
|
||||
throw new Error(`Duplicate stable chunk key "${comparisonKey}": ${existing.file}, ${entry.file}`);
|
||||
}
|
||||
comparableChunks.set(comparisonKey, entry);
|
||||
}
|
||||
}
|
||||
|
||||
// manifest に載らないロケール別チャンクも合計サイズには含めたいので拾っておく
|
||||
const localeDir = path.join(outDir, locale);
|
||||
if (await fileExists(localeDir)) {
|
||||
for await (const fullPath of traverseDirectory(localeDir)) {
|
||||
if (!fullPath.endsWith('.js')) continue;
|
||||
const relativePath = normalizePath(path.relative(outDir, fullPath));
|
||||
if (chunksByFile.has(relativePath)) continue;
|
||||
chunksByFile.set(relativePath, {
|
||||
comparisonKey: null,
|
||||
displayName: relativePath,
|
||||
file: relativePath,
|
||||
manifestKeys: [],
|
||||
size: await fileSize(fullPath),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const startupFiles = new Set<string>();
|
||||
for (const manifestKey of collectStartupManifestKeys(manifest)) {
|
||||
const entry = chunksByManifestKey.get(manifestKey);
|
||||
if (entry != null) startupFiles.add(entry.file);
|
||||
}
|
||||
|
||||
return {
|
||||
manifest,
|
||||
chunks: [...chunksByFile.values()],
|
||||
comparableChunks: Object.fromEntries(comparableChunks),
|
||||
chunksByManifestKey: Object.fromEntries(chunksByManifestKey),
|
||||
startupFiles: [...startupFiles],
|
||||
};
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user