Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1a4a8e2366 | ||
|
|
eea2d3d2f1 | ||
|
|
ae7ff78e78 | ||
|
|
ca08f277f4 | ||
|
|
aaa4f586b1 | ||
|
|
b9daee966a | ||
|
|
cda88398cc | ||
|
|
5f372e2229 | ||
|
|
c0c5acb382 | ||
|
|
9371541e1c | ||
|
|
8981f49c6a | ||
|
|
cec99a96d1 | ||
|
|
d1852af99f | ||
|
|
945568ce89 | ||
|
|
a736f120bc | ||
|
|
d2a603eb9e | ||
|
|
5b4909fcab | ||
|
|
6248f800a4 | ||
|
|
cdc749cca8 | ||
|
|
837689dfa4 | ||
|
|
b92e5affba | ||
|
|
93105b148f | ||
|
|
dd8b40eb53 | ||
|
|
eccc45eca2 | ||
|
|
d7a11e2d27 | ||
|
|
f16e5e3f04 | ||
|
|
3e7c42bc2b | ||
|
|
613165df93 | ||
|
|
7d261d9edd | ||
|
|
fc939ca7a6 | ||
|
|
c9a3131e77 | ||
|
|
af0e954f56 | ||
|
|
9976474a6c | ||
|
|
b126505b4f | ||
|
|
9b6a0f3327 | ||
|
|
5b4cfd0607 | ||
|
|
74ef86d84a | ||
|
|
c7a36b43b1 | ||
|
|
8ce6939120 | ||
|
|
1c147780ff | ||
|
|
50661c33db | ||
|
|
6fda6bf868 | ||
|
|
ccec12753c | ||
|
|
524bf552fb | ||
|
|
34bac65194 | ||
|
|
383178d22c | ||
|
|
41578aff84 | ||
|
|
38b253563c | ||
|
|
154743d3b8 | ||
|
|
d92e52b580 | ||
|
|
916f5943fa | ||
|
|
7f259f2f96 | ||
|
|
f26fb981be | ||
|
|
f93eb63929 | ||
|
|
a75ab7728b | ||
|
|
23cb22d3cb | ||
|
|
cbcc2ebb1e | ||
|
|
fb0b7396e3 | ||
|
|
674c2b45e7 | ||
|
|
ec6929c157 | ||
|
|
dcd4135049 | ||
|
|
08ea935ea9 | ||
|
|
2a3a3738db | ||
|
|
d1eb9c968e | ||
|
|
a2597fb17c | ||
|
|
41ea14ccc9 | ||
|
|
b3bea0d076 | ||
|
|
efff0a825f | ||
|
|
44712c3aad | ||
|
|
d188af4207 | ||
|
|
8a89f0874b | ||
|
|
350379ffff | ||
|
|
65ecbce661 | ||
|
|
2656f33f28 | ||
|
|
e31c2347b4 | ||
|
|
74c2f11209 | ||
|
|
be65b117e0 | ||
|
|
19f05e8509 | ||
|
|
4fde96dccc | ||
|
|
9c38dd1129 | ||
|
|
355e047c27 | ||
|
|
0abc13d3c0 | ||
|
|
4055d9e404 | ||
|
|
ddd0db3ee7 | ||
|
|
ba95fc74f1 | ||
|
|
1da925d0f9 | ||
|
|
eaf5329101 | ||
|
|
dc659c7509 | ||
|
|
310567fd08 | ||
|
|
77059988f8 | ||
|
|
4b87d87883 | ||
|
|
b8997b34eb | ||
|
|
3c6ad78273 | ||
|
|
2cbd60e326 | ||
|
|
7b41ec8a08 | ||
|
|
9be610d41d | ||
|
|
d16063929c | ||
|
|
0ab4329305 | ||
|
|
692a46d8d3 | ||
|
|
8066440bb7 | ||
|
|
aa09229ab2 | ||
|
|
87d59a1816 | ||
|
|
f98edeec7b | ||
|
|
cab93a8b02 | ||
|
|
2b517203be | ||
|
|
08a1288603 | ||
|
|
e5eb7cbee2 | ||
|
|
d5db1cc446 | ||
|
|
02bf796dcc | ||
|
|
396b2c6b6d | ||
|
|
79a2232e55 | ||
|
|
af1628b8ea | ||
|
|
73da13ee39 | ||
|
|
955474f6fc | ||
|
|
1ba8ed87cb | ||
|
|
181e697147 | ||
|
|
bccf76ab7e | ||
|
|
8a1fda41a7 | ||
|
|
da91464adb | ||
|
|
1619856458 | ||
|
|
51eb1bb24f | ||
|
|
812e405a8d | ||
|
|
17fe3c7110 | ||
|
|
c58b1242c8 | ||
|
|
90f2ad2c3b | ||
|
|
9c973fae65 | ||
|
|
ce2c3acd44 | ||
|
|
6202465e3e | ||
|
|
bb2a71f959 | ||
|
|
7dec45ad05 | ||
|
|
9a89dc2d34 | ||
|
|
6ac89de76e | ||
|
|
54c5d01574 | ||
|
|
8412374533 | ||
|
|
35e79a8d46 | ||
|
|
b554637a26 | ||
|
|
3e269c1362 | ||
|
|
02bf511ba2 | ||
|
|
74ad7e5f39 | ||
|
|
25a97bb683 | ||
|
|
972e910722 | ||
|
|
a0f197a535 | ||
|
|
f665a00794 | ||
|
|
acb47daee9 | ||
|
|
fbf0b99ccd | ||
|
|
9b666634e6 | ||
|
|
40ab228ab6 | ||
|
|
19c7b76de1 | ||
|
|
d803f11506 | ||
|
|
70b90453a5 | ||
|
|
7205271b07 | ||
|
|
a6ffc30339 | ||
|
|
f3e3ac9f2e | ||
|
|
f133704176 | ||
|
|
ac956885c1 | ||
|
|
382016dfe1 | ||
|
|
4fefec819c | ||
|
|
c1079d99ba | ||
|
|
6c1f68df48 | ||
|
|
9e68ee8f25 | ||
|
|
74f0bf4dc9 | ||
|
|
9ce320d1c2 | ||
|
|
8ccc09925f | ||
|
|
da10d503d5 | ||
|
|
96cdd810da | ||
|
|
d2f94a6bc3 | ||
|
|
ccd0f0ca9f | ||
|
|
a001b16563 | ||
|
|
cc08d5552b | ||
|
|
bf42f9ad83 | ||
|
|
e511357b6d | ||
|
|
79b96679e0 | ||
|
|
6d29079a09 | ||
|
|
787886669f | ||
|
|
8378f0834c | ||
|
|
286106c0e7 | ||
|
|
600bd55461 | ||
|
|
dc92a55618 | ||
|
|
80ebd88bf7 | ||
|
|
8ae73f9332 | ||
|
|
7352385cc6 | ||
|
|
6ade8b3238 | ||
|
|
87fb22211d | ||
|
|
cbdc515ce7 | ||
|
|
fb3e8535e7 | ||
|
|
1117fb2eca | ||
|
|
f181b32fcc | ||
|
|
430aea04a1 | ||
|
|
7972a60da7 | ||
|
|
f00e8ac590 | ||
|
|
af176c59f3 | ||
|
|
cd9c8723de | ||
|
|
61bbcd5a16 | ||
|
|
edefe37198 | ||
|
|
7059b4ac45 | ||
|
|
e210f9be5e | ||
|
|
bfbb153ab3 | ||
|
|
ffa0911a56 | ||
|
|
6ff61b6c0e | ||
|
|
e85a2de9c4 | ||
|
|
4713b74f1d | ||
|
|
0f5e12064c | ||
|
|
a36dfec9e5 | ||
|
|
c112ecdfc9 | ||
|
|
6279a638dc | ||
|
|
1a208789c5 | ||
|
|
d21e4bebbf | ||
|
|
e0728253bb | ||
|
|
75317a6fe3 | ||
|
|
c7a869c9e9 | ||
|
|
984b715f97 | ||
|
|
a4c2ebd5be | ||
|
|
c4960bd263 | ||
|
|
37a0cf23c6 | ||
|
|
281d9264cc | ||
|
|
7bbc19bb1a | ||
|
|
6161a95f5c | ||
|
|
d371619a9e | ||
|
|
3fef1b4ea1 | ||
|
|
180d3a71eb | ||
|
|
336ca9cc30 | ||
|
|
eeb0e27878 | ||
|
|
57678cc06a | ||
|
|
e19aff7f26 | ||
|
|
fed8d4e387 | ||
|
|
03b0268479 | ||
|
|
d7d0c8aeef | ||
|
|
c1321f9971 | ||
|
|
dff7a9e7f3 | ||
|
|
1fbb96530a | ||
|
|
32c323a553 | ||
|
|
96cd01d6e2 | ||
|
|
f41b9d4131 | ||
|
|
8094bcb3a8 | ||
|
|
d5ead8314c | ||
|
|
d2a2f9fe97 | ||
|
|
cf5f12ed55 | ||
|
|
1d81d02a3f | ||
|
|
3c4982bc5c | ||
|
|
6012926b9f | ||
|
|
fd901eabed | ||
|
|
b79621c47c | ||
|
|
f193dfabe7 | ||
|
|
27b8e944ec | ||
|
|
5d973599fc | ||
|
|
37a74507d1 | ||
|
|
b7fe9ca5f7 | ||
|
|
012bd085aa | ||
|
|
590bc21470 | ||
|
|
e031cd0cf6 | ||
|
|
589bcab7a3 | ||
|
|
87f7238ab7 | ||
|
|
1bcded8b1b | ||
|
|
e0f288b8ed | ||
|
|
61a6c0b18b | ||
|
|
5831a67380 | ||
|
|
b3ba6ee899 | ||
|
|
fc68f914f8 | ||
|
|
eafeac4ea4 | ||
|
|
432c8927f2 | ||
|
|
306aeecded | ||
|
|
75a36959fd | ||
|
|
03c406a5a4 | ||
|
|
74805c102c | ||
|
|
e9268cc022 | ||
|
|
d05faa37a7 | ||
|
|
b0bb1588bc | ||
|
|
e27fecef3c | ||
|
|
b19415a2a4 | ||
|
|
d54c00e138 | ||
|
|
61f71af0c8 | ||
|
|
e8f3bfc7f2 | ||
|
|
33f4e9f642 | ||
|
|
827bc2cf4c | ||
|
|
b8b7ac87f9 | ||
|
|
3463385901 | ||
|
|
2ae3cc8fa8 | ||
|
|
e19b45d39b | ||
|
|
3653373671 | ||
|
|
97202ddd48 | ||
|
|
c5334b70f3 | ||
|
|
47df9ff015 | ||
|
|
4a28c20aad | ||
|
|
0aedda5da2 | ||
|
|
72e46ebbc8 | ||
|
|
573c87ce81 | ||
|
|
3321d690ea | ||
|
|
897f9fc2dc | ||
|
|
6336f67634 | ||
|
|
3e6cb52f6a | ||
|
|
4227c20b83 | ||
|
|
a755eb601c | ||
|
|
191d1e17a1 | ||
|
|
147d5365e8 | ||
|
|
e8c22e7ce7 | ||
|
|
4ca33493a6 | ||
|
|
00a68b64b7 | ||
|
|
495a234781 | ||
|
|
167c01014a | ||
|
|
de21fb4880 | ||
|
|
ab144a4cd8 | ||
|
|
ed624e928b | ||
|
|
7934cbb25b | ||
|
|
34c6199885 | ||
|
|
51f2b1b5ec | ||
|
|
0fc3806db7 | ||
|
|
2fa7ad62b1 | ||
|
|
c2a082f328 | ||
|
|
e39ff6a8cf | ||
|
|
5b9fedcafd | ||
|
|
e062fcad00 | ||
|
|
90310e50bc | ||
|
|
9d1aafe7b6 | ||
|
|
e07e0cb335 | ||
|
|
f87512d326 | ||
|
|
2aff58cea3 | ||
|
|
6e6186d123 | ||
|
|
2ecca64075 | ||
|
|
82d0a3e8c8 | ||
|
|
7fb9b9c11f | ||
|
|
6ba04e3621 | ||
|
|
eadf190dda | ||
|
|
b03f059771 | ||
|
|
00c8d9142e | ||
|
|
6c3982a6fa | ||
|
|
7e302f39c1 | ||
|
|
02653e31b9 | ||
|
|
bf149af099 | ||
|
|
c12af38c28 | ||
|
|
c83ba32982 | ||
|
|
63ef499a62 | ||
|
|
15fb25d923 | ||
|
|
37a2f0a31a | ||
|
|
8394467359 | ||
|
|
3baa8532b9 | ||
|
|
dba175d8f7 | ||
|
|
7974185006 | ||
|
|
8c2f7c163c | ||
|
|
e5a3a41e61 | ||
|
|
a50aac0022 | ||
|
|
9b3241aecb | ||
|
|
ede7df2c00 | ||
|
|
f2752a056d | ||
|
|
4ec65d610c | ||
|
|
f6f1734dc6 | ||
|
|
eed84be6d6 | ||
|
|
93d7679db5 | ||
|
|
cc90a23fac | ||
|
|
190cbeb483 | ||
|
|
089133fe56 | ||
|
|
378f65045c | ||
|
|
3ebb4ebc25 | ||
|
|
1bc745ea59 | ||
|
|
da70bab265 | ||
|
|
9799eae998 | ||
|
|
d797d79374 | ||
|
|
f781d9e244 | ||
|
|
1e17cad975 | ||
|
|
a6bb1f97da | ||
|
|
829adcf220 | ||
|
|
2bd768738a | ||
|
|
ebab571cdc | ||
|
|
181c8dac78 | ||
|
|
834b633c01 | ||
|
|
e596cefc00 | ||
|
|
97d1b184a6 | ||
|
|
743c27f5a9 | ||
|
|
3d27a1a4e1 | ||
|
|
a4cce4e7eb | ||
|
|
632bb81057 | ||
|
|
fb76a1c8a3 | ||
|
|
2a72d4273d | ||
|
|
eaf5fba77e | ||
|
|
2279f841db | ||
|
|
57598edb62 | ||
|
|
fbc3c10d60 | ||
|
|
4ed7e0896c | ||
|
|
c65f8be1b4 | ||
|
|
beefbedc05 | ||
|
|
ec56ac27a3 | ||
|
|
d95b0b112a | ||
|
|
ce2ef197bd | ||
|
|
438e5b71c4 | ||
|
|
98570c95c0 | ||
|
|
5e222f332e | ||
|
|
1cc6bf327a | ||
|
|
c8fcbc138e | ||
|
|
abd3e3a62e | ||
|
|
80baf7e0c5 | ||
|
|
c1ffa46df2 | ||
|
|
36ecb1868a | ||
|
|
022c4db333 | ||
|
|
91858315ca | ||
|
|
14323ca29d | ||
|
|
6458adf7b6 | ||
|
|
728a0ed165 | ||
|
|
4f915fe504 | ||
|
|
5a16e27b59 | ||
|
|
70955832c3 | ||
|
|
6881c9748d | ||
|
|
d11ca0472c | ||
|
|
57784a251c | ||
|
|
4377886f88 | ||
|
|
28cab7d14b | ||
|
|
bd5ea6ddd3 | ||
|
|
6e2ba1056a | ||
|
|
b458854377 | ||
|
|
b29f8bf16b | ||
|
|
d1f852953a | ||
|
|
6287044a20 | ||
|
|
52e9abe311 | ||
|
|
d0bff6bcf5 | ||
|
|
8c42340ea4 | ||
|
|
f45fb56f62 | ||
|
|
127ee9755b | ||
|
|
0c9d5d1fd1 | ||
|
|
50a5186930 | ||
|
|
d51fb03f6b | ||
|
|
7d65acd714 | ||
|
|
0fd8d526e5 | ||
|
|
751f0b13ca | ||
|
|
7273f04d81 | ||
|
|
def94177cd | ||
|
|
e6337ef356 | ||
|
|
d5e7c53401 | ||
|
|
9609216f74 | ||
|
|
8add01aacc | ||
|
|
bdf3784f96 | ||
|
|
186583996d | ||
|
|
af958fdae8 | ||
|
|
148a3eabe7 | ||
|
|
82997a2d8f | ||
|
|
682ecbe392 | ||
|
|
40f3156907 | ||
|
|
966f6ece9b | ||
|
|
a3473882ce | ||
|
|
9260e62870 | ||
|
|
8b4dea758d | ||
|
|
3858f0d51f | ||
|
|
1d723d2a28 | ||
|
|
a43ba964fd | ||
|
|
f7c2506cf2 | ||
|
|
5d484c4fe2 | ||
|
|
4db40e881c | ||
|
|
6c4f00ce42 | ||
|
|
5232ed58f5 | ||
|
|
a76cbec375 | ||
|
|
3a67b488a2 | ||
|
|
553ed22a50 | ||
|
|
889211c785 | ||
|
|
540c14998b | ||
|
|
25169a45f1 | ||
|
|
9fce63aa5a | ||
|
|
c41306961a | ||
|
|
0865bc1071 | ||
|
|
46d63cf414 | ||
|
|
619c5b243d | ||
|
|
1c330cbd9e | ||
|
|
6343cc7970 | ||
|
|
ef0874a111 | ||
|
|
adcc4b4d80 | ||
|
|
caf5c2d45e | ||
|
|
bb273fec87 | ||
|
|
e1c38d3036 | ||
|
|
463e2f76f0 | ||
|
|
172ae8b80f | ||
|
|
d03be98bcb | ||
|
|
decba9d9eb | ||
|
|
3af39d9d0a | ||
|
|
4775113397 | ||
|
|
21a4569b39 | ||
|
|
479c8e7b16 | ||
|
|
c79c5192ee | ||
|
|
d42cdbb434 | ||
|
|
42c3c9f195 | ||
|
|
526908630a | ||
|
|
7d9e9b7b6c | ||
|
|
2dce532bf5 | ||
|
|
083d30bec8 | ||
|
|
d4d8876e92 | ||
|
|
4af626e714 | ||
|
|
ffe06c2db8 | ||
|
|
3154e9163d | ||
|
|
5671e00f3f | ||
|
|
c3d192dc4b | ||
|
|
5aa737bb95 | ||
|
|
24a775a94c | ||
|
|
b26eddec18 | ||
|
|
aeeff97700 | ||
|
|
15f72be36f | ||
|
|
8bd18a5db4 | ||
|
|
eab502bd8e | ||
|
|
1aa101e6a9 | ||
|
|
495f8ede4c | ||
|
|
de2256dfc4 | ||
|
|
0e63e353fe | ||
|
|
3fa1ea460c | ||
|
|
bf472862ca | ||
|
|
d28a6089fc | ||
|
|
8c669ebe61 | ||
|
|
df14e2f085 | ||
|
|
600b4b8cb4 | ||
|
|
e01aae97fd | ||
|
|
36cb5e6c7b | ||
|
|
aaaaf4d2ef | ||
|
|
d1fe589bf8 | ||
|
|
89935ce452 | ||
|
|
db7a840a3f | ||
|
|
b242c67680 | ||
|
|
8ebd327b12 | ||
|
|
04bdd5702b | ||
|
|
811e335a89 | ||
|
|
7a530b37ee | ||
|
|
ee9f75e286 | ||
|
|
e29812d38f | ||
|
|
e4779b9f95 | ||
|
|
772b2f1de6 | ||
|
|
749e691863 | ||
|
|
e59262c519 | ||
|
|
35003e6628 | ||
|
|
cbac083536 | ||
|
|
cf5fedae64 | ||
|
|
d816d53347 | ||
|
|
5b2b297e6e | ||
|
|
ab68ff145f | ||
|
|
ffccf6877c | ||
|
|
b786e30b7a | ||
|
|
72b725862f | ||
|
|
bc330925c0 | ||
|
|
affb71f9dd | ||
|
|
7adc244b94 | ||
|
|
453eee6b69 | ||
|
|
ce57da64cf | ||
|
|
c0e99ad1a3 | ||
|
|
33ab226422 | ||
|
|
15652de96a | ||
|
|
ce7baf7e75 | ||
|
|
11652b0146 | ||
|
|
ffc054a483 | ||
|
|
a60af4d12f | ||
|
|
7096a83db3 | ||
|
|
dc7489570a | ||
|
|
aa694db5a3 | ||
|
|
2be21f5e53 | ||
|
|
f96ec2f869 | ||
|
|
cf077634d8 | ||
|
|
dcd09b8352 | ||
|
|
48a1614e8c | ||
|
|
65def98d70 | ||
|
|
5230537980 | ||
|
|
e2a3342f8d | ||
|
|
be47298166 | ||
|
|
cf93de51be | ||
|
|
59bd6bc212 | ||
|
|
efba49918c | ||
|
|
80c1b58c6e | ||
|
|
0dd1a41153 | ||
|
|
1c253bb218 | ||
|
|
4eb303b1ea | ||
|
|
b0dac9152c | ||
|
|
0357832547 | ||
|
|
55bbd301db | ||
|
|
083a17ee02 | ||
|
|
0e3fcb96ef | ||
|
|
e13e76e1c5 | ||
|
|
d3880a6c83 | ||
|
|
d561a40068 | ||
|
|
fff33508f8 | ||
|
|
5e342dc0ec | ||
|
|
087ea05686 | ||
|
|
5f5a63afa6 | ||
|
|
8aaa4af237 | ||
|
|
7c22c5340e | ||
|
|
34e61e806d | ||
|
|
e401575036 | ||
|
|
2b4ee07fd7 | ||
|
|
33abf2ef03 | ||
|
|
ef34969818 | ||
|
|
7639c30135 | ||
|
|
a5a7439b75 | ||
|
|
fd8729692b | ||
|
|
780b7fb1f9 | ||
|
|
0f943c7f7a | ||
|
|
3c3ba4395b | ||
|
|
2a57596001 | ||
|
|
db465ec26f | ||
|
|
4f896f2710 | ||
|
|
5846f3a226 | ||
|
|
591aac54e9 | ||
|
|
5d24185309 | ||
|
|
5810c1033b | ||
|
|
ef79a2215e | ||
|
|
9614465f5b | ||
|
|
80d90498c9 | ||
|
|
1384c6db9d | ||
|
|
6aec414270 | ||
|
|
24df650056 | ||
|
|
50f7e2711b | ||
|
|
0d35e92bc4 | ||
|
|
1862e06e2b | ||
|
|
9bc6bfd45f | ||
|
|
b838d8d0ed | ||
|
|
6e3947fdfb | ||
|
|
336e4c71c4 | ||
|
|
a9dbff297d | ||
|
|
e1940bae40 | ||
|
|
6d6990785e | ||
|
|
9055a5cb63 | ||
|
|
bcf61cf0bd | ||
|
|
af7b5fabe0 | ||
|
|
a5db484ac3 | ||
|
|
3a0daf2abd | ||
|
|
c6ba3522b9 | ||
|
|
0da7d75b75 | ||
|
|
da9fb7a9f9 | ||
|
|
a26dacdbb1 | ||
|
|
52dae508f7 | ||
|
|
630b941e58 | ||
|
|
42ec9546cf | ||
|
|
ee15d89fa2 | ||
|
|
3621696b13 | ||
|
|
b19969e0b4 | ||
|
|
e05325230c | ||
|
|
8a3d9a6091 | ||
|
|
7bbbaf25dc | ||
|
|
27b5dbd9a3 | ||
|
|
858a73d03f | ||
|
|
f902caae16 | ||
|
|
851f1dcf64 | ||
|
|
56b6f1a9b9 | ||
|
|
2071971c8b | ||
|
|
769f4d1184 | ||
|
|
4a3ef66d9c | ||
|
|
c3307775dc | ||
|
|
9c7cb1aa70 | ||
|
|
206d95dd8a | ||
|
|
237d63417a | ||
|
|
eeacfea88f | ||
|
|
6bf6b440ed | ||
|
|
27829cd3ff | ||
|
|
9068636c21 | ||
|
|
f08e2be652 | ||
|
|
4975865891 | ||
|
|
08158b6167 | ||
|
|
6df4907583 | ||
|
|
935afed07c | ||
|
|
660f2c9b8b | ||
|
|
2184e43c60 | ||
|
|
8032428bbf | ||
|
|
453c9b7aeb | ||
|
|
51cb4202ba | ||
|
|
3d5d965850 | ||
|
|
fe4b1d6d75 | ||
|
|
19bf655776 | ||
|
|
c8a7ab08c4 | ||
|
|
9ad90c43bf | ||
|
|
68e602fa97 | ||
|
|
fee24df408 | ||
|
|
3f388550f7 | ||
|
|
7bca7b7c7a | ||
|
|
e511c0beaa | ||
|
|
0812e937c1 | ||
|
|
b4aa67ecb1 | ||
|
|
a55b816321 | ||
|
|
ee4db04dd9 | ||
|
|
0948930529 | ||
|
|
c122a0c33a | ||
|
|
70c77311c8 | ||
|
|
aafd47647a | ||
|
|
4c9472e006 | ||
|
|
80a3649883 | ||
|
|
0586891b82 | ||
|
|
498ac3a50f | ||
|
|
d0baf85892 | ||
|
|
bb7e1156f0 | ||
|
|
9487d458ab | ||
|
|
2639a5f526 | ||
|
|
5345930379 | ||
|
|
e4903bb43f | ||
|
|
f22623f5d1 | ||
|
|
1e4806ada6 | ||
|
|
f07ec5db87 | ||
|
|
66a992d41c | ||
|
|
fbaa4595cd | ||
|
|
62ea682420 | ||
|
|
981d70747b | ||
|
|
7eae730528 | ||
|
|
40a219f211 | ||
|
|
f3521711c7 | ||
|
|
6e97bf257a | ||
|
|
493736c685 | ||
|
|
402eda1383 | ||
|
|
5e8d0d704a | ||
|
|
e6ce624bf3 | ||
|
|
f2ff4b3425 | ||
|
|
dbcf4dc572 | ||
|
|
edfc6b12b6 | ||
|
|
061a1f69cc | ||
|
|
30cc944d20 | ||
|
|
9e60fd762a | ||
|
|
24e6ccf64b | ||
|
|
f9de73aeda | ||
|
|
370b90716b | ||
|
|
80e897cbf2 | ||
|
|
30e7514754 | ||
|
|
3ec18e6cb0 | ||
|
|
26d429e2b3 | ||
|
|
4f27fdd99f | ||
|
|
a440f83b0a | ||
|
|
c9a6a3b4fc | ||
|
|
162648e8e6 | ||
|
|
71970278a5 | ||
|
|
8fd97e1245 | ||
|
|
2784587243 | ||
|
|
58c112c919 | ||
|
|
ccc7010c8f | ||
|
|
a3880cf953 | ||
|
|
6ca52b40fb | ||
|
|
35a508c30a | ||
|
|
728bab14d9 | ||
|
|
f14ae798be | ||
|
|
f174ee55e4 | ||
|
|
e8e03fde3a | ||
|
|
6fa3ab8dcf | ||
|
|
892d071d42 | ||
|
|
b6a321054e | ||
|
|
741f40c7d4 | ||
|
|
de2f8b81a1 | ||
|
|
26a21fd177 | ||
|
|
79ab732ae1 | ||
|
|
ecdf0fb140 | ||
|
|
f702bf02ed | ||
|
|
08ac241c5e | ||
|
|
9af6d78c1e | ||
|
|
384c0b8087 | ||
|
|
9224c80c92 | ||
|
|
6e4681f33b | ||
|
|
907357fb33 | ||
|
|
30b38c9063 | ||
|
|
ab8e51c497 | ||
|
|
c6930ad1b7 | ||
|
|
b4db2d6516 | ||
|
|
180450189e | ||
|
|
01f839c664 | ||
|
|
47cefa8fb2 | ||
|
|
5cad66697b | ||
|
|
1bbd409235 | ||
|
|
bdf86eb379 | ||
|
|
1261a1aade | ||
|
|
66297898b9 | ||
|
|
0ba5b81993 | ||
|
|
9a5fbb9e2e | ||
|
|
a375cbde45 | ||
|
|
691bc617a1 | ||
|
|
3f0cf0bb10 | ||
|
|
994b9e05eb | ||
|
|
aff96b0800 | ||
|
|
0fc7a3c70a | ||
|
|
a327256d5d | ||
|
|
a3172ed092 | ||
|
|
b92e5f0b50 | ||
|
|
8def830791 | ||
|
|
5f7365e656 | ||
|
|
9a986bc21a | ||
|
|
7633a44adb | ||
|
|
8e88bd1665 | ||
|
|
e64f9f5507 | ||
|
|
66fb456651 | ||
|
|
630d4ce71c | ||
|
|
3bb09ebc23 | ||
|
|
e812286ad7 | ||
|
|
a9a451a565 | ||
|
|
cc328cfda8 | ||
|
|
def7a9733c | ||
|
|
da724fde38 | ||
|
|
abf4643a1d | ||
|
|
734eedebdf | ||
|
|
0da4ecfd91 | ||
|
|
d34063a89b | ||
|
|
08428a8bd3 | ||
|
|
216ea245fd | ||
|
|
a0934614fc | ||
|
|
add2e45c05 | ||
|
|
3649924679 | ||
|
|
cd3c673973 | ||
|
|
1dae038558 | ||
|
|
839f2a2deb | ||
|
|
61c2633834 | ||
|
|
930808cb6d | ||
|
|
aff13c0dc3 | ||
|
|
63cefcd0fb | ||
|
|
872031925d | ||
|
|
2023dfe047 | ||
|
|
fb3f5e4e57 | ||
|
|
94b51af86d | ||
|
|
9f1ad0873d | ||
|
|
74212941ae | ||
|
|
269bc394d1 | ||
|
|
4f25e4e555 | ||
|
|
6065e522c0 | ||
|
|
b10f8bfd6f | ||
|
|
cc49a64de4 | ||
|
|
b572d96811 | ||
|
|
d968c3e329 | ||
|
|
3821d42d8a | ||
|
|
d1858ef240 | ||
|
|
36571f34ae | ||
|
|
a08a4d1c8a | ||
|
|
85e1227ec6 | ||
|
|
14cc437ffe | ||
|
|
de91470edc | ||
|
|
6a59451d7b | ||
|
|
17f98c8d2a | ||
|
|
519288c24c | ||
|
|
2e3cffd9b8 | ||
|
|
d56044f47e | ||
|
|
fe71ea4d71 | ||
|
|
a5f083449a | ||
|
|
4fa25f9a04 | ||
|
|
62b96c7737 | ||
|
|
cd1241e3a8 | ||
|
|
0355e62b93 | ||
|
|
dd6d27e9bf | ||
|
|
1441c85e27 | ||
|
|
ab2878f33c | ||
|
|
c85d97648f | ||
|
|
7ca1d60d4f | ||
|
|
2ddc6f646e | ||
|
|
ec980105a2 | ||
|
|
00bcf2610a | ||
|
|
01c8881a5f | ||
|
|
cc499a99b9 | ||
|
|
2a670f9419 | ||
|
|
9309ce419a | ||
|
|
b771548207 | ||
|
|
26c4aec7d3 | ||
|
|
2c4b9c29a9 | ||
|
|
05419f3569 | ||
|
|
c505806479 | ||
|
|
9f8478b578 | ||
|
|
70c8f4c18d | ||
|
|
6301f4cb46 | ||
|
|
cda7945c58 | ||
|
|
b0e69e04bb | ||
|
|
09e10c5a3c | ||
|
|
62228e6f5d | ||
|
|
249248678d | ||
|
|
12c3d7e0cc | ||
|
|
c7260eacf1 | ||
|
|
1199ca4356 | ||
|
|
76020b84cb | ||
|
|
2ef6093fba | ||
|
|
dc426fc539 | ||
|
|
f84861bea2 | ||
|
|
f5c5b79a47 | ||
|
|
006c369d09 | ||
|
|
57da2aa319 | ||
|
|
accf15760b | ||
|
|
71f434721b | ||
|
|
6b717476f8 | ||
|
|
c6afee53d8 | ||
|
|
42f1cd739c | ||
|
|
95d86f1700 | ||
|
|
6b56a1fb0e | ||
|
|
c7b90c0676 | ||
|
|
c6586b9ec9 | ||
|
|
8560defcf8 | ||
|
|
788cf7e18f | ||
|
|
93e5cf97de | ||
|
|
a18234d724 | ||
|
|
240a24ca41 | ||
|
|
4ec14ba1fb | ||
|
|
f9d0c934f6 | ||
|
|
e986ba01fb | ||
|
|
bbd7a01725 | ||
|
|
d337b7d8b0 | ||
|
|
c163b58980 | ||
|
|
c5d0df2120 | ||
|
|
9c12321906 | ||
|
|
42c398aa6e | ||
|
|
9ef691ebc9 | ||
|
|
f0c704db90 | ||
|
|
8e498b3f8c | ||
|
|
9a27d9a967 | ||
|
|
95b8aa15a0 | ||
|
|
485e2506eb | ||
|
|
b98bad7509 | ||
|
|
d5916445b2 | ||
|
|
17bb7dd92c | ||
|
|
0b48f5f3b7 | ||
|
|
2df3595ac5 | ||
|
|
09d43155a1 | ||
|
|
46f6adebbb | ||
|
|
f4fe395407 | ||
|
|
eebf779b7c | ||
|
|
9d3a8958e1 | ||
|
|
1184b33233 | ||
|
|
664d75d1d2 | ||
|
|
c9b435284b | ||
|
|
7a139fe8fa | ||
|
|
8029860f15 | ||
|
|
6b56aade96 | ||
|
|
54a1d436a8 | ||
|
|
b3ff8de554 | ||
|
|
7ded08677e | ||
|
|
b6b1b3da08 | ||
|
|
3698fb6dad | ||
|
|
9264b58ba4 | ||
|
|
8380c9b67c | ||
|
|
869c4dc6e3 | ||
|
|
2a8bdbce0b | ||
|
|
859b3d3382 | ||
|
|
b4e6be051e | ||
|
|
72e85d0634 | ||
|
|
68b68a58e0 | ||
|
|
0db2d1d0d7 | ||
|
|
1551bf6254 | ||
|
|
8f48aa844f | ||
|
|
cbc094ebbf | ||
|
|
87965d1863 | ||
|
|
b110b945f0 | ||
|
|
85ff4dc202 | ||
|
|
1772a7bbba | ||
|
|
b57079c75a | ||
|
|
9f08f957f3 | ||
|
|
eba966d685 | ||
|
|
f095d8e9f8 | ||
|
|
640ec25699 | ||
|
|
d79787b973 | ||
|
|
602c9a3e1d |
No files matched your search
@@ -0,0 +1,24 @@
|
||||
[
|
||||
{
|
||||
"package": "reasonix/internal/runtime/delegation",
|
||||
"tests": [
|
||||
"TestAncestorCopyAdmissionCoversUnpublishedRun",
|
||||
"TestAncestorCopyAdmissionCoversUnpublishedRun/continue_then_continue",
|
||||
"TestAncestorCopyAdmissionCoversUnpublishedRun/continue_then_legacy",
|
||||
"TestAncestorCopyAdmissionCoversUnpublishedRun/legacy_then_continue",
|
||||
"TestAncestorCopyAdmissionCoversUnpublishedRun/legacy_then_legacy",
|
||||
"TestAncestorCopyAdmissionAllowsDifferentParents",
|
||||
"TestAncestorCopyAdmissionReleasesAbandonedPreparation",
|
||||
"TestAncestorCopyAdmissionReleasesAfterInvalidCopy",
|
||||
"TestAncestorCopyAdmissionAllowsDifferentSources",
|
||||
"TestAncestorCopyAdmissionUsesNearestSourceIdentity",
|
||||
"TestAncestorCopyAdmissionReleasesAfterBusyCurrentReference",
|
||||
"TestTaskAncestorCopyAdmissionBeforeRunningMetadata",
|
||||
"TestTaskAncestorCopyAdmissionBeforeRunningMetadata/continue_from",
|
||||
"TestTaskAncestorCopyAdmissionBeforeRunningMetadata/fork_from",
|
||||
"TestTaskAncestorCopyAdmissionReleasesUnstartedBackground",
|
||||
"TestTaskAncestorCopyAdmissionReleasesUnstartedBackground/continue_from",
|
||||
"TestTaskAncestorCopyAdmissionReleasesUnstartedBackground/fork_from"
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,26 @@
|
||||
[
|
||||
{
|
||||
"package": "reasonix/internal/runtime/agent",
|
||||
"tests": [
|
||||
"TestCarriedProofInvalidatesPermissionChange",
|
||||
"TestCarriedProofFileStateCompatibility",
|
||||
"TestCarriedProofFileStateCompatibility/legacy_file",
|
||||
"TestCarriedProofFileStateCompatibility/legacy_absent",
|
||||
"TestCarriedProofFileStateCompatibility/future",
|
||||
"TestCarriedProofFileStateCompatibility/current_file",
|
||||
"TestCarriedProofFileStateCompatibility/current_absent",
|
||||
"TestCarriedProofRejectsDirectoryState",
|
||||
"TestCarriedProofStandsOnlyForUnchangedContent",
|
||||
"TestCarriedProofSeesADeletion"
|
||||
]
|
||||
},
|
||||
{
|
||||
"package": "reasonix/internal/assembly/boot",
|
||||
"tests": [
|
||||
"TestEffectCarriedProofTracksFileState",
|
||||
"TestEffectCarriedProofTracksFileState/unchanged",
|
||||
"TestEffectCarriedProofTracksFileState/content",
|
||||
"TestEffectCarriedProofTracksFileState/mode"
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,293 @@
|
||||
name: Android
|
||||
|
||||
# The Android shell under `android/` ships from the same tag the desktop line
|
||||
# publishes, and lands on the same release page.
|
||||
#
|
||||
# It is a separate workflow rather than a job inside release-studio.yml for two
|
||||
# reasons. The toolchains share nothing — Go, the Android SDK, a Gradle wrapper —
|
||||
# so a runner carrying both would install three toolchains to build one artifact.
|
||||
# And `signing-contract` pins which workflows may reach the SignPath credentials;
|
||||
# this one never touches them, and keeping it out of that graph is what keeps the
|
||||
# fingerprint stable.
|
||||
#
|
||||
# A failure here does not hold back the desktop release: the two are independent
|
||||
# by construction, and an APK is worth less than the desktop builds it sits
|
||||
# beside.
|
||||
on:
|
||||
push:
|
||||
tags: ['studio-v*']
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: "Existing studio tag to build for and attach to (for example studio-v2.51.0)"
|
||||
required: false
|
||||
type: string
|
||||
ref:
|
||||
description: "A branch to build as a trial run of this workflow; nothing is attached to any release"
|
||||
required: false
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: write # upload the APK to the release the tag belongs to
|
||||
|
||||
concurrency:
|
||||
group: android-${{ inputs.tag || inputs.ref || github.ref_name }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
resolve:
|
||||
name: resolve the tag
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
outputs:
|
||||
tag: ${{ steps.release.outputs.tag }}
|
||||
version: ${{ steps.release.outputs.version }}
|
||||
sha: ${{ steps.release.outputs.sha }}
|
||||
candidate: ${{ steps.release.outputs.candidate }}
|
||||
publish: ${{ steps.release.outputs.publish }}
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
# The same tag grammar release-studio.yml enforces, spelled the same way:
|
||||
# two workflows that disagree about which tags name a release would publish
|
||||
# one artifact for a tag the other refused.
|
||||
- name: Resolve tag and version
|
||||
id: release
|
||||
env:
|
||||
IN_TAG: ${{ inputs.tag }}
|
||||
IN_REF: ${{ inputs.ref }}
|
||||
REF_NAME: ${{ github.ref_name }}
|
||||
run: |
|
||||
# A trial run builds a branch as a throwaway version and publishes nothing.
|
||||
if [ -n "$IN_REF" ] && [ -z "$IN_TAG" ]; then
|
||||
sha="$(git rev-parse --verify "origin/$IN_REF^{commit}")"
|
||||
{ echo "tag="; echo "version=v0.0.0-dev.${sha:0:7}"; echo "sha=$sha"
|
||||
echo "candidate=false"; echo "publish=false"; } >> "$GITHUB_OUTPUT"
|
||||
echo "trial build of $IN_REF at $sha"
|
||||
exit 0
|
||||
fi
|
||||
tag="${IN_TAG:-$REF_NAME}"
|
||||
semver='^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-([0-9A-Za-z-]+)(\.[0-9A-Za-z-]+)*)?$'
|
||||
if [[ ! "$tag" =~ ^studio-v(.+)$ ]] || [[ ! "${BASH_REMATCH[1]}" =~ $semver ]]; then
|
||||
echo "::error::studio release tag must be studio-vMAJOR.MINOR.PATCH[-PRERELEASE], got: $tag"
|
||||
exit 1
|
||||
fi
|
||||
sha="$(git rev-parse --verify "refs/tags/$tag^{commit}")"
|
||||
if ! git merge-base --is-ancestor "$sha" origin/studio; then
|
||||
echo "::error::$tag is not on studio history"
|
||||
exit 1
|
||||
fi
|
||||
version="${tag#studio-}"
|
||||
candidate=false
|
||||
if [[ "${version#v}" =~ $semver ]] && [ -n "${BASH_REMATCH[4]}" ]; then
|
||||
candidate=true
|
||||
fi
|
||||
{ echo "tag=$tag"; echo "version=$version"; echo "sha=$sha"
|
||||
echo "candidate=$candidate"; echo "publish=true"; } >> "$GITHUB_OUTPUT"
|
||||
echo "resolved $tag -> $version at $sha (candidate=$candidate)"
|
||||
|
||||
build:
|
||||
name: build the APK
|
||||
needs: resolve
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
# `publish` decides whether to attach anything, and the only honest answer to
|
||||
# "will this APK install" is whether a key was restored.
|
||||
outputs:
|
||||
signed: ${{ steps.key.outputs.signed }}
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
ref: ${{ needs.resolve.outputs.sha }}
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
cache: true
|
||||
cache-dependency-path: go.sum
|
||||
|
||||
# pnpm first: setup-node's pnpm cache needs the executable to exist.
|
||||
- uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v4
|
||||
with:
|
||||
version: 10
|
||||
|
||||
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7
|
||||
with:
|
||||
node-version: "24"
|
||||
cache: pnpm
|
||||
cache-dependency-path: desktop/frontend-next/pnpm-lock.yaml
|
||||
|
||||
- uses: actions/setup-java@cf277c60eb25467037889841efdb72551f06f6c3 # v4
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "21"
|
||||
cache: gradle
|
||||
|
||||
# The SDK is the one thing nothing sets for you, per android/README.md. The
|
||||
# runner image carries one; this asserts the two pieces the build names
|
||||
# rather than re-installing them, so a missing package fails here by name
|
||||
# instead of surfacing later as an unresolved compileSdk.
|
||||
- name: Provide the Android SDK
|
||||
run: |
|
||||
if [ -z "${ANDROID_HOME:-}" ]; then
|
||||
echo "::error::ANDROID_HOME is unset; the runner image is expected to provide the Android SDK"
|
||||
exit 1
|
||||
fi
|
||||
for piece in "platforms/android-35" "build-tools/35.0.0"; do
|
||||
if [ ! -d "$ANDROID_HOME/$piece" ]; then
|
||||
echo "::error::the Android SDK at $ANDROID_HOME has no $piece; android/README.md names it as a requirement"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
echo "ANDROID_SDK_ROOT=$ANDROID_HOME" >> "$GITHUB_ENV"
|
||||
|
||||
# A key that exists signs; a key that does not leaves the artifact
|
||||
# unsigned, which is what a release with no key has always produced. The
|
||||
# decision is the repository's, not this workflow's: it reads the four
|
||||
# secrets android/README.md already names.
|
||||
- name: Restore the signing key
|
||||
id: key
|
||||
env:
|
||||
KEYSTORE_BASE64: ${{ secrets.REASONIX_KEYSTORE_BASE64 }}
|
||||
STORE_PASSWORD: ${{ secrets.REASONIX_KEYSTORE_PASSWORD }}
|
||||
KEY_PASSWORD: ${{ secrets.REASONIX_KEY_PASSWORD }}
|
||||
KEY_ALIAS: ${{ secrets.REASONIX_KEY_ALIAS }}
|
||||
run: |
|
||||
if [ -z "$KEYSTORE_BASE64" ] || [ -z "$STORE_PASSWORD" ] || [ -z "$KEY_ALIAS" ]; then
|
||||
echo "signed=false" >> "$GITHUB_OUTPUT"
|
||||
echo "no signing key configured: the APK will be unsigned"
|
||||
exit 0
|
||||
fi
|
||||
mkdir -p android/keystore
|
||||
printf '%s' "$KEYSTORE_BASE64" | base64 --decode > android/keystore/release.jks
|
||||
{
|
||||
echo "storeFile=keystore/release.jks"
|
||||
echo "storePassword=${STORE_PASSWORD}"
|
||||
echo "keyAlias=${KEY_ALIAS}"
|
||||
# PKCS#12 keystores use one password for both; only write the second
|
||||
# when it is genuinely a different fact.
|
||||
[ -n "$KEY_PASSWORD" ] && echo "keyPassword=${KEY_PASSWORD}"
|
||||
} > android/keystore/keystore.properties
|
||||
chmod 600 android/keystore/*
|
||||
echo "signed=true" >> "$GITHUB_OUTPUT"
|
||||
echo "signing key restored"
|
||||
|
||||
- name: Build the kernel and the page
|
||||
working-directory: android
|
||||
env:
|
||||
VERSION: ${{ needs.resolve.outputs.version }}
|
||||
run: |
|
||||
export REASONIX_APP_VERSION="${VERSION#v}"
|
||||
bash ./scripts/build-kernel.sh
|
||||
bash ./scripts/sync-page.sh
|
||||
|
||||
# The one end-to-end check that needs no phone: the kernel the shell starts,
|
||||
# serving the page the APK carries. Running it here means an APK that would
|
||||
# open blank fails the build instead of the install.
|
||||
- name: Verify the packaged page
|
||||
working-directory: android
|
||||
run: |
|
||||
(cd .. && go build -o reasonix-verify ./cmd/reasonix)
|
||||
KERNEL_BIN="$PWD/../reasonix-verify" bash ./scripts/verify-page.sh
|
||||
|
||||
- name: Assemble
|
||||
working-directory: android
|
||||
env:
|
||||
VERSION: ${{ needs.resolve.outputs.version }}
|
||||
run: |
|
||||
# The version comes from the tag, so the About screen and the update
|
||||
# check name this release. versionCode keeps its existing shape, which
|
||||
# is why the build digit stays 1: it is a single decimal place, and a
|
||||
# number that grows past it would collide with the next patch.
|
||||
./gradlew assembleRelease --no-daemon "-PreasonixVersion=${VERSION#v}"
|
||||
|
||||
- name: Name the artifact
|
||||
id: apk
|
||||
working-directory: android/app/build/outputs/apk/release
|
||||
run: |
|
||||
src="$(ls ./*.apk | head -n 1)"
|
||||
if [ -z "$src" ]; then
|
||||
echo "::error::assembleRelease produced no APK"
|
||||
exit 1
|
||||
fi
|
||||
case "$src" in
|
||||
*-unsigned.apk) name="ReasonixMobile-${{ needs.resolve.outputs.version }}-unsigned.apk" ;;
|
||||
*) name="ReasonixMobile-${{ needs.resolve.outputs.version }}.apk" ;;
|
||||
esac
|
||||
mv "$src" "$name"
|
||||
sha256sum "$name" > "$name.sha256"
|
||||
{
|
||||
echo "path=$PWD/$name"
|
||||
echo "name=$name"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
ls -l "$name" "$name.sha256"
|
||||
|
||||
# An unsigned package is not a lesser APK, it is an unusable one: Android's
|
||||
# package manager rejects it outright rather than reporting a signature
|
||||
# mismatch. Say so where the run summary can be read, instead of letting a
|
||||
# green build imply a shippable artifact.
|
||||
- name: Say what an unsigned build means
|
||||
if: steps.key.outputs.signed != 'true'
|
||||
run: |
|
||||
echo "::warning::no signing key configured, so this APK is unsigned. Android refuses to install an unsigned package, so the release will carry no APK; the file stays a workflow artifact for whoever signs it."
|
||||
{
|
||||
echo "### No APK on this release"
|
||||
echo ""
|
||||
echo "The build produced an unsigned APK, which Android refuses to install."
|
||||
echo "It is attached to this run as a workflow artifact only. Add the four"
|
||||
echo "signing secrets to publish one."
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v8
|
||||
with:
|
||||
name: android-apk
|
||||
path: |
|
||||
android/app/build/outputs/apk/release/*.apk
|
||||
android/app/build/outputs/apk/release/*.sha256
|
||||
if-no-files-found: error
|
||||
|
||||
publish:
|
||||
name: attach the APK
|
||||
needs: [resolve, build]
|
||||
# Nothing to attach unless the build was signed. Skipping is the honest
|
||||
# outcome: a release page with an APK nobody can install is worse than a
|
||||
# release page without one.
|
||||
if: needs.build.outputs.signed == 'true' && needs.resolve.outputs.publish == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
||||
with:
|
||||
name: android-apk
|
||||
path: dist
|
||||
|
||||
# The release itself belongs to release-studio.yml, which creates it and
|
||||
# owns its notes. This waits for it rather than creating a second one: two
|
||||
# jobs racing to define a release is how a page ends up with the wrong body.
|
||||
- name: Attach to the release
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
TAG: ${{ needs.resolve.outputs.tag }}
|
||||
REPO: ${{ github.repository }}
|
||||
run: |
|
||||
# 90 minutes: on the last Studio release the page appeared about 40
|
||||
# minutes after the tag run started, and the APK build spends part of
|
||||
# that before this wait begins.
|
||||
for _ in $(seq 1 360); do
|
||||
if gh release view "$TAG" --repo "$REPO" >/dev/null 2>&1; then
|
||||
gh release upload "$TAG" dist/*.apk dist/*.sha256 --repo "$REPO" --clobber
|
||||
echo "attached to $TAG"
|
||||
exit 0
|
||||
fi
|
||||
sleep 15
|
||||
done
|
||||
echo "::error::release $TAG did not appear within 90 minutes; the APK was built but not published"
|
||||
exit 1
|
||||
@@ -170,6 +170,23 @@ jobs:
|
||||
shell: pwsh
|
||||
run: Get-CimInstance Win32_Processor | Select-Object -ExpandProperty Name
|
||||
|
||||
- name: test (Windows concurrency contracts)
|
||||
if: env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name == 'pull_request'
|
||||
timeout-minutes: 5
|
||||
shell: bash
|
||||
run: |
|
||||
node --test scripts/windows-concurrency-tests.test.mjs
|
||||
node scripts/windows-concurrency-tests.mjs "$RUNNER_TEMP/concurrency-windows.jsonl"
|
||||
|
||||
- name: upload Windows concurrency inventory
|
||||
if: always() && env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name == 'pull_request'
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7
|
||||
with:
|
||||
name: concurrency-windows-${{ github.run_attempt }}
|
||||
path: ${{ runner.temp }}/concurrency-windows.jsonl
|
||||
if-no-files-found: error
|
||||
retention-days: 7
|
||||
|
||||
- name: test
|
||||
if: env.RUN_STEPS == 'true' && runner.os != 'Windows'
|
||||
env:
|
||||
@@ -179,6 +196,35 @@ jobs:
|
||||
REASONIX_RELEASE_CACHE_GUARD: "1"
|
||||
run: go test ./...
|
||||
|
||||
- name: test (Windows subagent status admission)
|
||||
if: env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name == 'pull_request'
|
||||
timeout-minutes: 5
|
||||
shell: bash
|
||||
run: |
|
||||
set -o pipefail
|
||||
go test -count=1 -timeout=3m -json -run '^TestSubagent(PersistedStatusAdmission|UnknownStatusStillReadableForDiagnosis)$' ./internal/runtime/delegation | tee "$RUNNER_TEMP/subagent-status-admission.jsonl"
|
||||
jq -se '
|
||||
[.[] | select(.Test == null and .Action == "pass") | .Package] as $packages |
|
||||
[.[] | select(.Test != null and .Action == "pass") | .Test] as $tests |
|
||||
["future-paused", "missing", "empty", "COMPLETED", "completed", "cancelled", "failed", "interrupted", "running"] as $states |
|
||||
["continue", "copy", "ancestor", "legacy_ancestor", "current_copy"] as $routes |
|
||||
(["TestSubagentPersistedStatusAdmission", "TestSubagentUnknownStatusStillReadableForDiagnosis"] +
|
||||
[$states[] | "TestSubagentPersistedStatusAdmission/" + .] +
|
||||
[$states[] as $state | $routes[] | "TestSubagentPersistedStatusAdmission/" + $state + "/" + .]) as $required |
|
||||
all(.[]; .Action != "skip" and .Action != "fail") and
|
||||
(["reasonix/internal/runtime/delegation"] - $packages | length) == 0 and
|
||||
($required - $tests | length) == 0
|
||||
' "$RUNNER_TEMP/subagent-status-admission.jsonl"
|
||||
|
||||
- name: upload Windows subagent status admission inventory
|
||||
if: always() && env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name == 'pull_request'
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7
|
||||
with:
|
||||
name: subagent-status-admission-windows-${{ github.run_attempt }}
|
||||
path: ${{ runner.temp }}/subagent-status-admission.jsonl
|
||||
if-no-files-found: ignore
|
||||
retention-days: 7
|
||||
|
||||
- name: test (Windows composition links)
|
||||
if: env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name == 'pull_request'
|
||||
timeout-minutes: 5
|
||||
@@ -198,7 +244,8 @@ jobs:
|
||||
|
||||
# Pull requests run a Windows smoke suite: the packages that actually
|
||||
# carry *_windows.go code, the startup/checkpoint packages with portable
|
||||
# filesystem contracts, plus cmd/. The full ./... sweep stays on pushes
|
||||
# filesystem contracts, plus cmd/. Isolation/worktree/mutation have a
|
||||
# complete JSON inventory below. The full ./... sweep stays on pushes
|
||||
# to main-v2; the Unix legs always run the full suite. Keep the job-level
|
||||
# budget above normal 7-9 minute runner variance; each package test binary
|
||||
# remains independently bounded by Go's per-package timeout below.
|
||||
@@ -222,11 +269,728 @@ jobs:
|
||||
WINDOWS_SANDBOX_WAIT_MS: "20000"
|
||||
run: go test -count=1 -p 4 -timeout=18m ./internal/runtime/agent/... ./internal/platform/appidentity/... ./internal/assembly/boot/... ./internal/state/checkpoint/... ./internal/frontend/cli/... ./internal/session/control/... ./internal/base/filelock/... ./internal/base/fileutil/... ./internal/ext/hook/... ./internal/state/instruction/... ./internal/ext/mcplaunch/... ./internal/platform/notify/... ./internal/base/proc/... ./internal/platform/remote/... ./internal/platform/repair/... ./internal/safety/sandbox/... ./internal/base/sysproxy/... ./internal/state/workspacelease/... ./cmd/...
|
||||
|
||||
# Worktree rollback is absent from the broad Windows PR smoke list.
|
||||
- name: test (Windows worktree rollback)
|
||||
- name: test (Windows published command job owner)
|
||||
if: env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name == 'pull_request'
|
||||
timeout-minutes: 3
|
||||
run: go test -count=1 -timeout=2m -run '^TestDiscardUnpublished.*OnWindows$|^TestCreateFromDetachedHEADAndOccupiedBranch$' ./internal/platform/worktree
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
evidence="$RUNNER_TEMP/published-job-owner"
|
||||
mkdir -p "$evidence"
|
||||
go test -count=1 -timeout=3m -json -run '^TestPublishedJob' ./internal/base/proc | tee "$evidence/positive.jsonl"
|
||||
node scripts/published-job-evidence.mjs positive "$evidence/positive.jsonl"
|
||||
node scripts/published-job-evidence.mjs overlay "$evidence"
|
||||
set +e
|
||||
go test -count=1 -timeout=3m -json -overlay="$evidence/overlay.json" -run '^TestPublishedJob' ./internal/base/proc > "$evidence/negative.jsonl"
|
||||
old_result=$?
|
||||
set -e
|
||||
test "$old_result" -eq 1
|
||||
node scripts/published-job-evidence.mjs negative "$evidence/negative.jsonl"
|
||||
|
||||
- name: upload Windows published command job owner evidence
|
||||
if: always() && env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name == 'pull_request'
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7
|
||||
with:
|
||||
name: published-job-owner-windows-${{ github.run_attempt }}
|
||||
path: ${{ runner.temp }}/published-job-owner/
|
||||
if-no-files-found: error
|
||||
retention-days: 7
|
||||
|
||||
- name: test (Windows isolation owners)
|
||||
if: env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name == 'pull_request'
|
||||
timeout-minutes: 25
|
||||
shell: bash
|
||||
run: |
|
||||
set -o pipefail
|
||||
go test -count=1 -p 4 -timeout=18m -json ./internal/runtime/isolation/... ./internal/platform/worktree/... ./internal/state/mutation/... | tee "$RUNNER_TEMP/isolation-owners.jsonl" |
|
||||
jq -rj --unbuffered 'select(.Action == "output" and .Test == null) | .Output'
|
||||
jq -se '
|
||||
[.[] | select(.Test == null and .Action == "pass") | .Package] as $passed |
|
||||
[.[] | select(.Test != null and .Action == "pass") | .Test] as $tests |
|
||||
(["reasonix/internal/runtime/isolation", "reasonix/internal/platform/worktree", "reasonix/internal/state/mutation"] - $passed | length) == 0 and
|
||||
(["TestResultOperationUsesCrossProcessOwnership",
|
||||
"TestActualResultOperationHoldsOwnershipThroughWriteAndCleanup/restore",
|
||||
"TestActualResultOperationHoldsOwnershipThroughWriteAndCleanup/remove",
|
||||
"TestApplyPathsWithUntrustedModesPreservesTheIndex/content",
|
||||
"TestApplyPathsWithUntrustedModesPreservesTheIndex/source_index_mode",
|
||||
"TestApplyPathsWithUntrustedModesPreservesTheIndex/candidate_mode",
|
||||
"TestApplyPathsWithUntrustedModesPreservesTheIndex/candidate_type",
|
||||
"TestApplyPathsEmulatedLinksUseSnapshotFilters",
|
||||
"TestHeldPathsCoverOnlyTheSameFileAcrossCaseSpellings",
|
||||
"TestLegacyWaitDoesNotConsumeExecutionCapacity"] - $tests | length) == 0
|
||||
' "$RUNNER_TEMP/isolation-owners.jsonl"
|
||||
jq -se '
|
||||
. as $events |
|
||||
{"reasonix/internal/platform/worktree": [
|
||||
"TestApplyPathsRejectsRedirectedAncestors",
|
||||
"TestApplyPathsRejectsRedirectedAncestors/outside_delete",
|
||||
"TestApplyPathsRejectsRedirectedAncestors/inside_delete",
|
||||
"TestApplyPathsRejectsRedirectedAncestors/dangling_delete",
|
||||
"TestApplyPathsRejectsRedirectedAncestors/outside_write",
|
||||
"TestApplyPathsRejectsRedirectedAncestors/inside_write",
|
||||
"TestApplyPathsRejectsRedirectedAncestors/regular_parent",
|
||||
"TestApplyPathsRejectsRedirectedAncestors/unchanged",
|
||||
"TestApplyPathsRejectsRedirectedAncestors/missing_creation",
|
||||
"TestWriteChangesRejectsRedirectedDeletion",
|
||||
"TestWriteChangesUnlinksLeafDirectoryLink",
|
||||
"TestWholeWorkspaceApplyPreservesFileToDirectoryTransition"],
|
||||
"reasonix/internal/runtime/isolation": [
|
||||
"TestApplyToolRetainsResultAfterAncestorReplacement"]} |
|
||||
to_entries | all(.[]; . as $package |
|
||||
([ $events[] | select(.Package == $package.key and .Test == null and
|
||||
(.Action == "pass" or .Action == "fail" or .Action == "skip")) ] |
|
||||
length == 1 and .[0].Action == "pass") and
|
||||
all(.value[]; . as $test |
|
||||
[ $events[] | select(.Package == $package.key and .Test == $test and
|
||||
(.Action == "pass" or .Action == "fail" or .Action == "skip")) ] |
|
||||
length == 1 and .[0].Action == "pass"))
|
||||
' "$RUNNER_TEMP/isolation-owners.jsonl"
|
||||
|
||||
- name: upload Windows isolation test inventory
|
||||
if: always() && env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name == 'pull_request'
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7
|
||||
with:
|
||||
name: isolation-owners-windows-${{ github.run_attempt }}
|
||||
path: ${{ runner.temp }}/isolation-owners.jsonl
|
||||
if-no-files-found: ignore
|
||||
retention-days: 7
|
||||
|
||||
- name: test (Windows background resource owners)
|
||||
if: env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name == 'pull_request'
|
||||
timeout-minutes: 25
|
||||
shell: bash
|
||||
run: |
|
||||
set -o pipefail
|
||||
go test -count=1 -p 2 -timeout=18m -json ./internal/tools/jobs/... ./internal/runtime/delegation/... | tee "$RUNNER_TEMP/background-resources.jsonl" |
|
||||
jq -rj --unbuffered 'select(.Action == "output" and .Test == null) | .Output'
|
||||
jq -se '
|
||||
[.[] | select(.Test == null and .Action == "pass") | .Package] as $packages |
|
||||
[.[] | select(.Test != null and .Action == "pass") | .Test] as $tests |
|
||||
(["reasonix/internal/tools/jobs", "reasonix/internal/runtime/delegation"] - $packages | length) == 0 and
|
||||
(["TestJobFinalizationCoversInvokedAndUninvoked/success",
|
||||
"TestJobFinalizationCoversInvokedAndUninvoked/error",
|
||||
"TestJobFinalizationCoversInvokedAndUninvoked/panic",
|
||||
"TestJobFinalizationCoversInvokedAndUninvoked/closed",
|
||||
"TestJobFinalizationCoversInvokedAndUninvoked/invalid_session",
|
||||
"TestJobFinalizationCoversInvokedAndUninvoked/invalid_kind",
|
||||
"TestJobFinalizationFailuresAttemptAllCallbacksAndKeepCauses/invoked",
|
||||
"TestJobFinalizationFailuresAttemptAllCallbacksAndKeepCauses/uninvoked",
|
||||
"TestJobFinalizationPrecedesCompletionPublication",
|
||||
"TestJobFinalizationCloseBetweenPublicationAndLaunch",
|
||||
"TestJobFinalizationWaitsForClosureAfterLogicalCancellation",
|
||||
"TestBackgroundNoLaunchFinalizesOwnedResources/task/closed_manager",
|
||||
"TestBackgroundNoLaunchFinalizesOwnedResources/task/invalid_session",
|
||||
"TestBackgroundNoLaunchFinalizesOwnedResources/fleet/closed_manager",
|
||||
"TestBackgroundNoLaunchFinalizesOwnedResources/fleet/invalid_session"] - $tests | length) == 0
|
||||
' "$RUNNER_TEMP/background-resources.jsonl"
|
||||
|
||||
- name: upload Windows background resource inventory
|
||||
if: always() && env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name == 'pull_request'
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7
|
||||
with:
|
||||
name: background-resources-windows-${{ github.run_attempt }}
|
||||
path: ${{ runner.temp }}/background-resources.jsonl
|
||||
if-no-files-found: ignore
|
||||
retention-days: 7
|
||||
|
||||
- name: test (Windows delegated Goal usage scope)
|
||||
if: env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name == 'pull_request'
|
||||
shell: bash
|
||||
run: |
|
||||
set -o pipefail
|
||||
go test -count=1 -timeout=3m -json -run '^Test(UsageScope.*|DelegatedUsageKeepsGoalOwner|EffectDelegatedUsageKeepsGoalOwner)$' ./internal/contract/event ./internal/session/control ./internal/assembly/boot | tee "$RUNNER_TEMP/goal-usage-scope.jsonl"
|
||||
jq -se '
|
||||
["TestUsageScopePreservesCapturedOwnerAndWire", "TestUsageScopeKeepsEarlierEventOwner",
|
||||
"TestUsageScopeWithoutCapability", "TestDelegatedUsageKeepsGoalOwner",
|
||||
"TestDelegatedUsageKeepsGoalOwner/chat_to_goal", "TestDelegatedUsageKeepsGoalOwner/replaced_goal",
|
||||
"TestDelegatedUsageKeepsGoalOwner/cleared_goal", "TestDelegatedUsageKeepsGoalOwner/resumed_goal",
|
||||
"TestDelegatedUsageKeepsGoalOwner/same_goal_next_turn", "TestDelegatedUsageKeepsGoalOwner/between_turns",
|
||||
"TestDelegatedUsageKeepsGoalOwner/nested_after_replacement", "TestEffectDelegatedUsageKeepsGoalOwner",
|
||||
"TestEffectDelegatedUsageKeepsGoalOwner/chat_to_goal", "TestEffectDelegatedUsageKeepsGoalOwner/replaced_goal",
|
||||
"TestEffectDelegatedUsageKeepsGoalOwner/nested_chat_to_goal", "TestEffectDelegatedUsageKeepsGoalOwner/same_goal",
|
||||
"TestEffectDelegatedUsageKeepsGoalOwner/completed_before_goal"] as $required |
|
||||
[.[] | select(.Test != null and .Action == "pass") | .Test] as $passed |
|
||||
[.[] | select(.Test == null and .Action == "pass") | .Package] as $packages |
|
||||
all(.[]; .Action != "fail" and .Action != "skip") and
|
||||
(($required - $passed) | length == 0) and
|
||||
((["reasonix/internal/contract/event", "reasonix/internal/session/control", "reasonix/internal/assembly/boot"] - $packages) | length == 0)
|
||||
' "$RUNNER_TEMP/goal-usage-scope.jsonl"
|
||||
|
||||
- name: upload Windows delegated Goal usage scope inventory
|
||||
if: always() && env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name == 'pull_request'
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7
|
||||
with:
|
||||
name: goal-usage-scope-windows-${{ github.run_attempt }}
|
||||
path: ${{ runner.temp }}/goal-usage-scope.jsonl
|
||||
if-no-files-found: error
|
||||
retention-days: 7
|
||||
|
||||
- name: test (Windows planner local budget)
|
||||
if: env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name == 'pull_request'
|
||||
timeout-minutes: 5
|
||||
shell: bash
|
||||
run: |
|
||||
set -o pipefail
|
||||
go test -count=1 -timeout=3m -json -run '^TestEffectPlannerConfiguredTaskBudget$' ./internal/assembly/boot | tee "$RUNNER_TEMP/planner-local-budget.jsonl"
|
||||
jq -se '
|
||||
[.[] | select(.Test == null and .Action == "pass") | .Package] as $packages |
|
||||
[.[] | select(.Test != null and .Action == "pass") | .Test] as $tests |
|
||||
all(.[]; .Action != "skip" and .Action != "fail") and
|
||||
(["reasonix/internal/assembly/boot"] - $packages | length) == 0 and
|
||||
(["TestEffectPlannerConfiguredTaskBudget", "TestEffectPlannerConfiguredTaskBudget/tokens",
|
||||
"TestEffectPlannerConfiguredTaskBudget/cost", "TestEffectPlannerConfiguredTaskBudget/wall",
|
||||
"TestEffectPlannerConfiguredTaskBudget/unconfigured", "TestEffectPlannerConfiguredTaskBudget/explicit_zero",
|
||||
"TestEffectPlannerConfiguredTaskBudget/unpriced_cost", "TestEffectPlannerConfiguredTaskBudget/missing_usage_cost",
|
||||
"TestEffectPlannerConfiguredTaskBudget/context_bound", "TestEffectPlannerConfiguredTaskBudget/context_disables",
|
||||
"TestEffectPlannerConfiguredTaskBudget/executor_only"] - $tests | length) == 0
|
||||
' "$RUNNER_TEMP/planner-local-budget.jsonl"
|
||||
|
||||
- name: upload Windows planner local budget inventory
|
||||
if: always() && env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name == 'pull_request'
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7
|
||||
with:
|
||||
name: planner-local-budget-windows-${{ github.run_attempt }}
|
||||
path: ${{ runner.temp }}/planner-local-budget.jsonl
|
||||
if-no-files-found: ignore
|
||||
retention-days: 7
|
||||
|
||||
- name: test (Windows Controller routing root budget)
|
||||
if: env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name == 'pull_request'
|
||||
timeout-minutes: 5
|
||||
shell: bash
|
||||
run: |
|
||||
set -o pipefail
|
||||
go test -count=1 -timeout=3m -json -run '^Test(SemanticRootUsage|EffectRoutingRootBudget)$' ./internal/runtime/capability ./internal/assembly/boot | tee "$RUNNER_TEMP/controller-routing-budget.jsonl"
|
||||
jq -se '
|
||||
[.[] | select(.Test == null and .Action == "pass") | .Package] as $packages |
|
||||
[.[] | select(.Test != null and .Action == "pass") | .Test] as $tests |
|
||||
all(.[]; .Action != "skip" and .Action != "fail") and
|
||||
(["reasonix/internal/runtime/capability", "reasonix/internal/assembly/boot"] - $packages | length) == 0 and
|
||||
([
|
||||
"TestEffectRoutingRootBudget",
|
||||
"TestEffectRoutingRootBudget/balanced",
|
||||
"TestEffectRoutingRootBudget/cached_continuation",
|
||||
"TestEffectRoutingRootBudget/cached_new_scope",
|
||||
"TestEffectRoutingRootBudget/cached_new_task",
|
||||
"TestEffectRoutingRootBudget/cached_reset",
|
||||
"TestEffectRoutingRootBudget/context_zero",
|
||||
"TestEffectRoutingRootBudget/dual",
|
||||
"TestEffectRoutingRootBudget/headless",
|
||||
"TestEffectRoutingRootBudget/interactive",
|
||||
"TestEffectRoutingRootBudget/missing",
|
||||
"TestEffectRoutingRootBudget/unconfigured",
|
||||
"TestSemanticRootUsage",
|
||||
"TestSemanticRootUsage/billed_error",
|
||||
"TestSemanticRootUsage/cache",
|
||||
"TestSemanticRootUsage/estimated",
|
||||
"TestSemanticRootUsage/invalid_answer",
|
||||
"TestSemanticRootUsage/missing",
|
||||
"TestSemanticRootUsage/nil_sink",
|
||||
"TestSemanticRootUsage/no_observer",
|
||||
"TestSemanticRootUsage/reported"
|
||||
] - $tests | length) == 0
|
||||
' "$RUNNER_TEMP/controller-routing-budget.jsonl"
|
||||
|
||||
- name: upload Windows Controller routing root budget inventory
|
||||
if: always() && env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name == 'pull_request'
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7
|
||||
with:
|
||||
name: controller-routing-budget-windows-${{ github.run_attempt }}
|
||||
path: ${{ runner.temp }}/controller-routing-budget.jsonl
|
||||
if-no-files-found: ignore
|
||||
retention-days: 7
|
||||
|
||||
- name: test (Windows Coordinator root budget)
|
||||
if: env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name == 'pull_request'
|
||||
timeout-minutes: 5
|
||||
shell: bash
|
||||
run: |
|
||||
set -o pipefail
|
||||
go test -count=1 -timeout=3m -json -run '^Test(PreparedRootBudget|CoordinatorRootBudget|EffectCoordinatorRoot)' ./internal/runtime/agent ./internal/runtime/coordinator ./internal/assembly/boot | tee "$RUNNER_TEMP/coordinator-root-budget.jsonl"
|
||||
jq -se '
|
||||
[.[] | select(.Test == null and .Action == "pass") | .Package] as $packages |
|
||||
[.[] | select(.Test != null and .Action == "pass") | .Test] as $tests |
|
||||
all(.[]; .Action != "skip" and .Action != "fail") and
|
||||
(["reasonix/internal/runtime/agent", "reasonix/internal/runtime/coordinator", "reasonix/internal/assembly/boot"] - $packages | length) == 0 and
|
||||
(["TestPreparedRootBudgetLifetime", "TestPreparedRootBudgetLifetime/continuation",
|
||||
"TestPreparedRootBudgetLifetime/new_scope", "TestPreparedRootBudgetLifetime/new_task",
|
||||
"TestPreparedRootBudgetLifetime/reset", "TestPreparedRootBudgetLifetime/new_session",
|
||||
"TestPreparedRootBudgetProjectionAndPreservedTurn", "TestCoordinatorRootBudgetRoutes",
|
||||
"TestCoordinatorRootBudgetRoutes/raw", "TestCoordinatorRootBudgetRoutes/tool_planner",
|
||||
"TestCoordinatorRootBudgetRoutes/failure_fallback", "TestCoordinatorRootBudgetRoutes/plan_only_continuation",
|
||||
"TestCoordinatorRootBudgetRoutes/approval_declined_continuation", "TestCoordinatorRootBudgetRoutes/approval_allowed",
|
||||
"TestEffectCoordinatorRootTokenBudget", "TestEffectCoordinatorRootTokenBudget/shared",
|
||||
"TestEffectCoordinatorRootTokenBudget/unconfigured", "TestEffectCoordinatorRootTokenBudget/context_disables",
|
||||
"TestEffectCoordinatorRootTokenBudget/missing_planner", "TestEffectCoordinatorRootTokenBudget/executor_only"] - $tests | length) == 0
|
||||
' "$RUNNER_TEMP/coordinator-root-budget.jsonl"
|
||||
|
||||
- name: upload Windows Coordinator root budget inventory
|
||||
if: always() && env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name == 'pull_request'
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7
|
||||
with:
|
||||
name: coordinator-root-budget-windows-${{ github.run_attempt }}
|
||||
path: ${{ runner.temp }}/coordinator-root-budget.jsonl
|
||||
if-no-files-found: ignore
|
||||
retention-days: 7
|
||||
|
||||
- name: test (Windows resumed Goal planner budget)
|
||||
if: env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name == 'pull_request'
|
||||
shell: bash
|
||||
run: |
|
||||
set -o pipefail
|
||||
go test -count=1 -timeout=3m -json -run '^Test(GoalResumeRenewsPlannerBudget|GoalNonBudgetResumeKeepsPlannerBudget|GoalTokenBudgetPausesAndResumes|EffectGoalResumeRenewsPlannerBudget)$' ./internal/session/control ./internal/assembly/boot | tee "$RUNNER_TEMP/goal-planner-resume.jsonl"
|
||||
jq -se '
|
||||
["TestGoalResumeRenewsPlannerBudget",
|
||||
"TestGoalNonBudgetResumeKeepsPlannerBudget",
|
||||
"TestGoalNonBudgetResumeKeepsPlannerBudget/manual_pause",
|
||||
"TestGoalNonBudgetResumeKeepsPlannerBudget/not_resumable",
|
||||
"TestGoalTokenBudgetPausesAndResumes",
|
||||
"TestEffectGoalResumeRenewsPlannerBudget"] as $required |
|
||||
[.[] | select(.Test != null and .Action == "pass") | .Test] as $passed |
|
||||
[.[] | select(.Test == null and .Action == "pass") | .Package] as $packages |
|
||||
all(.[]; .Action != "fail" and .Action != "skip") and
|
||||
(($required - $passed) | length == 0) and
|
||||
((["reasonix/internal/session/control", "reasonix/internal/assembly/boot"] - $packages) | length == 0)
|
||||
' "$RUNNER_TEMP/goal-planner-resume.jsonl"
|
||||
|
||||
- name: upload Windows resumed Goal planner budget inventory
|
||||
if: always() && env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name == 'pull_request'
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7
|
||||
with:
|
||||
name: goal-planner-resume-windows-${{ github.run_attempt }}
|
||||
path: ${{ runner.temp }}/goal-planner-resume.jsonl
|
||||
if-no-files-found: error
|
||||
retention-days: 7
|
||||
|
||||
- name: test (Windows root wall budget)
|
||||
if: env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name == 'pull_request'
|
||||
timeout-minutes: 5
|
||||
shell: bash
|
||||
run: |
|
||||
set -o pipefail
|
||||
go test -count=1 -timeout=3m -json -run '^Test(RootWall|EffectRootWall)' ./internal/runtime/agent ./internal/assembly/boot | tee "$RUNNER_TEMP/root-wall-budget.jsonl"
|
||||
jq -se '
|
||||
[.[] | select(.Test == null and .Action == "pass") | .Package] as $packages |
|
||||
[.[] | select(.Test != null and .Action == "pass") | .Test] as $tests |
|
||||
all(.[]; .Action != "skip" and .Action != "fail") and
|
||||
(["reasonix/internal/runtime/agent", "reasonix/internal/assembly/boot"] - $packages | length) == 0 and
|
||||
(["TestRootWallBudgetReachesDelegatedAgents", "TestRootWallBudgetReachesDelegatedAgents/foreground",
|
||||
"TestRootWallBudgetReachesDelegatedAgents/nested", "TestRootWallBudgetReachesDelegatedAgents/siblings",
|
||||
"TestRootWallBudgetReachesDelegatedAgents/context", "TestRootWallBudgetReachesDelegatedAgents/unconfigured",
|
||||
"TestRootWallBudgetReachesDelegatedAgents/explicit_zero", "TestRootWallBudgetContinuationAndReset",
|
||||
"TestRootWallBudgetContinuationAndReset/continuation", "TestRootWallBudgetContinuationAndReset/new_task",
|
||||
"TestRootWallBudgetContinuationAndReset/reset", "TestRootWallBudgetContinuationAndReset/override",
|
||||
"TestRootWallBudgetProjectionKeepsJobDeadline", "TestEffectRootWallBudgetReachesManagedJobs",
|
||||
"TestEffectRootWallBudgetReachesManagedJobs/task", "TestEffectRootWallBudgetReachesManagedJobs/capability",
|
||||
"TestEffectRootWallBudgetReachesManagedJobs/fleet"] - $tests | length) == 0
|
||||
' "$RUNNER_TEMP/root-wall-budget.jsonl"
|
||||
|
||||
- name: upload Windows root wall budget inventory
|
||||
if: always() && env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name == 'pull_request'
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7
|
||||
with:
|
||||
name: root-wall-budget-windows-${{ github.run_attempt }}
|
||||
path: ${{ runner.temp }}/root-wall-budget.jsonl
|
||||
if-no-files-found: ignore
|
||||
retention-days: 7
|
||||
|
||||
- name: test (Windows root token budget)
|
||||
if: env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name == 'pull_request'
|
||||
timeout-minutes: 5
|
||||
shell: bash
|
||||
run: |
|
||||
set -o pipefail
|
||||
go test -count=1 -timeout=3m -json -run '^Test(RootToken|EffectRootToken|BudgetCountsKnownReportedTokens|ReportedToken|EffectReportedTotalTokenBudget)' ./internal/runtime/agent ./internal/assembly/boot | tee "$RUNNER_TEMP/root-token-budget.jsonl"
|
||||
jq -se '
|
||||
[.[] | select(.Test == null and .Action == "pass") | .Package] as $packages |
|
||||
[.[] | select(.Test != null and .Action == "pass") | .Test] as $tests |
|
||||
all(.[]; .Action != "skip" and .Action != "fail") and
|
||||
(["reasonix/internal/runtime/agent", "reasonix/internal/assembly/boot"] - $packages | length) == 0 and
|
||||
(["TestRootTokenBudgetIncludesChildProviderUsage", "TestRootTokenBudgetIncludesChildProviderUsage/configured",
|
||||
"TestRootTokenBudgetIncludesChildProviderUsage/context", "TestRootTokenBudgetIncludesChildProviderUsage/unconfigured",
|
||||
"TestRootTokenBudgetIncludesChildProviderUsage/explicit_zero", "TestRootTokenBudgetSurvivesNestedAndConcurrentChildren",
|
||||
"TestRootTokenBudgetSurvivesNestedAndConcurrentChildren/nested", "TestRootTokenBudgetSurvivesNestedAndConcurrentChildren/siblings",
|
||||
"TestRootTokenBudgetSurvivesNestedAndConcurrentChildren/child_override", "TestRootTokenBudgetContinuationAndReset",
|
||||
"TestRootTokenBudgetContinuationAndReset/continuation", "TestRootTokenBudgetContinuationAndReset/new_task",
|
||||
"TestRootTokenBudgetContinuationAndReset/reset", "TestRootTokenScopeAtomicAccountingAndJobCancellation",
|
||||
"TestEffectRootTokenBudgetReachesDelegatedJobs", "TestEffectRootTokenBudgetReachesDelegatedJobs/foreground",
|
||||
"TestEffectRootTokenBudgetReachesDelegatedJobs/foreground_capability", "TestEffectRootTokenBudgetReachesDelegatedJobs/background",
|
||||
"TestEffectRootTokenBudgetReachesDelegatedJobs/background_capability", "TestEffectRootTokenBudgetReachesDelegatedJobs/background_fleet"] - $tests | length) == 0
|
||||
' "$RUNNER_TEMP/root-token-budget.jsonl"
|
||||
jq -se '
|
||||
. as $events |
|
||||
{"reasonix/internal/runtime/agent": [
|
||||
"TestBudgetCountsKnownReportedTokens",
|
||||
"TestBudgetCountsKnownReportedTokens/split",
|
||||
"TestBudgetCountsKnownReportedTokens/total_only",
|
||||
"TestBudgetCountsKnownReportedTokens/partial_split",
|
||||
"TestBudgetCountsKnownReportedTokens/lower_total",
|
||||
"TestBudgetCountsKnownReportedTokens/missing_total",
|
||||
"TestBudgetCountsKnownReportedTokens/negative_split",
|
||||
"TestBudgetCountsKnownReportedTokens/zero",
|
||||
"TestBudgetCountsKnownReportedTokens/negative",
|
||||
"TestReportedTokensEnforceLocalCapOnInheritedRoot",
|
||||
"TestReportedTokensFollowOriginalAuxiliaryRoot",
|
||||
"TestReportedTokenCountersSaturate"],
|
||||
"reasonix/internal/assembly/boot": [
|
||||
"TestEffectReportedTotalTokenBudget",
|
||||
"TestEffectReportedTotalTokenBudget/split",
|
||||
"TestEffectReportedTotalTokenBudget/total_only",
|
||||
"TestEffectReportedTotalTokenBudget/partial_split",
|
||||
"TestEffectReportedTotalTokenBudget/lower_total",
|
||||
"TestEffectReportedTotalTokenBudget/missing_total",
|
||||
"TestEffectReportedTotalTokenBudget/disabled"]} |
|
||||
to_entries | all(.[]; . as $package |
|
||||
([ $events[] | select(.Package == $package.key and .Test == null and
|
||||
(.Action == "pass" or .Action == "fail" or .Action == "skip")) ] |
|
||||
length == 1 and .[0].Action == "pass") and
|
||||
all(.value[]; . as $test |
|
||||
[ $events[] | select(.Package == $package.key and .Test == $test and
|
||||
(.Action == "pass" or .Action == "fail" or .Action == "skip")) ] |
|
||||
length == 1 and .[0].Action == "pass"))
|
||||
' "$RUNNER_TEMP/root-token-budget.jsonl"
|
||||
|
||||
- name: upload Windows root token budget inventory
|
||||
if: always() && env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name == 'pull_request'
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7
|
||||
with:
|
||||
name: root-token-budget-windows-${{ github.run_attempt }}
|
||||
path: ${{ runner.temp }}/root-token-budget.jsonl
|
||||
if-no-files-found: ignore
|
||||
retention-days: 7
|
||||
|
||||
- name: test (Windows budget usage completeness)
|
||||
if: env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name == 'pull_request'
|
||||
timeout-minutes: 5
|
||||
shell: bash
|
||||
run: |
|
||||
set -o pipefail
|
||||
go test -count=1 -timeout=3m -json -run '^Test(RequestOnlyUsage|RunBudgetMissingUsage|TaskBudgetKnownCost|RunBudgetExplicitZero|TaskBudgetUsageCompleteness|TaskBudgetCostUses|EffectBudgetCompleteness|TaskBudgetGateIgnoresCostWhenUnpriced)' ./internal/contract/provider ./internal/runtime/agent ./internal/assembly/boot | tee "$RUNNER_TEMP/budget-usage.jsonl"
|
||||
jq -se '
|
||||
[.[] | select(.Test == null and .Action == "pass") | .Package] as $packages |
|
||||
[.[] | select(.Test != null and .Action == "pass") | .Test] as $tests |
|
||||
all(.[]; .Action != "skip" and .Action != "fail") and
|
||||
(["reasonix/internal/contract/provider", "reasonix/internal/runtime/agent", "reasonix/internal/assembly/boot"] - $packages | length) == 0 and
|
||||
(["TestRequestOnlyUsageDoesNotCertifyTokenAccounting", "TestRunBudgetMissingUsageKeepsCostIncomplete",
|
||||
"TestRunBudgetMissingUsageKeepsCostIncomplete/complete", "TestRunBudgetMissingUsageKeepsCostIncomplete/missing_first",
|
||||
"TestRunBudgetMissingUsageKeepsCostIncomplete/missing_last", "TestRunBudgetMissingUsageKeepsCostIncomplete/all_missing",
|
||||
"TestTaskBudgetKnownCostStillLandsAfterMissingUsage", "TestRunBudgetExplicitZeroUsageIsPriced",
|
||||
"TestTaskBudgetUsageCompletenessFollowsItsLifetime", "TestTaskBudgetUsageCompletenessFollowsItsLifetime/continuation",
|
||||
"TestTaskBudgetUsageCompletenessFollowsItsLifetime/new_task", "TestTaskBudgetUsageCompletenessFollowsItsLifetime/explicit_reset",
|
||||
"TestTaskBudgetCostUsesOnlyTheReportedSubtotal", "TestTaskBudgetGateIgnoresCostWhenUnpriced",
|
||||
"TestEffectBudgetCompletenessFromOpenAIStream", "TestEffectBudgetCompletenessFromOpenAIStream/missing_0",
|
||||
"TestEffectBudgetCompletenessFromOpenAIStream/missing_1", "TestEffectBudgetCompletenessFromOpenAIStream/missing_2"] - $tests | length) == 0
|
||||
' "$RUNNER_TEMP/budget-usage.jsonl"
|
||||
|
||||
- name: upload Windows budget usage inventory
|
||||
if: always() && env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name == 'pull_request'
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7
|
||||
with:
|
||||
name: budget-usage-windows-${{ github.run_attempt }}
|
||||
path: ${{ runner.temp }}/budget-usage.jsonl
|
||||
if-no-files-found: ignore
|
||||
retention-days: 7
|
||||
|
||||
|
||||
- name: test (Windows Git storage environment)
|
||||
if: env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name == 'pull_request'
|
||||
timeout-minutes: 5
|
||||
shell: bash
|
||||
run: |
|
||||
set -o pipefail
|
||||
go test -count=1 -timeout=3m -json -run '^Test(HostStorageEnvironment|HostObjectStorage|HostExplicitStorageControls|DetachedStorageEnvironment|WorktreePreparationOwnsItsIndex|EffectIsolatedTaskOwnsGitStorage)$' ./internal/platform/gitcmd ./internal/platform/worktree ./internal/assembly/boot | tee "$RUNNER_TEMP/git-storage-environment.jsonl"
|
||||
jq -se '
|
||||
[.[] | select(.Test == null and .Action == "pass") | .Package] as $packages |
|
||||
[.[] | select(.Test != null and .Action == "pass") | .Test] as $tests |
|
||||
all(.[]; .Action != "skip" and .Action != "fail") and
|
||||
(["reasonix/internal/platform/gitcmd", "reasonix/internal/platform/worktree", "reasonix/internal/assembly/boot"] - $packages | length) == 0 and
|
||||
([
|
||||
"TestDetachedStorageEnvironment",
|
||||
"TestDetachedStorageEnvironment/explicit_env",
|
||||
"TestDetachedStorageEnvironment/inherited_env",
|
||||
"TestEffectIsolatedTaskOwnsGitStorage",
|
||||
"TestEffectIsolatedTaskOwnsGitStorage/ambient_existing",
|
||||
"TestEffectIsolatedTaskOwnsGitStorage/ambient_missing",
|
||||
"TestEffectIsolatedTaskOwnsGitStorage/control",
|
||||
"TestHostExplicitStorageControls",
|
||||
"TestHostExplicitStorageControls/explicit_object_directory",
|
||||
"TestHostExplicitStorageControls/private_index",
|
||||
"TestHostExplicitStorageControls/repository_declared_alternate",
|
||||
"TestHostObjectStorage",
|
||||
"TestHostObjectStorage/ambient_alternate_not_read",
|
||||
"TestHostObjectStorage/writes_stay_in_repository",
|
||||
"TestHostStorageEnvironment",
|
||||
"TestHostStorageEnvironment/GIT_ALTERNATE_OBJECT_DIRECTORIES",
|
||||
"TestHostStorageEnvironment/GIT_INDEX_FILE",
|
||||
"TestHostStorageEnvironment/GIT_OBJECT_DIRECTORY",
|
||||
"TestWorktreePreparationOwnsItsIndex",
|
||||
"TestWorktreePreparationOwnsItsIndex/candidate_ambient_existing",
|
||||
"TestWorktreePreparationOwnsItsIndex/candidate_ambient_missing",
|
||||
"TestWorktreePreparationOwnsItsIndex/candidate_control",
|
||||
"TestWorktreePreparationOwnsItsIndex/delivery_ambient_existing",
|
||||
"TestWorktreePreparationOwnsItsIndex/delivery_ambient_missing",
|
||||
"TestWorktreePreparationOwnsItsIndex/delivery_control"
|
||||
] - $tests | length) == 0
|
||||
' "$RUNNER_TEMP/git-storage-environment.jsonl"
|
||||
|
||||
- name: upload Windows Git storage environment inventory
|
||||
if: always() && env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name == 'pull_request'
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7
|
||||
with:
|
||||
name: git-storage-environment-windows-${{ github.run_attempt }}
|
||||
path: ${{ runner.temp }}/git-storage-environment.jsonl
|
||||
if-no-files-found: ignore
|
||||
retention-days: 7
|
||||
|
||||
- name: test (Windows Anthropic interrupted usage)
|
||||
if: env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name == 'pull_request'
|
||||
timeout-minutes: 5
|
||||
shell: bash
|
||||
run: |
|
||||
set -o pipefail
|
||||
go test -count=1 -timeout=3m -json -run '^Test(InterruptedUsage|EffectAnthropicPartialUsageRecovery)' ./internal/model/anthropic ./internal/assembly/boot | tee "$RUNNER_TEMP/anthropic-interrupted-usage.jsonl"
|
||||
jq -se '
|
||||
[.[] | select(.Test == null and .Action == "pass") | .Package] as $packages |
|
||||
[.[] | select(.Test != null and .Action == "pass") | .Test] as $tests |
|
||||
all(.[]; .Action != "skip" and .Action != "fail") and
|
||||
(["reasonix/internal/model/anthropic", "reasonix/internal/assembly/boot"] - $packages | length) == 0 and
|
||||
(["TestInterruptedUsageTerminal", "TestInterruptedUsageTerminal/upstream_error",
|
||||
"TestInterruptedUsageTerminal/payload_error", "TestInterruptedUsageTerminal/premature_eof",
|
||||
"TestInterruptedUsageTerminal/decode_error", "TestInterruptedUsageTerminal/reported_zero",
|
||||
"TestInterruptedUsageTerminal/missing_control", "TestInterruptedUsageTerminal/complete_control",
|
||||
"TestInterruptedUsageBoundedConsumer", "TestInterruptedUsageReadErrorAndCancellation",
|
||||
"TestInterruptedUsageReadErrorAndCancellation/read_error", "TestInterruptedUsageReadErrorAndCancellation/cancelled_buffered_reader",
|
||||
"TestInterruptedUsageAbandonedConsumer", "TestInterruptedUsageIdleTimeout",
|
||||
"TestEffectAnthropicPartialUsageRecovery", "TestEffectAnthropicPartialUsageRecovery/upstream_error",
|
||||
"TestEffectAnthropicPartialUsageRecovery/premature_eof", "TestEffectAnthropicPartialUsageRecovery/complete_control"] - $tests | length) == 0
|
||||
' "$RUNNER_TEMP/anthropic-interrupted-usage.jsonl"
|
||||
|
||||
- name: upload Windows Anthropic interrupted usage inventory
|
||||
if: always() && env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name == 'pull_request'
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7
|
||||
with:
|
||||
name: anthropic-interrupted-usage-windows-${{ github.run_attempt }}
|
||||
path: ${{ runner.temp }}/anthropic-interrupted-usage.jsonl
|
||||
if-no-files-found: ignore
|
||||
retention-days: 7
|
||||
|
||||
- name: test (Windows root websearch budget)
|
||||
if: env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name == 'pull_request'
|
||||
timeout-minutes: 5
|
||||
shell: bash
|
||||
run: |
|
||||
set -o pipefail
|
||||
go test -count=1 -timeout=3m -json -run '^Test(RootWebSearch|EffectRootWebSearch)' ./internal/tools/websearch ./internal/assembly/boot | tee "$RUNNER_TEMP/root-websearch-budget.jsonl"
|
||||
jq -se '
|
||||
[.[] | select(.Test == null and .Action == "pass") | .Package] as $packages |
|
||||
[.[] | select(.Test != null and .Action == "pass") | .Test] as $tests |
|
||||
all(.[]; .Action != "skip" and .Action != "fail") and
|
||||
(["reasonix/internal/tools/websearch", "reasonix/internal/assembly/boot"] - $packages | length) == 0 and
|
||||
(["TestRootWebSearchUsageTerminal", "TestRootWebSearchUsageTerminal/reported",
|
||||
"TestRootWebSearchUsageTerminal/no_reporter", "TestRootWebSearchUsageTerminal/missing",
|
||||
"TestRootWebSearchUsageTerminal/no_observer", "TestRootWebSearchUsageTerminal/input_refused",
|
||||
"TestRootWebSearchUsageTerminal/no_factory", "TestRootWebSearchUsageTerminal/factory_error",
|
||||
"TestRootWebSearchUsageTerminal/billed_failure", "TestRootWebSearchUsageTerminal/cancel_after_usage",
|
||||
"TestRootWebSearchUsageTerminal/cancel_before_start", "TestRootWebSearchUsageTerminal/stream_error",
|
||||
"TestEffectRootWebSearchTokenBudget", "TestEffectRootWebSearchTokenBudget/reported",
|
||||
"TestEffectRootWebSearchTokenBudget/disabled", "TestEffectRootWebSearchTokenBudget/missing"] - $tests | length) == 0
|
||||
' "$RUNNER_TEMP/root-websearch-budget.jsonl"
|
||||
|
||||
- name: upload Windows root websearch budget inventory
|
||||
if: always() && env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name == 'pull_request'
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7
|
||||
with:
|
||||
name: root-websearch-budget-windows-${{ github.run_attempt }}
|
||||
path: ${{ runner.temp }}/root-websearch-budget.jsonl
|
||||
if-no-files-found: ignore
|
||||
retention-days: 7
|
||||
|
||||
- name: test (Windows explicit skill root budget)
|
||||
if: env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name == 'pull_request'
|
||||
timeout-minutes: 5
|
||||
shell: bash
|
||||
run: |
|
||||
set -o pipefail
|
||||
go test -count=1 -timeout=3m -json -run '^(TestProfileRootBudgetLifetime|TestProfileBudgetPreparationUsesTurnAdmission|TestEffectExplicitSkillRootBudget)$' ./internal/session/control ./internal/assembly/boot | tee "$RUNNER_TEMP/explicit-skill-root-budget.jsonl"
|
||||
jq -se '
|
||||
[.[] | select(.Test == null and .Action == "pass") | .Package] as $packages |
|
||||
[.[] | select(.Test != null and .Action == "pass") | .Test] as $tests |
|
||||
all(.[]; .Action != "skip" and .Action != "fail") and
|
||||
(["reasonix/internal/session/control", "reasonix/internal/assembly/boot"] - $packages | length) == 0 and
|
||||
(["TestProfileRootBudgetLifetime", "TestProfileRootBudgetLifetime/new_task",
|
||||
"TestProfileRootBudgetLifetime/same_scope", "TestProfileRootBudgetLifetime/new_scope",
|
||||
"TestProfileRootBudgetLifetime/reset", "TestProfileRootBudgetLifetime/new_session",
|
||||
"TestProfileRootBudgetLifetime/inherited", "TestProfileRootBudgetLifetime/context_zero",
|
||||
"TestProfileBudgetPreparationUsesTurnAdmission", "TestEffectExplicitSkillRootBudget",
|
||||
"TestEffectExplicitSkillRootBudget/profile", "TestEffectExplicitSkillRootBudget/preview",
|
||||
"TestEffectExplicitSkillRootBudget/slash", "TestEffectExplicitSkillRootBudget/chips",
|
||||
"TestEffectExplicitSkillRootBudget/goal", "TestEffectExplicitSkillRootBudget/context_zero",
|
||||
"TestEffectExplicitSkillRootBudget/unconfigured"] - $tests | length) == 0
|
||||
' "$RUNNER_TEMP/explicit-skill-root-budget.jsonl"
|
||||
|
||||
- name: upload Windows explicit skill root budget inventory
|
||||
if: always() && env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name == 'pull_request'
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7
|
||||
with:
|
||||
name: explicit-skill-root-budget-windows-${{ github.run_attempt }}
|
||||
path: ${{ runner.temp }}/explicit-skill-root-budget.jsonl
|
||||
if-no-files-found: ignore
|
||||
retention-days: 7
|
||||
|
||||
- name: test (Windows root triage budget)
|
||||
if: env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name == 'pull_request'
|
||||
timeout-minutes: 5
|
||||
shell: bash
|
||||
run: |
|
||||
set -o pipefail
|
||||
go test -count=1 -timeout=3m -json -run '^Test(RootTriage|EffectRootTriage)' ./internal/runtime/agent ./internal/assembly/boot | tee "$RUNNER_TEMP/root-triage-budget.jsonl"
|
||||
jq -se '
|
||||
[.[] | select(.Test == null and .Action == "pass") | .Package] as $packages |
|
||||
[.[] | select(.Test != null and .Action == "pass") | .Test] as $tests |
|
||||
all(.[]; .Action != "skip" and .Action != "fail") and
|
||||
(["reasonix/internal/runtime/agent", "reasonix/internal/assembly/boot"] - $packages | length) == 0 and
|
||||
(["TestRootTriageTokenBudget", "TestRootTriageTokenBudget/classifier", "TestRootTriageTokenBudget/screen",
|
||||
"TestRootTriageTokenBudget/billed_failure", "TestRootTriageTokenBudget/missing", "TestRootTriageTokenBudget/disabled",
|
||||
"TestRootTriageTokenBudget/no_sink", "TestRootTriageTokenBudget/counts_without_total",
|
||||
"TestRootTriageOutsideRunDoesNotCreateScope", "TestEffectRootTriageScreenBudget",
|
||||
"TestEffectRootTriageScreenBudget/reported", "TestEffectRootTriageScreenBudget/flagged",
|
||||
"TestEffectRootTriageScreenBudget/disabled", "TestEffectRootTriageScreenBudget/missing",
|
||||
"TestEffectRootTriageScreenBudget/screen_disabled"] - $tests | length) == 0
|
||||
' "$RUNNER_TEMP/root-triage-budget.jsonl"
|
||||
|
||||
- name: upload Windows root triage budget inventory
|
||||
if: always() && env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name == 'pull_request'
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7
|
||||
with:
|
||||
name: root-triage-budget-windows-${{ github.run_attempt }}
|
||||
path: ${{ runner.temp }}/root-triage-budget.jsonl
|
||||
if-no-files-found: ignore
|
||||
retention-days: 7
|
||||
|
||||
- name: test (Windows root auxiliary budget)
|
||||
if: env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name == 'pull_request'
|
||||
timeout-minutes: 5
|
||||
shell: bash
|
||||
run: |
|
||||
set -o pipefail
|
||||
go test -count=1 -timeout=3m -json -run '^Test(RootAuxiliary|EffectRootAuxiliary)' ./internal/runtime/agent ./internal/assembly/boot | tee "$RUNNER_TEMP/root-auxiliary-budget.jsonl"
|
||||
jq -se '
|
||||
[.[] | select(.Test == null and .Action == "pass") | .Package] as $packages |
|
||||
[.[] | select(.Test != null and .Action == "pass") | .Test] as $tests |
|
||||
all(.[]; .Action != "skip" and .Action != "fail") and
|
||||
(["reasonix/internal/runtime/agent", "reasonix/internal/assembly/boot"] - $packages | length) == 0 and
|
||||
(["TestRootAuxiliaryAutomaticCompaction", "TestRootAuxiliaryTokenBudget",
|
||||
"TestRootAuxiliaryTokenBudget/summary", "TestRootAuxiliaryTokenBudget/summary_failed",
|
||||
"TestRootAuxiliaryTokenBudget/summary_missing", "TestRootAuxiliaryTokenBudget/summary_disabled",
|
||||
"TestRootAuxiliaryTokenBudget/diagnosis", "TestRootAuxiliaryTokenBudget/diagnosis_failed",
|
||||
"TestRootAuxiliaryTokenBudget/diagnosis_missing", "TestRootAuxiliaryTokenBudget/diagnosis_disabled",
|
||||
"TestRootAuxiliaryManualCompactionOutsideRun", "TestRootAuxiliaryBudgetProjectionKeepsOldTask",
|
||||
"TestEffectRootAuxiliarySummaryBudget", "TestEffectRootAuxiliarySummaryBudget/configured",
|
||||
"TestEffectRootAuxiliarySummaryBudget/disabled", "TestEffectRootAuxiliarySummaryBudget/missing_usage"] - $tests | length) == 0
|
||||
' "$RUNNER_TEMP/root-auxiliary-budget.jsonl"
|
||||
|
||||
- name: upload Windows root auxiliary budget inventory
|
||||
if: always() && env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name == 'pull_request'
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7
|
||||
with:
|
||||
name: root-auxiliary-budget-windows-${{ github.run_attempt }}
|
||||
path: ${{ runner.temp }}/root-auxiliary-budget.jsonl
|
||||
if-no-files-found: ignore
|
||||
retention-days: 7
|
||||
|
||||
- name: test (Windows local cost quote)
|
||||
if: env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name == 'pull_request'
|
||||
shell: bash
|
||||
run: |
|
||||
set -o pipefail
|
||||
go test -count=1 -timeout=3m -json -run '^Test(LocalBudgetUsesOccurrenceQuote|LocalCost.*|UsageQuote.*|EffectLocalCostUsesOccurrenceQuote)$' ./internal/contract/event ./internal/runtime/agent ./internal/assembly/boot | tee "$RUNNER_TEMP/local-cost-quote.jsonl"
|
||||
jq -se '
|
||||
["TestLocalBudgetUsesOccurrenceQuote", "TestLocalBudgetUsesOccurrenceQuote/peak", "TestLocalBudgetUsesOccurrenceQuote/offpeak",
|
||||
"TestLocalCostKeepsOriginalCurrencyBuckets", "TestLocalCostFixedPointThresholdAndUnknown",
|
||||
"TestUsageQuoteUsesOneOwnerAndOccurrence", "TestUsageQuoteLegacyAndNilSinks",
|
||||
"TestEffectLocalCostUsesOccurrenceQuote", "TestEffectLocalCostUsesOccurrenceQuote/peak",
|
||||
"TestEffectLocalCostUsesOccurrenceQuote/offpeak", "TestEffectLocalCostUsesOccurrenceQuote/relay"] as $required |
|
||||
[.[] | select(.Test != null and .Action == "pass") | .Test] as $passed |
|
||||
[.[] | select(.Test == null and .Action == "pass") | .Package] as $packages |
|
||||
all(.[]; .Action != "fail" and .Action != "skip") and
|
||||
(($required - $passed) | length == 0) and
|
||||
((["reasonix/internal/contract/event", "reasonix/internal/runtime/agent", "reasonix/internal/assembly/boot"] - $packages) | length == 0)
|
||||
' "$RUNNER_TEMP/local-cost-quote.jsonl"
|
||||
|
||||
- name: upload Windows local cost quote inventory
|
||||
if: always() && env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name == 'pull_request'
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7
|
||||
with:
|
||||
name: local-cost-quote-windows-${{ github.run_attempt }}
|
||||
path: ${{ runner.temp }}/local-cost-quote.jsonl
|
||||
if-no-files-found: error
|
||||
retention-days: 7
|
||||
|
||||
- name: test (Windows estimated usage quotes)
|
||||
if: env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name == 'pull_request'
|
||||
timeout-minutes: 5
|
||||
shell: bash
|
||||
run: |
|
||||
set -o pipefail
|
||||
go test -count=1 -timeout=3m -json -run '^Test(EstimatedUsage|EffectEstimatedUsage|ToWireUsagePayloadJSON$|StatusExtensionTracksMultipleSessionsAndUsage$|CacheSplitProvenanceIsRecorded$|RecordedPlainCallIsUnchanged$)' ./internal/contract/pricing ./internal/contract/event ./internal/contract/config ./internal/assembly/boot ./internal/contract/eventwire ./internal/frontend/acp ./internal/state/stats | tee "$RUNNER_TEMP/estimated-quote.jsonl"
|
||||
jq -se '
|
||||
[.[] | select(.Test == null and .Action == "pass") | .Package] as $packages |
|
||||
[.[] | select(.Test != null and .Action == "pass") | .Test] as $tests |
|
||||
all(.[]; .Action != "skip" and .Action != "fail") and
|
||||
(["reasonix/internal/contract/pricing", "reasonix/internal/contract/event", "reasonix/internal/contract/config", "reasonix/internal/assembly/boot",
|
||||
"reasonix/internal/contract/eventwire", "reasonix/internal/frontend/acp", "reasonix/internal/state/stats"] - $packages | length) == 0 and
|
||||
(["TestEstimatedUsageQuotePreservesAmountsWithoutCompleteFacts", "TestEstimatedUsageQuotePreservesAmountsWithoutCompleteFacts/CNY",
|
||||
"TestEstimatedUsageQuotePreservesAmountsWithoutCompleteFacts/USD", "TestEstimatedUsageLedgerRetainsPartialCoverage",
|
||||
"TestEstimatedUsageLedgerRetainsPartialCoverage/separate_keys", "TestEstimatedUsageLedgerRetainsPartialCoverage/same_key",
|
||||
"TestEstimatedUsageSavedQuoteReader", "TestEstimatedUsageQuoteSinkCoversTurnAndGuardian",
|
||||
"TestEstimatedUsageQuoteSinkCoversTurnAndGuardian/turn", "TestEstimatedUsageQuoteSinkCoversTurnAndGuardian/guardian",
|
||||
"TestEstimatedUsageConfiguredQuote", "TestEffectEstimatedUsageQuoteAfterStreamRecovery",
|
||||
"TestEffectEstimatedUsageQuoteAfterStreamRecovery/reported_control", "TestEffectEstimatedUsageQuoteAfterStreamRecovery/interrupted",
|
||||
"TestToWireUsagePayloadJSON", "TestStatusExtensionTracksMultipleSessionsAndUsage", "TestCacheSplitProvenanceIsRecorded", "TestRecordedPlainCallIsUnchanged"] - $tests | length) == 0
|
||||
' "$RUNNER_TEMP/estimated-quote.jsonl"
|
||||
|
||||
- name: upload Windows estimated quote inventory
|
||||
if: always() && env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name == 'pull_request'
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7
|
||||
with:
|
||||
name: estimated-quote-windows-${{ github.run_attempt }}
|
||||
path: ${{ runner.temp }}/estimated-quote.jsonl
|
||||
if-no-files-found: ignore
|
||||
retention-days: 7
|
||||
|
||||
- name: test (Windows bounded root budget)
|
||||
if: env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name == 'pull_request'
|
||||
timeout-minutes: 5
|
||||
shell: bash
|
||||
run: |
|
||||
set -o pipefail
|
||||
go test -count=1 -timeout=3m -json -run '^Test(AuxiliaryUsageObserver|BoundedUsageObserver|RootBounded|EffectRootBounded)' ./internal/contract/provider ./internal/model/boundedllm ./internal/runtime/agent ./internal/assembly/boot | tee "$RUNNER_TEMP/bounded-root-budget.jsonl"
|
||||
jq -se '
|
||||
[.[] | select(.Test == null and .Action == "pass") | .Package] as $packages |
|
||||
[.[] | select(.Test != null and .Action == "pass") | .Test] as $tests |
|
||||
all(.[]; .Action != "skip" and .Action != "fail") and
|
||||
(["reasonix/internal/contract/provider", "reasonix/internal/model/boundedllm",
|
||||
"reasonix/internal/runtime/agent", "reasonix/internal/assembly/boot"] - $packages | length) == 0 and
|
||||
(["TestAuxiliaryUsageObserverReplacesAndCopies",
|
||||
"TestBoundedUsageObserverOwnsOneTerminal", "TestBoundedUsageObserverOwnsOneTerminal/reported",
|
||||
"TestBoundedUsageObserverOwnsOneTerminal/billed_failure", "TestBoundedUsageObserverOwnsOneTerminal/without_sink",
|
||||
"TestBoundedUsageObserverOwnsOneTerminal/missing", "TestBoundedUsageObserverOwnsOneTerminal/input_refused",
|
||||
"TestRootBoundedReviewerBudget", "TestRootBoundedReviewerBudget/reported",
|
||||
"TestRootBoundedReviewerBudget/billed_failure", "TestRootBoundedReviewerBudget/missing_usage",
|
||||
"TestRootBoundedReviewerBudget/disabled", "TestRootBoundedReviewerBudget/no_emission",
|
||||
"TestRootBoundedReviewerBudget/projected_job_context", "TestRootBoundedNestedBudgetChargesOnce",
|
||||
"TestRootBoundedLateProjectionStaysWithOldTask", "TestEffectRootBoundedAdvisorBudget",
|
||||
"TestEffectRootBoundedAdvisorBudget/reported", "TestEffectRootBoundedAdvisorBudget/disabled",
|
||||
"TestEffectRootBoundedAdvisorBudget/missing_usage"] - $tests | length) == 0
|
||||
' "$RUNNER_TEMP/bounded-root-budget.jsonl"
|
||||
|
||||
- name: upload Windows bounded root budget inventory
|
||||
if: always() && env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name == 'pull_request'
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7
|
||||
with:
|
||||
name: bounded-root-budget-windows-${{ github.run_attempt }}
|
||||
path: ${{ runner.temp }}/bounded-root-budget.jsonl
|
||||
if-no-files-found: ignore
|
||||
retention-days: 7
|
||||
|
||||
# The general Windows PR smoke list intentionally omits internal/contract/tool.
|
||||
# Keep the session-temp portability contract covered without widening the
|
||||
@@ -245,12 +1009,12 @@ jobs:
|
||||
timeout-minutes: 5
|
||||
run: go test -count=1 -timeout=3m -run '^TestBashPowerShellExecuteDetailedContract$|^TestBashPowerShell51PreflightRejectsAndAndDetailed$|^TestBashPowerShellOutputIsUTF8$|^TestBashPowerShellSurfacesNonZeroExit$|^TestBashPowerShellRejectsChaining$' ./internal/tools/builtin
|
||||
|
||||
# Full push suite uses the same 18m package timeout as Windows smoke and a
|
||||
# 35m step budget above it. -count=1 as at the smoke step. A failure prints
|
||||
# the failed tests' output and the 15 slowest boot tests.
|
||||
# Full push suite: a 36m package timeout inside a 45m step budget. -count=1
|
||||
# as at the smoke step. A failure prints the failed tests' output and the 15
|
||||
# slowest boot tests.
|
||||
- name: test (full)
|
||||
if: env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name != 'pull_request'
|
||||
timeout-minutes: 35
|
||||
timeout-minutes: 45
|
||||
env:
|
||||
# Run the prompt-cache prefix-stability guard (TestCacheHit*) in CI: a
|
||||
# regression there silently tanks the cache hit rate the project is
|
||||
@@ -261,9 +1025,9 @@ jobs:
|
||||
WINDOWS_SANDBOX_WAIT_MS: "20000"
|
||||
shell: bash
|
||||
run: |
|
||||
# internal/assembly/boot alone takes 14-15 minutes on a Windows runner;
|
||||
# internal/assembly/boot alone can take over 25 minutes on a Windows runner;
|
||||
# the package limit leaves headroom inside the step limit above.
|
||||
go test -count=1 -p 4 -timeout=28m -json ./... | tee "$RUNNER_TEMP/full.jsonl" |
|
||||
go test -count=1 -p 4 -timeout=36m -json ./... | tee "$RUNNER_TEMP/full.jsonl" |
|
||||
jq -rj --unbuffered 'select(.Action == "output" and .Test == null) | .Output'
|
||||
|
||||
- name: test (full) failure detail
|
||||
@@ -315,13 +1079,15 @@ jobs:
|
||||
if: github.event_name == 'pull_request'
|
||||
env:
|
||||
REASONIX_RELEASE_CACHE_GUARD: "1"
|
||||
run: go test -race ./internal/runtime/agent/... ./internal/ext/plugin/... ./internal/tools/jobs/... ./internal/base/proc/... ./internal/safety/sandbox/... ./internal/base/filelock/... ./internal/contract/eventwire/... ./internal/platform/remote/... ./internal/ext/extension/... ./internal/assembly/boot/... ./internal/session/control/... ./internal/contract/tool/...
|
||||
run: go test -race ./internal/runtime/agent/... ./internal/runtime/delegation/... ./internal/runtime/isolation/... ./internal/platform/worktree/... ./internal/state/mutation/... ./internal/ext/plugin/... ./internal/tools/jobs/... ./internal/base/proc/... ./internal/safety/sandbox/... ./internal/base/filelock/... ./internal/contract/eventwire/... ./internal/platform/remote/... ./internal/ext/extension/... ./internal/assembly/boot/... ./internal/session/control/... ./internal/contract/tool/...
|
||||
|
||||
- name: test -race (full)
|
||||
if: github.event_name != 'pull_request'
|
||||
env:
|
||||
REASONIX_RELEASE_CACHE_GUARD: "1"
|
||||
run: go test -race ./...
|
||||
# internal/assembly/boot under -race runs past ten minutes on a shared
|
||||
# runner; the package limit leaves headroom over the default.
|
||||
run: go test -race -timeout=25m ./...
|
||||
|
||||
# sdk/go is a nested stdlib-only module invisible to root `go test ./...`.
|
||||
# Its DTOs are generated from internal/ext/extension/protocol, and the
|
||||
|
||||
@@ -43,20 +43,20 @@ jobs:
|
||||
if: matrix.branch != ''
|
||||
run: echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@6f2ac81f5cd81fe351a197ba0b00d91d03cd2aee # v4.39.0
|
||||
uses: github/codeql-action/init@9b3254085494b55a0f7c4c2b3d2d52cc712ce370 # v4.39.1
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
build-mode: ${{ matrix.language == 'go' && 'autobuild' || 'none' }}
|
||||
- name: Perform CodeQL Analysis
|
||||
if: matrix.branch == ''
|
||||
uses: github/codeql-action/analyze@6f2ac81f5cd81fe351a197ba0b00d91d03cd2aee # v4.39.0
|
||||
uses: github/codeql-action/analyze@9b3254085494b55a0f7c4c2b3d2d52cc712ce370 # v4.39.1
|
||||
with:
|
||||
category: "/language:${{ matrix.language }}"
|
||||
# Results from a checked-out line are filed under that line's ref, not the
|
||||
# default branch the schedule happened to run from.
|
||||
- name: Perform CodeQL Analysis (${{ matrix.branch }})
|
||||
if: matrix.branch != ''
|
||||
uses: github/codeql-action/analyze@6f2ac81f5cd81fe351a197ba0b00d91d03cd2aee # v4.39.0
|
||||
uses: github/codeql-action/analyze@9b3254085494b55a0f7c4c2b3d2d52cc712ce370 # v4.39.1
|
||||
with:
|
||||
category: "/language:${{ matrix.language }}"
|
||||
ref: refs/heads/${{ matrix.branch }}
|
||||
|
||||
@@ -51,7 +51,7 @@ jobs:
|
||||
go-version-file: go.mod
|
||||
cache: true
|
||||
|
||||
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7
|
||||
- uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7
|
||||
with:
|
||||
python-version: '3.12'
|
||||
|
||||
|
||||
@@ -87,7 +87,7 @@ jobs:
|
||||
browser-guards:
|
||||
name: browser guards
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
timeout-minutes: 25
|
||||
defaults:
|
||||
run:
|
||||
working-directory: desktop/frontend-next
|
||||
@@ -155,9 +155,14 @@ jobs:
|
||||
node perf/browser-full.mjs
|
||||
node perf/mod-ui.mjs
|
||||
node perf/codeeditor-size.mjs
|
||||
node perf/composer-reflow.mjs
|
||||
node perf/composer-toolbar-row.mjs
|
||||
node perf/action-icons.mjs
|
||||
node perf/rowhit.mjs
|
||||
node perf/prefs-wide-centered.mjs
|
||||
node perf/tab-pip.mjs
|
||||
node perf/syntax-tokens.mjs
|
||||
node perf/tab-close-inside.mjs
|
||||
node perf/context-card-hover.mjs
|
||||
node perf/sessquick.mjs
|
||||
node perf/sessionrows.mjs
|
||||
@@ -183,8 +188,12 @@ jobs:
|
||||
node perf/web-access.mjs
|
||||
node perf/peers-settings.mjs
|
||||
node perf/output-cap.mjs
|
||||
node perf/agent-run-tab.mjs
|
||||
node perf/changes-cap.mjs
|
||||
node perf/session-menu-fit.mjs
|
||||
node perf/rail-rows-typography.mjs
|
||||
node perf/settings-nav-fit.mjs
|
||||
node perf/rail-rows-align.mjs
|
||||
node perf/progress-tail.mjs
|
||||
node perf/trust-wait.mjs
|
||||
node perf/nest-nodes.mjs
|
||||
|
||||
@@ -117,6 +117,22 @@ go test ./internal/runtime/agent/ -v # verbose, one package
|
||||
go test ./internal/tools/builtin/ -run TestGrep # one test
|
||||
```
|
||||
|
||||
`testenv.IsolateUserState` installs a disposable user home and config-edit lock
|
||||
scope. Default-path tests can clear `REASONIX_HOME` without losing the test
|
||||
registry, while explicitly isolated Reasonix homes retain their own locks.
|
||||
|
||||
`REASONIX_TEST_HOME` carries the scope to helper subprocesses. A helper that
|
||||
skips the isolation guard must install the inherited home with
|
||||
`testscope.InstallHome` before running tests.
|
||||
|
||||
The environment variable alone cannot activate a test scope. Production
|
||||
executables still use the OS account's home for shared config locks.
|
||||
|
||||
The guard removes its scratch home on normal completion. Scratch homes are
|
||||
created under the original home, so an interrupted run can still leave
|
||||
`.reasonix-test-home-*` directories; this is separate from persistent
|
||||
config-edit locks in the real `.reasonix/locks` directory.
|
||||
|
||||
### Code style
|
||||
|
||||
- `gofmt` is enforced by CI — format before committing
|
||||
|
||||
@@ -169,6 +169,19 @@ Every package ships with a `.minisig` signature, and the release carries
|
||||
The same release also carries the 2.x `reasonix` CLI as archives for
|
||||
`darwin|linux|windows × amd64|arm64`.
|
||||
|
||||
The Android build ships on that release as `ReasonixMobile-<version>.apk`
|
||||
(`arm64`, Android 8.0 or newer). The kernel and the page run on the phone, and
|
||||
the agent reaches your servers over SSH; pairing with a computer is done by
|
||||
scanning the code it shows.
|
||||
|
||||
The APK is signed with the Android release key rather than minisign, so it
|
||||
carries a `.sha256` and no `.minisig`.
|
||||
|
||||
When that key is absent the build is named
|
||||
`ReasonixMobile-<version>-unsigned.apk`. Android refuses to install an unsigned
|
||||
package, so those builds stay a workflow artifact for whoever wants to sign one,
|
||||
and the release carries no APK at all.
|
||||
|
||||
### Reasonix 1.x (stable)
|
||||
|
||||
The 1.x CLI installs through npm on any supported platform, or Homebrew on
|
||||
@@ -288,11 +301,11 @@ A small list of folks whose work has shaped Reasonix the most — the current to
|
||||
<!-- reasonix-top-contributors:start -->
|
||||
| Contributor | Contributor | Contributor | Contributor |
|
||||
| --- | --- | --- | --- |
|
||||
| [**SivanCola**](https://github.com/SivanCola) | [**KHG420**](https://github.com/KHG420) | [**ttmouse**](https://github.com/ttmouse) | [**lifu963**](https://github.com/lifu963) |
|
||||
| [**XTLine**](https://github.com/XTLine) | [**HUQIANTAO**](https://github.com/HUQIANTAO) | [**chuiwenwei**](https://github.com/chuiwenwei) | [**Harbor404**](https://github.com/Harbor404) |
|
||||
| [**GTC2080**](https://github.com/GTC2080) | [**mchenziyi**](https://github.com/mchenziyi) | [**Li-Charles-One**](https://github.com/Li-Charles-One) | [**light-front-theory**](https://github.com/light-front-theory) |
|
||||
| [**xxoingr**](https://github.com/xxoingr) | [**eghrhegpe**](https://github.com/eghrhegpe) | [**noreply**](https://github.com/noreply) | [**par73e**](https://github.com/par73e) |
|
||||
| [**Bernardxu123**](https://github.com/Bernardxu123) | [**Guan0923**](https://github.com/Guan0923) | [**HaoyueQin**](https://github.com/HaoyueQin) | [**BuGlessRB**](https://github.com/BuGlessRB) |
|
||||
| [**SivanCola**](https://github.com/SivanCola) | [**KHG420**](https://github.com/KHG420) | [**ttmouse**](https://github.com/ttmouse) | [**chuiwenwei**](https://github.com/chuiwenwei) |
|
||||
| [**lifu963**](https://github.com/lifu963) | [**XTLine**](https://github.com/XTLine) | [**HUQIANTAO**](https://github.com/HUQIANTAO) | [**xxoingr**](https://github.com/xxoingr) |
|
||||
| [**Harbor404**](https://github.com/Harbor404) | [**GTC2080**](https://github.com/GTC2080) | [**mchenziyi**](https://github.com/mchenziyi) | [**Li-Charles-One**](https://github.com/Li-Charles-One) |
|
||||
| [**light-front-theory**](https://github.com/light-front-theory) | [**imcreazy**](https://github.com/imcreazy) | [**eghrhegpe**](https://github.com/eghrhegpe) | [**noreply**](https://github.com/noreply) |
|
||||
| [**par73e**](https://github.com/par73e) | [**Bernardxu123**](https://github.com/Bernardxu123) | [**lancy69**](https://github.com/lancy69) | [**Guan0923**](https://github.com/Guan0923) |
|
||||
<!-- reasonix-top-contributors:end -->
|
||||
|
||||
Special thanks to [**Bernardxu123**](https://github.com/Bernardxu123) for designing the project logo and intro video.
|
||||
|
||||
@@ -156,6 +156,15 @@ Reasonix 分为两条版本线,调整的原因见[版本路线公告](https://
|
||||
|
||||
同一个发布页也提供 2.x 的 `reasonix` CLI 归档(`darwin|linux|windows × amd64|arm64`)。
|
||||
|
||||
安卓版在同一个发布页上,文件名是 `ReasonixMobile-<版本>.apk`(`arm64`,Android 8.0 及以上)。
|
||||
|
||||
这个 APK 用 Android 发布密钥签名,不走 minisign,因此只有一个 `.sha256`,没有 `.minisig`。
|
||||
|
||||
该密钥缺失时产物名为 `ReasonixMobile-<版本>-unsigned.apk`。Android 不接受未签名的包,
|
||||
这类构建只留作 workflow 产物供自行签名,发布页上不会挂 APK。
|
||||
|
||||
内核与界面都跑在手机上,Agent 通过 SSH 连接你自己的服务器;与电脑配对时扫它显示的二维码。
|
||||
|
||||
### Reasonix 1.x(稳定版)
|
||||
|
||||
1.x CLI 在任意支持的平台上都可以通过 npm 安装,macOS 也可以使用 Homebrew:
|
||||
@@ -269,11 +278,11 @@ CLI 进阶用法和详细配置见 **[CLI 命令参考](./docs/CLI.zh-CN.md)**
|
||||
<!-- reasonix-top-contributors:start -->
|
||||
| Contributor | Contributor | Contributor | Contributor |
|
||||
| --- | --- | --- | --- |
|
||||
| [**SivanCola**](https://github.com/SivanCola) | [**KHG420**](https://github.com/KHG420) | [**ttmouse**](https://github.com/ttmouse) | [**lifu963**](https://github.com/lifu963) |
|
||||
| [**XTLine**](https://github.com/XTLine) | [**HUQIANTAO**](https://github.com/HUQIANTAO) | [**chuiwenwei**](https://github.com/chuiwenwei) | [**Harbor404**](https://github.com/Harbor404) |
|
||||
| [**GTC2080**](https://github.com/GTC2080) | [**mchenziyi**](https://github.com/mchenziyi) | [**Li-Charles-One**](https://github.com/Li-Charles-One) | [**light-front-theory**](https://github.com/light-front-theory) |
|
||||
| [**xxoingr**](https://github.com/xxoingr) | [**eghrhegpe**](https://github.com/eghrhegpe) | [**noreply**](https://github.com/noreply) | [**par73e**](https://github.com/par73e) |
|
||||
| [**Bernardxu123**](https://github.com/Bernardxu123) | [**Guan0923**](https://github.com/Guan0923) | [**HaoyueQin**](https://github.com/HaoyueQin) | [**BuGlessRB**](https://github.com/BuGlessRB) |
|
||||
| [**SivanCola**](https://github.com/SivanCola) | [**KHG420**](https://github.com/KHG420) | [**ttmouse**](https://github.com/ttmouse) | [**chuiwenwei**](https://github.com/chuiwenwei) |
|
||||
| [**lifu963**](https://github.com/lifu963) | [**XTLine**](https://github.com/XTLine) | [**HUQIANTAO**](https://github.com/HUQIANTAO) | [**xxoingr**](https://github.com/xxoingr) |
|
||||
| [**Harbor404**](https://github.com/Harbor404) | [**GTC2080**](https://github.com/GTC2080) | [**mchenziyi**](https://github.com/mchenziyi) | [**Li-Charles-One**](https://github.com/Li-Charles-One) |
|
||||
| [**light-front-theory**](https://github.com/light-front-theory) | [**imcreazy**](https://github.com/imcreazy) | [**eghrhegpe**](https://github.com/eghrhegpe) | [**noreply**](https://github.com/noreply) |
|
||||
| [**par73e**](https://github.com/par73e) | [**Bernardxu123**](https://github.com/Bernardxu123) | [**lancy69**](https://github.com/lancy69) | [**Guan0923**](https://github.com/Guan0923) |
|
||||
<!-- reasonix-top-contributors:end -->
|
||||
|
||||
特别感谢 [**Bernardxu123**](https://github.com/Bernardxu123) 设计的项目 logo和开场视频。
|
||||
|
||||
@@ -20,10 +20,25 @@ plugins {
|
||||
}
|
||||
|
||||
// One version, read by the About screen and the update check.
|
||||
//
|
||||
// A published build takes its version from the tag it was cut for, so the About
|
||||
// screen and the update check name that release rather than the number this file
|
||||
// was last edited to. Without the property — a local build — the numbers below
|
||||
// are the answer.
|
||||
def publishedVersion = findProperty("reasonixVersion") as String
|
||||
def versionMajor = 2
|
||||
def versionMinor = 0
|
||||
def versionPatch = 0
|
||||
def versionBuild = 1
|
||||
if (publishedVersion) {
|
||||
def parsed = publishedVersion =~ /^(\d+)\.(\d+)\.(\d+)/
|
||||
if (!parsed.find()) {
|
||||
throw new GradleException("reasonixVersion must start with MAJOR.MINOR.PATCH, got: ${publishedVersion}")
|
||||
}
|
||||
versionMajor = parsed.group(1) as int
|
||||
versionMinor = parsed.group(2) as int
|
||||
versionPatch = parsed.group(3) as int
|
||||
}
|
||||
|
||||
// Where the two build inputs are expected. Both are produced from the checkout
|
||||
// beside this module, by the scripts named in each message.
|
||||
@@ -53,6 +68,12 @@ android {
|
||||
// what `MainActivity` feeds real inset values for, since a WebView does
|
||||
// not implement `env(safe-area-inset-*)`.
|
||||
targetSdk 35
|
||||
// versionCode must strictly increase, because Android refuses an update
|
||||
// whose versionCode is not higher. The build digit is a single decimal
|
||||
// place, so the scheme collides once a patch reaches 10: 2.55.10 and
|
||||
// 2.56.0 both come out as 25601. Patch releases above 9 are not expected
|
||||
// on Studio, and widening the digit now would renumber every build, so
|
||||
// the constraint is recorded rather than fixed.
|
||||
versionCode versionMajor * 10000 + versionMinor * 100 + versionPatch * 10 + versionBuild
|
||||
versionName "${versionMajor}.${versionMinor}.${versionPatch}"
|
||||
|
||||
|
||||
@@ -47,7 +47,7 @@ app_gradle="$android/app/build.gradle"
|
||||
version_field() {
|
||||
sed -n "s/^def $1 *= *\([0-9]*\).*/\1/p" "$app_gradle" | head -n1
|
||||
}
|
||||
app_version="$(version_field versionMajor).$(version_field versionMinor).$(version_field versionPatch)"
|
||||
app_version="${REASONIX_APP_VERSION:-$(version_field versionMajor).$(version_field versionMinor).$(version_field versionPatch)}"
|
||||
build_time_utc="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||
ldflags="-s -w"
|
||||
ldflags="$ldflags -X main.version=v$app_version"
|
||||
|
||||
@@ -32,7 +32,8 @@ fi
|
||||
|
||||
# 32 random bytes as hex — the same shape `Kernel.mintToken` writes.
|
||||
token=$(od -An -tx1 -N32 /dev/urandom | tr -d ' \n')
|
||||
printf '%s\n' "$token" > "$work/home/remote/launch.token"
|
||||
# The kernel refuses a token file other users can read.
|
||||
(umask 077; printf '%s\n' "$token" > "$work/home/remote/launch.token")
|
||||
|
||||
export REASONIX_HOME="$work/home"
|
||||
export HOME="$work/home"
|
||||
|
||||
@@ -420,7 +420,7 @@ own outcome).
|
||||
|
||||
| Flag | Default | Purpose |
|
||||
| --- | --- | --- |
|
||||
| `-mode` | `suite` | `suite` \| `diff` \| `swebench` \| `compare` \| `traj` (`diff` generates tests for the PR diff; `swebench` runs the official per-instance evaluation; `compare` renders KPI/Pareto readouts from 2+ `-json` reports; `traj` re-digests recorded trajectory files without spending tokens). |
|
||||
| `-mode` | `suite` | `suite` \| `diff` \| `swebench` \| `compare` \| `traj` (`diff` generates tests for the PR diff; `swebench` runs the official per-instance evaluation; `compare` renders KPI/Pareto readouts from 2+ `-json` reports; `traj` re-digests recorded trajectory files without spending tokens; `audit` checks a batch's published figures against its retained rows). |
|
||||
| `-suite` | `benchmarks/e2e` | Suite root (must contain `tasks/<id>/`). |
|
||||
| `-task` | *(all)* | Suite mode: run only these comma-separated task IDs (e.g. `-task fix-add-bug`); unknown IDs fail with the available list. |
|
||||
| `-attempts` | `1` | Suite and diff modes: retry a task until an attempt passes, up to N; enables the `Pass@≤N` KPI, and TTCS charges a retried solve with its failed attempts' wall. |
|
||||
@@ -433,6 +433,7 @@ own outcome).
|
||||
| `-ablate` | *(none)* | Ablation arm: comma-separated subsystems to switch off — `evidence`, `planner`, `subagent`, `retrieval`, `compaction`, `upstream` and `recall-search`; `none` \| `all`. |
|
||||
| `-out` | *(stdout)* | Write the markdown report here. |
|
||||
| `-json` | *(none)* | Write the JSON report here (optional). |
|
||||
| `-batch` | *(none)* | Suite mode: write `results.jsonl` (one row per attempt) and `batch.json` (manifest v1) here, so the report's figures can be audited later. Audit mode: read the batch from here. See [Auditing a batch](#auditing-a-batch). |
|
||||
| `-trajectories` | *(none)* | Suite mode: write one `<task-id>.trajectory.jsonl` per task into this directory (the agent's full event stream with timestamps — see `reasonix run --trajectory`). The report gains a time-attribution line (tools vs. model) and each JSON result a `trajectory` digest. |
|
||||
| `-force-planner` | `false` | Suite mode: prefix each prompt with a plan-first directive so the two-model turn engages regardless of the planner gate. Use for the "with planner" arm of an A/B; results carry `plan_forced` so arms are only comparable with equal forcing. |
|
||||
| `-anchor` | `blind` | Suite mode: which hypothesis the agent holds before it looks at anything — `blind` (none, the control) \| `correct` \| `wrong`. The seeded arms prefix each prompt with the task's authored seed and **skip** tasks that have none, so an unseeded control run never lands in a seeded denominator. Results carry `anchor`. See [Anchor resistance](#anchor-resistance). |
|
||||
@@ -455,10 +456,65 @@ Diff-mode flags:
|
||||
| `-timeout` | `1200` | Agent timeout in seconds (diff mode). |
|
||||
| `-attempts` | `1` | Diff mode: retry up to N times until a run passes (stochastic agent). |
|
||||
|
||||
## Auditing a batch
|
||||
|
||||
`-batch DIR` writes two files from a suite run: `results.jsonl` (one row per
|
||||
attempt — the raw record every figure is computed from) and `batch.json`
|
||||
(manifest v1). `-mode audit -batch DIR` re-derives the report's headline figures
|
||||
from `results.jsonl`, prints the manifest's claim beside the recomputed value,
|
||||
and exits non-zero naming any figure that moved. It also refuses a batch whose
|
||||
manifest is missing, records no commit, or whose rows are truncated.
|
||||
|
||||
This is an **aggregation audit, not a replay**: it proves the published figures
|
||||
follow from the retained rows. It does not re-execute the model, tools or
|
||||
environment, and cannot show the experiment would reproduce. A real replay would
|
||||
need the commit, a corpus fingerprint, the model and provider configuration, the
|
||||
seed, the flags, the raw results and the runtime version; it is not implemented.
|
||||
|
||||
`schema_version` is top level. The rest is three groups:
|
||||
|
||||
| Group | Fields | Answers |
|
||||
| --- | --- | --- |
|
||||
| `over` | `command.mode` and `command.axes` (every flag that changes what a figure means; paths omitted); `tasks.count` and `tasks.digest` (sha256 over sorted, NUL-separated task ids — the corpus identity, never the task text); `filters`; `denominator` (`unit`, `definition`, `value`) | what the figures are over |
|
||||
| `producer` | `commit` (or an explicit `unknown`, never a guess), `commit_source`, `dirty`, `model`, `provider`, `seed`, `started_at`, `finished_at`, `harness_version`, `go_version` | which run produced them |
|
||||
| `trust` | `results_sha256`, `claimed` (the headline figures), `ledger`, `retention` | why they can still be checked |
|
||||
|
||||
The ledger accounts for every planned run, and the audit checks that the counts
|
||||
form a funnel (`scored ≤ completed ≤ attempted`, `failed ≤ scored`,
|
||||
`scored = pass1 + failed`, `rows = attempted + skipped`). A row count is never a
|
||||
denominator: `denominator.value` is a count of scored tasks, and `rows` is
|
||||
published only to show what was retained.
|
||||
|
||||
| Count | Meaning |
|
||||
| --- | --- |
|
||||
| `planned` | graded runs the batch set out to attempt: filtered tasks × trials |
|
||||
| `attempted` | retained rows for runs that started (skipped rows are not attempts) |
|
||||
| `completed` | attempts that ended and left a row; this harness writes a row only after an attempt ends, so it equals `attempted` and the equality is checked, not assumed |
|
||||
| `scored` | completed first attempts of solvable, non-skipped tasks — the pass@1 denominator |
|
||||
| `failed` | scored attempts the grader did not pass |
|
||||
| `unmeasured` | attempts whose cost and tokens are unknown because no metrics landed; kept out of spend totals, never counted as zero |
|
||||
| `excluded` | rows kept out of the accuracy denominator: skipped, `no_solution`, and retry entries |
|
||||
|
||||
Spend and cache fields are omitted from `claimed` when nothing was measured, so
|
||||
an absent figure reads as unknown rather than as a measured zero. `seed` is
|
||||
omitted for the same reason: the harness does not set a sampling seed.
|
||||
`results_sha256` covers the values and the presence of every row; line endings,
|
||||
indentation and key order are normalised away because none of them is evidence,
|
||||
while number spelling is kept (a rewritten `1` is not `1.0`).
|
||||
A successful audit records the verifier version (`audit.verifier_version`) and
|
||||
the digest it checked in `batch.json`, so a re-verification can tell which code
|
||||
checked the batch.
|
||||
|
||||
`results.jsonl` is retained unredacted beside the manifest — task prompts and
|
||||
agent notes included — and is not public. The manifest itself carries a task-id
|
||||
digest, no prompt text, no absolute paths, no endpoints or keys and no model
|
||||
output. A claim whose rows are gone cannot be audited: there is nothing to
|
||||
recompute from, and this tool will not certify it.
|
||||
|
||||
## Dataset retention
|
||||
|
||||
Keep every `-json` report and `-trajectories` directory from real runs: they
|
||||
are the accumulating corpus — per-task contracts-to-be, full event
|
||||
Keep every `-json` report, `-trajectories` directory and `-batch` directory from
|
||||
real runs: they are the accumulating corpus — per-task contracts-to-be, full event
|
||||
trajectories, checkpoint oracle verdicts, stop curves and phase traces — that
|
||||
any future offline learning (routing, stop policies, budgets) would train
|
||||
and evaluate on. The control plane stays deterministic and interpretable
|
||||
|
||||
@@ -0,0 +1,428 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// runAuditMode prints both sides of the audit before returning the failure, so
|
||||
// a moved figure is visible even when the exit code is non-zero.
|
||||
func runAuditMode(dir, outMD string) error {
|
||||
report, err := auditBatch(dir)
|
||||
if report != "" {
|
||||
emit(report, outMD, "")
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// auditBatch re-derives the published figures from results.jsonl and checks
|
||||
// them against batch.json. It reports both sides and never re-runs anything.
|
||||
func auditBatch(dir string) (string, error) {
|
||||
if strings.TrimSpace(dir) == "" {
|
||||
return "", errors.New("audit needs -batch <directory>: the directory a suite run wrote")
|
||||
}
|
||||
manifestPath := filepath.Join(dir, batchManifestName)
|
||||
data, err := os.ReadFile(manifestPath)
|
||||
if err != nil {
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
return "", fmt.Errorf("no %s under %s: a batch that kept no manifest cannot be audited", batchManifestName, dir)
|
||||
}
|
||||
return "", fmt.Errorf("read %s: %w", batchManifestName, err)
|
||||
}
|
||||
var m batchManifest
|
||||
if err := json.Unmarshal(data, &m); err != nil {
|
||||
return "", fmt.Errorf("%s is not valid JSON: %w", batchManifestName, err)
|
||||
}
|
||||
if m.SchemaVersion != batchSchemaVersion {
|
||||
return "", fmt.Errorf("%s declares schema version %d; this audit implements v%d", batchManifestName, m.SchemaVersion, batchSchemaVersion)
|
||||
}
|
||||
if commit := strings.TrimSpace(m.Producer.Commit); commit == "" || commit == "unknown" {
|
||||
return "", fmt.Errorf("%s records no commit (%q): the figures cannot be tied to a version, so this audit stops", batchManifestName, m.Producer.Commit)
|
||||
}
|
||||
|
||||
rows, canonical, err := readBatch(filepath.Join(dir, batchResultsName))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
hash := hashOf(canonical)
|
||||
stats := gatherSuiteStats(rows)
|
||||
fromRows := ledgerFromRows(rows)
|
||||
figures, failures := compareClaims(m.Trust.Claimed, stats)
|
||||
ledger, ledgerFailures := compareLedger(m.Trust.Ledger, fromRows)
|
||||
failures = append(failures, ledgerFailures...)
|
||||
failures = append(failures, ledgerInvariants(m.Trust.Ledger)...)
|
||||
failures = append(failures, denominatorChecks(m, fromRows)...)
|
||||
if hash != m.Trust.ResultsSHA256 {
|
||||
failures = append([]string{fmt.Sprintf("results_sha256: manifest %s, rows %s", textOrUnknown(m.Trust.ResultsSHA256), hash)}, failures...)
|
||||
}
|
||||
report := renderAudit(dir, m, rows, hash, figures, ledger, failures)
|
||||
if len(failures) > 0 {
|
||||
return report, fmt.Errorf("%d mismatch(es): the published figures do not follow from the retained rows", len(failures))
|
||||
}
|
||||
if err := recordAuditReceipt(manifestPath, m, hash); err != nil {
|
||||
return report, err
|
||||
}
|
||||
return report, nil
|
||||
}
|
||||
|
||||
// readBatch parses results.jsonl once: the typed rows the figures come from,
|
||||
// and the canonical bytes results_sha256 covers.
|
||||
func readBatch(path string) ([]result, []byte, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
return nil, nil, fmt.Errorf("no %s: the manifest describes rows that are not beside it", batchResultsName)
|
||||
}
|
||||
return nil, nil, fmt.Errorf("read %s: %w", batchResultsName, err)
|
||||
}
|
||||
if len(data) > 0 && data[len(data)-1] != '\n' {
|
||||
return nil, nil, fmt.Errorf("%s does not end in a newline: its last row is truncated", batchResultsName)
|
||||
}
|
||||
var rows []result
|
||||
var canonical bytes.Buffer
|
||||
line := 0
|
||||
for raw := range bytes.SplitSeq(data, []byte("\n")) {
|
||||
if len(bytes.TrimSpace(raw)) == 0 {
|
||||
continue
|
||||
}
|
||||
line++
|
||||
var r result
|
||||
if err := json.Unmarshal(raw, &r); err != nil {
|
||||
return nil, nil, fmt.Errorf("%s line %d is not a complete row (truncated or corrupt): %w", batchResultsName, line, err)
|
||||
}
|
||||
rows = append(rows, r)
|
||||
canon, err := canonicalJSON(raw)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("%s line %d: %w", batchResultsName, line, err)
|
||||
}
|
||||
canonical.Write(canon)
|
||||
canonical.WriteByte('\n')
|
||||
}
|
||||
return rows, canonical.Bytes(), nil
|
||||
}
|
||||
|
||||
// auditFigure is one number on both sides: what the manifest claimed and what
|
||||
// the retained rows give. Unknown is printed as unknown, never as zero.
|
||||
type auditFigure struct {
|
||||
name string
|
||||
claimed string
|
||||
actual string
|
||||
note string
|
||||
moved bool
|
||||
}
|
||||
|
||||
func compareClaims(c batchClaims, s suiteStats) ([]auditFigure, []string) {
|
||||
rows := []struct {
|
||||
name string
|
||||
claimed string
|
||||
actual string
|
||||
required bool
|
||||
}{
|
||||
{"solved", intText(c.Solved), strconv.Itoa(s.passed), true},
|
||||
{"pass1", intText(c.Pass1), strconv.Itoa(s.pass1), true},
|
||||
{"prompt_tokens", intText(c.PromptTokens), strconv.Itoa(s.pTok), false},
|
||||
{"completion_tokens", intText(c.CompletionTokens), strconv.Itoa(s.cTok), false},
|
||||
{"cache_hit_tokens", intText(c.CacheHitTokens), strconv.Itoa(s.hit), false},
|
||||
{"cache_miss_tokens", intText(c.CacheMissTokens), strconv.Itoa(s.miss), false},
|
||||
{"cost", floatText(c.Cost), floatText(new(s.cost)), false},
|
||||
{"currency", c.Currency, s.currency, false},
|
||||
{"accounted", intText(c.Accounted), strconv.Itoa(s.accounted), true},
|
||||
{"unaccounted", intText(c.Unaccounted), strconv.Itoa(s.unaccounted), true},
|
||||
{"partial", intText(c.Partial), strconv.Itoa(s.partial), true},
|
||||
{"tcts_median_ms", int64Text(c.TCTSMedianMs), strconv.FormatInt(median(s.ttcs), 10), false},
|
||||
}
|
||||
var figures []auditFigure
|
||||
var failures []string
|
||||
for _, row := range rows {
|
||||
f := auditFigure{name: row.name, claimed: row.claimed, actual: row.actual}
|
||||
switch {
|
||||
case row.claimed == "":
|
||||
f.claimed = "unknown"
|
||||
f.note = "not claimed"
|
||||
if row.required {
|
||||
f.note = "required, absent"
|
||||
failures = append(failures, row.name+": the manifest claims no "+row.name+"; an absent figure is required here")
|
||||
}
|
||||
case row.claimed != row.actual:
|
||||
f.moved = true
|
||||
failures = append(failures, fmt.Sprintf("%s: manifest %s, rows %s", row.name, row.claimed, row.actual))
|
||||
}
|
||||
figures = append(figures, f)
|
||||
}
|
||||
return figures, failures
|
||||
}
|
||||
|
||||
// compareLedger checks the counts the rows can answer. Planned cannot be
|
||||
// recomputed from rows — it is what the batch set out to do — so it is only
|
||||
// held to the consistency checks in ledgerInvariants.
|
||||
func compareLedger(declared, actual batchLedger) ([]auditFigure, []string) {
|
||||
type entry struct {
|
||||
name string
|
||||
claimed *int
|
||||
replayed *int
|
||||
}
|
||||
entries := []entry{
|
||||
{"planned", declared.Planned, nil},
|
||||
{"attempted", declared.Attempted, actual.Attempted},
|
||||
{"completed", declared.Completed, actual.Completed},
|
||||
{"scored", declared.Scored, actual.Scored},
|
||||
{"failed", declared.Failed, actual.Failed},
|
||||
{"unmeasured", declared.Unmeasured, actual.Unmeasured},
|
||||
{"excluded", declared.Excluded, actual.Excluded},
|
||||
{"rows", declared.Rows, actual.Rows},
|
||||
{"skipped", declared.Skipped, actual.Skipped},
|
||||
}
|
||||
required := map[string]bool{"planned": true, "attempted": true, "completed": true, "scored": true, "failed": true, "unmeasured": true, "excluded": true}
|
||||
var figures []auditFigure
|
||||
var failures []string
|
||||
for _, e := range entries {
|
||||
f := auditFigure{name: e.name}
|
||||
switch {
|
||||
case e.claimed != nil:
|
||||
f.claimed = strconv.Itoa(*e.claimed)
|
||||
default:
|
||||
f.claimed = "unknown"
|
||||
f.note = "not claimed"
|
||||
if required[e.name] {
|
||||
f.note = "required, absent"
|
||||
failures = append(failures, "ledger."+e.name+": absent; schema v1 requires this count, and absent is unknown, never zero")
|
||||
}
|
||||
}
|
||||
if e.replayed == nil {
|
||||
f.actual = "n/a"
|
||||
f.note = joinNote(f.note, "not recomputable from rows")
|
||||
} else {
|
||||
f.actual = strconv.Itoa(*e.replayed)
|
||||
}
|
||||
if e.claimed != nil && e.replayed != nil && *e.claimed != *e.replayed {
|
||||
f.moved = true
|
||||
failures = append(failures, fmt.Sprintf("ledger.%s: manifest %d, rows %d", e.name, *e.claimed, *e.replayed))
|
||||
}
|
||||
figures = append(figures, f)
|
||||
}
|
||||
return figures, failures
|
||||
}
|
||||
|
||||
// ledgerInvariants is the denominator guard: the counts must describe a
|
||||
// funnel, so a manifest cannot publish a numerator larger than its base.
|
||||
func ledgerInvariants(l batchLedger) []string {
|
||||
var out []string
|
||||
check := func(lower, upper *int, lowerName, upperName string) {
|
||||
if lower == nil || upper == nil || *lower <= *upper {
|
||||
return
|
||||
}
|
||||
out = append(out, fmt.Sprintf("ledger: %s %d exceeds %s %d", lowerName, *lower, upperName, *upper))
|
||||
}
|
||||
check(l.Scored, l.Planned, "scored", "planned")
|
||||
check(l.Completed, l.Attempted, "completed", "attempted")
|
||||
check(l.Scored, l.Completed, "scored", "completed")
|
||||
check(l.Failed, l.Scored, "failed", "scored")
|
||||
check(l.Unmeasured, l.Completed, "unmeasured", "completed")
|
||||
check(l.Excluded, l.Rows, "excluded", "rows")
|
||||
check(l.Unmeasured, l.Attempted, "unmeasured", "attempted")
|
||||
if l.Rows != nil && l.Attempted != nil && l.Skipped != nil && *l.Rows != *l.Attempted+*l.Skipped {
|
||||
out = append(out, fmt.Sprintf("ledger: rows %d is not attempted %d + skipped %d", *l.Rows, *l.Attempted, *l.Skipped))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// denominatorChecks ties the published denominator to the ledger and to the
|
||||
// outcome split, so passes and failures have to add up to it.
|
||||
func denominatorChecks(m batchManifest, fromRows batchLedger) []string {
|
||||
var out []string
|
||||
d := m.Over.Denominator
|
||||
if d.Unit != denominatorUnit || d.Definition != denominatorDefinition {
|
||||
out = append(out, "denominator: the manifest defines it as "+strconv.Quote(d.Unit+" / "+d.Definition)+", which this audit does not implement")
|
||||
}
|
||||
if fromRows.Scored != nil && d.Value != *fromRows.Scored {
|
||||
out = append(out, fmt.Sprintf("denominator: manifest %d, rows %d", d.Value, *fromRows.Scored))
|
||||
}
|
||||
if m.Trust.Ledger.Scored != nil && d.Value != *m.Trust.Ledger.Scored {
|
||||
out = append(out, fmt.Sprintf("denominator: manifest %d, ledger.scored %d", d.Value, *m.Trust.Ledger.Scored))
|
||||
}
|
||||
if m.Trust.Claimed.Pass1 != nil && m.Trust.Ledger.Failed != nil && m.Trust.Ledger.Scored != nil {
|
||||
if sum := *m.Trust.Claimed.Pass1 + *m.Trust.Ledger.Failed; sum != *m.Trust.Ledger.Scored {
|
||||
out = append(out, fmt.Sprintf("ledger: scored %d is not pass1 %d + failed %d", *m.Trust.Ledger.Scored, *m.Trust.Claimed.Pass1, *m.Trust.Ledger.Failed))
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// recordAuditReceipt keeps the verifier identity in the manifest, so a later
|
||||
// re-verification can tell which code checked it.
|
||||
func recordAuditReceipt(manifestPath string, m batchManifest, hash string) error {
|
||||
m.Audit = &batchAuditReceipt{
|
||||
VerifierVersion: auditToolVersion,
|
||||
VerifiedAt: time.Now().UTC().Format(time.RFC3339),
|
||||
ResultsSHA256: hash,
|
||||
}
|
||||
b, err := json.MarshalIndent(m, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("encode audit receipt: %w", err)
|
||||
}
|
||||
if err := os.WriteFile(manifestPath, append(b, '\n'), 0o644); err != nil {
|
||||
return fmt.Errorf("record audit receipt: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func renderAudit(dir string, m batchManifest, rows []result, hash string, figures, ledger []auditFigure, failures []string) string {
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "## e2ebench batch audit — `%s`\n\n", filepath.Base(strings.TrimSpace(dir)))
|
||||
b.WriteString("**What the figures are over**\n\n")
|
||||
fmt.Fprintf(&b, "- schema: manifest v%d, audited by `%s/%d`\n", m.SchemaVersion, auditToolName, auditToolVersion)
|
||||
fmt.Fprintf(&b, "- command: `%s` · axes: %s\n", m.Over.Command.Mode, axisText(m.Over.Command.Axes))
|
||||
fmt.Fprintf(&b, "- task ids: `%s` (%d ids; task text is represented by this digest, not stored)\n", textOrUnknown(m.Over.Tasks.Digest), m.Over.Tasks.Count)
|
||||
fmt.Fprintf(&b, "- filters: %s\n", filterText(m.Over.Filters))
|
||||
fmt.Fprintf(&b, "- denominator: **%d %ss** — %s\n", m.Over.Denominator.Value, m.Over.Denominator.Unit, m.Over.Denominator.Definition)
|
||||
b.WriteString("\n**Which run produced them**\n\n")
|
||||
fmt.Fprintf(&b, "- commit: `%s` (%s, %s)\n", m.Producer.Commit, sourceText(m.Producer.CommitSource), dirtyText(m.Producer.Dirty))
|
||||
fmt.Fprintf(&b, "- model: %s · provider: %s\n", nameText(m.Producer.Model), nameText(m.Producer.Provider))
|
||||
fmt.Fprintf(&b, "- seed: %s\n", seedText(m.Producer.Seed))
|
||||
fmt.Fprintf(&b, "- started: %s · finished: %s\n", m.Producer.StartedAt, m.Producer.FinishedAt)
|
||||
fmt.Fprintf(&b, "- harness: %s · runtime %s\n", m.Producer.HarnessVersion, m.Producer.GoVersion)
|
||||
b.WriteString("\n**Why they can still be trusted**\n\n")
|
||||
fmt.Fprintf(&b, "- rows retained: %d in `%s`\n", len(rows), batchResultsName)
|
||||
fmt.Fprintf(&b, "- results_sha256: %s\n", hashText(m.Trust.ResultsSHA256, hash))
|
||||
if m.Audit != nil {
|
||||
fmt.Fprintf(&b, "- last verified by `%s/%d` at %s over `%s`\n", auditToolName, m.Audit.VerifierVersion, m.Audit.VerifiedAt, m.Audit.ResultsSHA256)
|
||||
}
|
||||
fmt.Fprintf(&b, "- retention: %s\n", textOrUnknown(m.Trust.Retention))
|
||||
b.WriteString("\n| claimed figure | manifest | rows | |\n|---|---:|---:|---|\n")
|
||||
writeFigures(&b, figures)
|
||||
b.WriteString("\n| ledger | manifest | rows | |\n|---|---:|---:|---|\n")
|
||||
writeFigures(&b, ledger)
|
||||
b.WriteString("\n")
|
||||
if len(failures) > 0 {
|
||||
b.WriteString("**Mismatches**\n\n")
|
||||
for _, f := range failures {
|
||||
fmt.Fprintf(&b, "- %s\n", f)
|
||||
}
|
||||
b.WriteString("\n")
|
||||
}
|
||||
if len(failures) > 0 {
|
||||
fmt.Fprintf(&b, "**Verdict:** the published figures do **not** follow from the retained rows (%d mismatch(es)).\n\n", len(failures))
|
||||
} else {
|
||||
b.WriteString("**Verdict:** the published figures follow from the retained rows.\n\n")
|
||||
}
|
||||
b.WriteString("<sub>An aggregation audit, not a reproduction: the model, tools and environment are not re-executed, so this cannot show the experiment would reproduce. A real replay would need the commit, corpus fingerprint, model and provider configuration, seed, flags, raw results and runtime version; it is not implemented.</sub>\n")
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func writeFigures(b *strings.Builder, figures []auditFigure) {
|
||||
for _, f := range figures {
|
||||
note := f.note
|
||||
if f.moved {
|
||||
note = joinNote(note, "**moved**")
|
||||
}
|
||||
fmt.Fprintf(b, "| %s | %s | %s | %s |\n", f.name, f.claimed, f.actual, note)
|
||||
}
|
||||
}
|
||||
|
||||
func axisText(axes []batchFlag) string {
|
||||
if len(axes) == 0 {
|
||||
return "none recorded"
|
||||
}
|
||||
parts := make([]string, len(axes))
|
||||
for i, a := range axes {
|
||||
parts[i] = a.Name + "=" + a.Value
|
||||
}
|
||||
return "`" + strings.Join(parts, "` · `") + "`"
|
||||
}
|
||||
|
||||
func filterText(f batchFilters) string {
|
||||
if strings.TrimSpace(f.Task) == "" {
|
||||
return "none (`-task` unset: every task in the suite)"
|
||||
}
|
||||
return "`-task " + f.Task + "`"
|
||||
}
|
||||
|
||||
func sourceText(source string) string {
|
||||
if source == "" {
|
||||
return "source unknown"
|
||||
}
|
||||
return "source " + source
|
||||
}
|
||||
|
||||
func dirtyText(dirty *bool) string {
|
||||
if dirty == nil {
|
||||
return "dirty state unknown"
|
||||
}
|
||||
if *dirty {
|
||||
return "dirty tree: the commit does not identify the code that ran"
|
||||
}
|
||||
return "clean tree"
|
||||
}
|
||||
|
||||
// nameText prints an identity name, and says unknown when the field is absent
|
||||
// rather than printing an empty name that reads as "none".
|
||||
func nameText(name string) string {
|
||||
if strings.TrimSpace(name) == "" {
|
||||
return "unknown (absent from the manifest; absent is not zero)"
|
||||
}
|
||||
return "`" + name + "`"
|
||||
}
|
||||
|
||||
func seedText(seed *int64) string {
|
||||
if seed == nil {
|
||||
return "unknown (absent from the manifest; an absent seed is not seed 0)"
|
||||
}
|
||||
return strconv.FormatInt(*seed, 10)
|
||||
}
|
||||
|
||||
func hashText(claimed, actual string) string {
|
||||
if claimed == "" {
|
||||
return "unknown (the manifest claims no digest)"
|
||||
}
|
||||
if claimed != actual {
|
||||
return "**MISMATCH** — manifest `" + claimed + "`, rows `" + actual + "`"
|
||||
}
|
||||
return "ok (`" + actual + "`)"
|
||||
}
|
||||
|
||||
func textOrUnknown(s string) string {
|
||||
if strings.TrimSpace(s) == "" {
|
||||
return "unknown"
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func joinNote(a, b string) string {
|
||||
switch {
|
||||
case a == "":
|
||||
return b
|
||||
case b == "":
|
||||
return a
|
||||
default:
|
||||
return a + "; " + b
|
||||
}
|
||||
}
|
||||
|
||||
func intText(p *int) string {
|
||||
if p == nil {
|
||||
return ""
|
||||
}
|
||||
return strconv.Itoa(*p)
|
||||
}
|
||||
|
||||
func int64Text(p *int64) string {
|
||||
if p == nil {
|
||||
return ""
|
||||
}
|
||||
return strconv.FormatInt(*p, 10)
|
||||
}
|
||||
|
||||
func floatText(p *float64) string {
|
||||
if p == nil {
|
||||
return ""
|
||||
}
|
||||
return strconv.FormatFloat(*p, 'f', -1, 64)
|
||||
}
|
||||
@@ -0,0 +1,458 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"runtime/debug"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/contract/config"
|
||||
)
|
||||
|
||||
const (
|
||||
batchSchemaVersion = 1
|
||||
batchResultsName = "results.jsonl"
|
||||
batchManifestName = "batch.json"
|
||||
// auditToolVersion is the version of the code below; every successful
|
||||
// audit stamps it into the manifest, so a later reader can tell which
|
||||
// verifier checked the batch.
|
||||
auditToolVersion = 1
|
||||
auditToolName = "e2ebench-audit"
|
||||
// batchScope names the claim: an aggregation audit, not a replay.
|
||||
batchScope = "aggregation audit: the claimed figures are recomputed from the retained rows; the model, tools and environment are not re-executed, so this is not a reproduction of the experiment"
|
||||
// denominatorUnit and denominatorDefinition say what pass@1 divides by.
|
||||
denominatorUnit = "task"
|
||||
denominatorDefinition = "first attempts of solvable, non-skipped tasks: pass@1 divides passes by this count, never by the retained row count"
|
||||
// batchRetention is the one line a reader needs before sharing the batch.
|
||||
batchRetention = "results.jsonl is retained unredacted beside this manifest (task prompts and agent notes included) and is not public; the manifest carries a task-id digest, no prompt text, no absolute paths, no endpoints or keys and no model output"
|
||||
)
|
||||
|
||||
// batchOver is what the figures are over: the command, the task set identity,
|
||||
// the filters, and the denominator the accuracy is divided by.
|
||||
type batchOver struct {
|
||||
Command batchCommand `json:"command"`
|
||||
Tasks batchTaskSet `json:"tasks"`
|
||||
Filters batchFilters `json:"filters"`
|
||||
Denominator batchDenominator `json:"denominator"`
|
||||
}
|
||||
|
||||
// batchCommand records the mode and every flag that changes what a figure
|
||||
// means. Output paths are deliberately absent: they are not evidence, and an
|
||||
// absolute path is the operator's, not the batch's.
|
||||
type batchCommand struct {
|
||||
Mode string `json:"mode"`
|
||||
Axes []batchFlag `json:"axes"`
|
||||
}
|
||||
|
||||
type batchFlag struct {
|
||||
Name string `json:"name"`
|
||||
Value string `json:"value"`
|
||||
}
|
||||
|
||||
// batchTaskSet identifies the corpus without carrying its text: a stable
|
||||
// digest over the sorted task ids and how many there were.
|
||||
type batchTaskSet struct {
|
||||
Count int `json:"count"`
|
||||
Digest string `json:"digest"`
|
||||
}
|
||||
|
||||
type batchFilters struct {
|
||||
// Task is -task as given; empty means no filter.
|
||||
Task string `json:"task,omitempty"`
|
||||
}
|
||||
|
||||
// batchDenominator names the unit pass@1 divides by, so a row count can never
|
||||
// stand in for it.
|
||||
type batchDenominator struct {
|
||||
Unit string `json:"unit"`
|
||||
Definition string `json:"definition"`
|
||||
Value int `json:"value"`
|
||||
}
|
||||
|
||||
// batchProducer is which run produced the figures. Absent optional fields are
|
||||
// unknown, never zero: a missing seed is not seed 0.
|
||||
type batchProducer struct {
|
||||
Commit string `json:"commit"`
|
||||
CommitSource string `json:"commit_source,omitempty"`
|
||||
Dirty *bool `json:"dirty,omitempty"`
|
||||
Model string `json:"model,omitempty"`
|
||||
Provider string `json:"provider,omitempty"`
|
||||
Seed *int64 `json:"seed,omitempty"`
|
||||
StartedAt string `json:"started_at"`
|
||||
FinishedAt string `json:"finished_at"`
|
||||
HarnessVersion string `json:"harness_version"`
|
||||
GoVersion string `json:"go_version"`
|
||||
}
|
||||
|
||||
// batchTrust is why the figures can still be checked: the digest of the rows,
|
||||
// the headline figures as published, and the ledger every planned run is
|
||||
// accounted for in.
|
||||
type batchTrust struct {
|
||||
ResultsSHA256 string `json:"results_sha256"`
|
||||
Claimed batchClaims `json:"claimed"`
|
||||
Ledger batchLedger `json:"ledger"`
|
||||
Retention string `json:"retention"`
|
||||
}
|
||||
|
||||
// batchClaims are the headline figures the report publishes. A figure the run
|
||||
// could not measure is absent, not zero.
|
||||
type batchClaims struct {
|
||||
Solved *int `json:"solved"`
|
||||
Pass1 *int `json:"pass1"`
|
||||
PromptTokens *int `json:"prompt_tokens,omitempty"`
|
||||
CompletionTokens *int `json:"completion_tokens,omitempty"`
|
||||
CacheHitTokens *int `json:"cache_hit_tokens,omitempty"`
|
||||
CacheMissTokens *int `json:"cache_miss_tokens,omitempty"`
|
||||
Cost *float64 `json:"cost,omitempty"`
|
||||
Currency string `json:"currency,omitempty"`
|
||||
Accounted *int `json:"accounted"`
|
||||
Unaccounted *int `json:"unaccounted"`
|
||||
Partial *int `json:"partial"`
|
||||
TCTSMedianMs *int64 `json:"tcts_median_ms,omitempty"`
|
||||
}
|
||||
|
||||
// batchLedger accounts for every planned run. Rows is the retained record, not
|
||||
// a denominator; Scored is the denominator.
|
||||
type batchLedger struct {
|
||||
Planned *int `json:"planned"`
|
||||
Attempted *int `json:"attempted"`
|
||||
Completed *int `json:"completed"`
|
||||
Scored *int `json:"scored"`
|
||||
Failed *int `json:"failed"`
|
||||
Unmeasured *int `json:"unmeasured"`
|
||||
Excluded *int `json:"excluded"`
|
||||
Rows *int `json:"rows,omitempty"`
|
||||
Skipped *int `json:"skipped,omitempty"`
|
||||
Definitions map[string]string `json:"definitions,omitempty"`
|
||||
}
|
||||
|
||||
// batchAuditReceipt is what a successful audit leaves behind.
|
||||
type batchAuditReceipt struct {
|
||||
VerifierVersion int `json:"verifier_version"`
|
||||
VerifiedAt string `json:"verified_at"`
|
||||
ResultsSHA256 string `json:"results_sha256"`
|
||||
}
|
||||
|
||||
// batchManifest is written beside results.jsonl so the report never has to be
|
||||
// reconstructed from a terminal scrollback.
|
||||
type batchManifest struct {
|
||||
SchemaVersion int `json:"schema_version"`
|
||||
Scope string `json:"scope"`
|
||||
Over batchOver `json:"over"`
|
||||
Producer batchProducer `json:"producer"`
|
||||
Trust batchTrust `json:"trust"`
|
||||
Audit *batchAuditReceipt `json:"audit,omitempty"`
|
||||
}
|
||||
|
||||
// ledgerDefinitions explains each count in the manifest itself, so the
|
||||
// denominator cannot be reinterpreted after the fact.
|
||||
var ledgerDefinitions = map[string]string{
|
||||
"planned": "graded runs the batch set out to attempt: filtered tasks x trials",
|
||||
"attempted": "retained rows for runs that started (skipped rows are not attempts)",
|
||||
"completed": "attempts that ended and left a row; this harness writes a row only after an attempt ends",
|
||||
"scored": "completed first attempts of solvable, non-skipped tasks: the pass@1 denominator",
|
||||
"failed": "scored attempts the grader did not pass",
|
||||
"unmeasured": "attempts whose cost and tokens are unknown because no metrics landed; kept out of spend totals",
|
||||
"excluded": "rows kept out of the accuracy denominator: skipped, no_solution, and retry entries",
|
||||
"rows": "rows retained in results.jsonl; a record of what was kept, never the denominator",
|
||||
"skipped": "planned runs deliberately not started (anchor has no seed, or the token budget ran out)",
|
||||
}
|
||||
|
||||
// batchProvenance is what the batch knows before any run happens. Everything
|
||||
// else in the manifest is derived from the rows that were written.
|
||||
type batchProvenance struct {
|
||||
dir string
|
||||
started time.Time
|
||||
planned int
|
||||
axes []batchFlag
|
||||
tasks batchTaskSet
|
||||
filters batchFilters
|
||||
commit string
|
||||
dirty *bool
|
||||
model string
|
||||
provider string
|
||||
}
|
||||
|
||||
// captureBatchProvenance reads the checkout and model identity before the
|
||||
// batch runs: git cannot describe a tree the agent has since changed.
|
||||
func captureBatchProvenance(dir string, cfg suiteConfig, tasks []task, taskFilter string) batchProvenance {
|
||||
commit, dirty := checkoutCommit(".")
|
||||
model, provider := modelIdentity(cfg.model)
|
||||
count, digest := taskSetDigest(tasks)
|
||||
return batchProvenance{
|
||||
dir: dir, started: time.Now().UTC(), planned: count * max(cfg.trials, 1),
|
||||
axes: batchAxes(cfg), tasks: batchTaskSet{Count: count, Digest: digest},
|
||||
filters: batchFilters{Task: taskFilter},
|
||||
commit: commit, dirty: dirty, model: model, provider: provider,
|
||||
}
|
||||
}
|
||||
|
||||
// writeBatch writes the rows first and the manifest second, so a manifest
|
||||
// never describes rows that are not beside it.
|
||||
func writeBatch(p batchProvenance, results []result) error {
|
||||
if err := os.MkdirAll(p.dir, 0o750); err != nil {
|
||||
return fmt.Errorf("create batch directory: %w", err)
|
||||
}
|
||||
var raw, canonical bytes.Buffer
|
||||
for _, r := range results {
|
||||
line, err := json.Marshal(r)
|
||||
if err != nil {
|
||||
return fmt.Errorf("encode row: %w", err)
|
||||
}
|
||||
raw.Write(line)
|
||||
raw.WriteByte('\n')
|
||||
canon, err := canonicalJSON(line)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
canonical.Write(canon)
|
||||
canonical.WriteByte('\n')
|
||||
}
|
||||
// Rows carry task prompts and agent notes, so they stay the operator's.
|
||||
if err := os.WriteFile(filepath.Join(p.dir, batchResultsName), raw.Bytes(), 0o600); err != nil {
|
||||
return fmt.Errorf("write %s: %w", batchResultsName, err)
|
||||
}
|
||||
manifest := buildBatchManifest(p, results, canonical.Bytes())
|
||||
b, err := json.MarshalIndent(manifest, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("encode manifest: %w", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(p.dir, batchManifestName), append(b, '\n'), 0o644); err != nil {
|
||||
return fmt.Errorf("write %s: %w", batchManifestName, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func buildBatchManifest(p batchProvenance, results []result, canonical []byte) batchManifest {
|
||||
stats := gatherSuiteStats(results)
|
||||
harness, goVersion := harnessIdentity()
|
||||
source := ""
|
||||
if p.commit != "unknown" {
|
||||
source = "checkout"
|
||||
}
|
||||
return batchManifest{
|
||||
SchemaVersion: batchSchemaVersion,
|
||||
Scope: batchScope,
|
||||
Over: batchOver{
|
||||
Command: batchCommand{Mode: "suite", Axes: p.axes},
|
||||
Tasks: p.tasks,
|
||||
Filters: p.filters,
|
||||
Denominator: batchDenominator{Unit: denominatorUnit, Definition: denominatorDefinition, Value: stats.ran},
|
||||
},
|
||||
Producer: batchProducer{
|
||||
Commit: p.commit, CommitSource: source, Dirty: p.dirty,
|
||||
Model: p.model, Provider: p.provider,
|
||||
StartedAt: p.started.Format(time.RFC3339), FinishedAt: time.Now().UTC().Format(time.RFC3339),
|
||||
HarnessVersion: harness, GoVersion: goVersion,
|
||||
},
|
||||
Trust: batchTrust{
|
||||
ResultsSHA256: hashOf(canonical),
|
||||
Claimed: claimsFor(stats),
|
||||
Ledger: ledgerFor(results, p.planned),
|
||||
Retention: batchRetention,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// claimsFor publishes the report's headline figures. Without a single
|
||||
// accounted run there is no measured spend: those fields stay absent.
|
||||
func claimsFor(s suiteStats) batchClaims {
|
||||
c := batchClaims{
|
||||
Solved: new(s.passed), Pass1: new(s.pass1),
|
||||
Accounted: new(s.accounted), Unaccounted: new(s.unaccounted), Partial: new(s.partial),
|
||||
}
|
||||
if s.accounted > 0 {
|
||||
c.PromptTokens, c.CompletionTokens = new(s.pTok), new(s.cTok)
|
||||
c.Cost, c.Currency = new(s.cost), s.currency
|
||||
if s.hit+s.miss > 0 {
|
||||
c.CacheHitTokens, c.CacheMissTokens = new(s.hit), new(s.miss)
|
||||
}
|
||||
}
|
||||
if s.passed > 0 {
|
||||
c.TCTSMedianMs = new(median(s.ttcs))
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// ledgerFor accounts for every planned run. A retry is a row, not a planned
|
||||
// unit: the plan is tasks x trials, which is why Scored and Attempted differ.
|
||||
func ledgerFor(results []result, planned int) batchLedger {
|
||||
l := ledgerFromRows(results)
|
||||
l.Planned = new(planned)
|
||||
l.Definitions = ledgerDefinitions
|
||||
return l
|
||||
}
|
||||
|
||||
func ledgerFromRows(results []result) batchLedger {
|
||||
var attempted, completed, scored, failed, unmeasured, excluded, skipped int
|
||||
for _, r := range results {
|
||||
inDenominator := !r.Skipped && !r.NoSolution && r.Attempt <= 1
|
||||
if r.Skipped {
|
||||
skipped++
|
||||
excluded++
|
||||
continue
|
||||
}
|
||||
attempted++
|
||||
completed++
|
||||
if r.Unaccounted {
|
||||
unmeasured++
|
||||
}
|
||||
if !inDenominator {
|
||||
excluded++
|
||||
continue
|
||||
}
|
||||
scored++
|
||||
if !r.Passed {
|
||||
failed++
|
||||
}
|
||||
}
|
||||
return batchLedger{
|
||||
Attempted: new(attempted), Completed: new(completed), Scored: new(scored),
|
||||
Failed: new(failed), Unmeasured: new(unmeasured), Excluded: new(excluded),
|
||||
Rows: new(len(results)), Skipped: new(skipped),
|
||||
}
|
||||
}
|
||||
|
||||
// canonicalJSON is what results_sha256 covers: line endings, indentation and
|
||||
// key order are dropped because none of them is evidence, while a changed value
|
||||
// or a missing row still changes the digest. Number spelling is kept, so a
|
||||
// rewritten 1 is not silently equal to 1.0.
|
||||
func canonicalJSON(line []byte) ([]byte, error) {
|
||||
dec := json.NewDecoder(bytes.NewReader(line))
|
||||
dec.UseNumber()
|
||||
var row any
|
||||
if err := dec.Decode(&row); err != nil {
|
||||
return nil, fmt.Errorf("row is not valid JSON: %w", err)
|
||||
}
|
||||
return json.Marshal(row)
|
||||
}
|
||||
|
||||
func hashOf(data []byte) string {
|
||||
sum := sha256.Sum256(data)
|
||||
return "sha256:" + hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
// checkoutCommit reports the checkout the batch runs from. Unknown is written
|
||||
// explicitly rather than guessed: a directory git cannot describe is not a
|
||||
// commit, and a dirty tree is not the commit it names.
|
||||
func checkoutCommit(dir string) (string, *bool) {
|
||||
commit := "unknown"
|
||||
out, err := exec.Command("git", "-C", dir, "rev-parse", "HEAD").Output()
|
||||
if err == nil {
|
||||
if sha := strings.TrimSpace(string(out)); sha != "" {
|
||||
commit = sha
|
||||
}
|
||||
}
|
||||
var dirty *bool
|
||||
out, err = exec.Command("git", "-C", dir, "status", "--porcelain").Output()
|
||||
if err == nil {
|
||||
state := strings.TrimSpace(string(out)) != ""
|
||||
dirty = &state
|
||||
}
|
||||
return commit, dirty
|
||||
}
|
||||
|
||||
// modelIdentity resolves the model and provider names the agent will run
|
||||
// under. Names only: no endpoint, key or credential reaches the manifest.
|
||||
func modelIdentity(flagModel string) (model, provider string) {
|
||||
ref := strings.TrimSpace(flagModel)
|
||||
cfg, err := config.Load()
|
||||
if err == nil && cfg != nil {
|
||||
if resolved, _, ok := cfg.ResolveModelWithFallback(ref); ok {
|
||||
if name, rest, found := strings.Cut(resolved, "/"); found {
|
||||
return rest, name
|
||||
}
|
||||
return resolved, ""
|
||||
}
|
||||
if ref == "" {
|
||||
ref = strings.TrimSpace(cfg.DefaultModel)
|
||||
}
|
||||
}
|
||||
if name, rest, found := strings.Cut(ref, "/"); found {
|
||||
return rest, name
|
||||
}
|
||||
return ref, ""
|
||||
}
|
||||
|
||||
func harnessIdentity() (string, string) {
|
||||
version := "(devel)"
|
||||
if bi, ok := debug.ReadBuildInfo(); ok && bi.Main.Version != "" {
|
||||
version = bi.Main.Version
|
||||
}
|
||||
return version, runtime.Version()
|
||||
}
|
||||
|
||||
// taskSetDigest identifies the corpus by sorted id, not by name order: a
|
||||
// reordered -task list is the same set. Ids are NUL-separated because a path
|
||||
// name cannot contain NUL, so no two id sets share a digest string.
|
||||
func taskSetDigest(tasks []task) (int, string) {
|
||||
ids := make([]string, len(tasks))
|
||||
for i, t := range tasks {
|
||||
ids[i] = t.ID
|
||||
}
|
||||
sort.Strings(ids)
|
||||
sum := sha256.Sum256([]byte(strings.Join(ids, "\x00")))
|
||||
return len(ids), "sha256:" + hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
// batchAxes lists the flags that change what a figure means, with their
|
||||
// effective values. Paths and free text stay out: -steer records its count.
|
||||
func batchAxes(cfg suiteConfig) []batchFlag {
|
||||
tape := "off"
|
||||
switch cfg.tape.mode {
|
||||
case tapeRecord:
|
||||
tape = "record"
|
||||
case tapeReplay:
|
||||
tape = "replay"
|
||||
}
|
||||
return []batchFlag{
|
||||
{"profile", cfg.profile},
|
||||
{"ablate", cfg.arm.Arm()},
|
||||
{"cache", cfg.cacheArm},
|
||||
{"anchor", cfg.anchor},
|
||||
{"effort", valueOr(cfg.effort, "default")},
|
||||
{"policy", valueOr(cfg.policy, "baseline")},
|
||||
{"force-planner", formatBool(cfg.forcePlanner)},
|
||||
{"checkpoints", formatBool(cfg.checkpoints)},
|
||||
{"attempts", strconv.Itoa(max(cfg.attempts, 1))},
|
||||
{"trials", strconv.Itoa(max(cfg.trials, 1))},
|
||||
{"budget", strconv.Itoa(cfg.budget)},
|
||||
{"segments", strconv.Itoa(max(cfg.segments, 1))},
|
||||
{"steers", strconv.Itoa(len(cfg.steers))},
|
||||
{"meter", onOff(cfg.meterConfig != "")},
|
||||
{"faults", onOff(!cfg.meterFaults.empty())},
|
||||
{"tape", tape},
|
||||
{"reply-language", valueOr(cfg.replyLanguage, "auto")},
|
||||
}
|
||||
}
|
||||
|
||||
func valueOr(v, fallback string) string {
|
||||
if strings.TrimSpace(v) == "" {
|
||||
return fallback
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func formatBool(v bool) string {
|
||||
if v {
|
||||
return "true"
|
||||
}
|
||||
return "false"
|
||||
}
|
||||
|
||||
func onOff(v bool) string {
|
||||
if v {
|
||||
return "on"
|
||||
}
|
||||
return "off"
|
||||
}
|
||||
@@ -0,0 +1,519 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// fixtureRows is a synthetic batch: three solvable tasks, one of them solved on
|
||||
// a retry. Numbers are chosen so every headline figure is non-zero and the
|
||||
// ledger has one of each interesting category.
|
||||
func fixtureRows() []result {
|
||||
return []result{
|
||||
{
|
||||
task: task{ID: "task-a", Prompt: "fixture prompt a"},
|
||||
runMetrics: runMetrics{PromptTokens: 1000, CompletionTokens: 100, CacheHitTokens: 600, CacheMissTokens: 400, Cost: 0.01, Currency: "USD"},
|
||||
Attempt: 1, Passed: true, WallMs: 1200, TTCSMs: 1200,
|
||||
},
|
||||
{
|
||||
task: task{ID: "task-b", Prompt: "fixture prompt b"},
|
||||
runMetrics: runMetrics{PromptTokens: 2000, CompletionTokens: 200, CacheHitTokens: 1500, CacheMissTokens: 500, Cost: 0.02, Currency: "USD"},
|
||||
Attempt: 1, Passed: true, WallMs: 800, TTCSMs: 800,
|
||||
},
|
||||
{
|
||||
task: task{ID: "task-c", Prompt: "fixture prompt c"},
|
||||
runMetrics: runMetrics{PromptTokens: 3000, CompletionTokens: 300, CacheHitTokens: 0, CacheMissTokens: 3000, Cost: 0.03, Currency: "USD"},
|
||||
Attempt: 1, Passed: false, WallMs: 500,
|
||||
},
|
||||
{
|
||||
task: task{ID: "task-c"},
|
||||
runMetrics: runMetrics{PromptTokens: 500, CompletionTokens: 50, Cost: 0.005, Currency: "USD"},
|
||||
Attempt: 2, Passed: true, WallMs: 400, TTCSMs: 900,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func fixtureProvenance(dir string) batchProvenance {
|
||||
count, digest := taskSetDigest([]task{{ID: "task-a"}, {ID: "task-b"}, {ID: "task-c"}})
|
||||
return batchProvenance{
|
||||
dir: dir,
|
||||
started: timeAt("2026-10-28T00:00:00Z"),
|
||||
planned: count,
|
||||
axes: []batchFlag{{"profile", "baseline"}, {"trials", "1"}},
|
||||
tasks: batchTaskSet{Count: count, Digest: digest},
|
||||
commit: "0123456789abcdef0123456789abcdef01234567",
|
||||
dirty: new(false),
|
||||
model: "deepseek-flash",
|
||||
}
|
||||
}
|
||||
|
||||
func timeAt(s string) time.Time {
|
||||
t, err := time.Parse(time.RFC3339, s)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
func writeFixtureBatch(t *testing.T) (string, batchManifest) {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
if err := writeBatch(fixtureProvenance(dir), fixtureRows()); err != nil {
|
||||
t.Fatalf("write batch: %v", err)
|
||||
}
|
||||
return dir, readManifest(t, dir)
|
||||
}
|
||||
|
||||
func readManifest(t *testing.T, dir string) batchManifest {
|
||||
t.Helper()
|
||||
data, err := os.ReadFile(filepath.Join(dir, batchManifestName))
|
||||
if err != nil {
|
||||
t.Fatalf("read manifest: %v", err)
|
||||
}
|
||||
var m batchManifest
|
||||
if err := json.Unmarshal(data, &m); err != nil {
|
||||
t.Fatalf("decode manifest: %v", err)
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func writeManifest(t *testing.T, dir string, m batchManifest) {
|
||||
t.Helper()
|
||||
data, err := json.MarshalIndent(m, "", " ")
|
||||
if err != nil {
|
||||
t.Fatalf("encode manifest: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, batchManifestName), append(data, '\n'), 0o644); err != nil {
|
||||
t.Fatalf("write manifest: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// writeRows rewrites results.jsonl without touching the manifest, which is what
|
||||
// a tampered batch looks like.
|
||||
func writeRows(t *testing.T, dir string, rows []result) {
|
||||
t.Helper()
|
||||
var b bytes.Buffer
|
||||
for _, r := range rows {
|
||||
line, err := json.Marshal(r)
|
||||
if err != nil {
|
||||
t.Fatalf("encode row: %v", err)
|
||||
}
|
||||
b.Write(line)
|
||||
b.WriteByte('\n')
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, batchResultsName), b.Bytes(), 0o600); err != nil {
|
||||
t.Fatalf("write rows: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// A batch written by the producer audits clean: the figures the manifest
|
||||
// published are the figures the retained rows give.
|
||||
func TestBatchAuditRoundTrip(t *testing.T) {
|
||||
dir, m := writeFixtureBatch(t)
|
||||
if m.SchemaVersion != batchSchemaVersion || m.Scope == "" {
|
||||
t.Fatalf("manifest schema = %d scope = %q, want v%d with a named claim", m.SchemaVersion, m.Scope, batchSchemaVersion)
|
||||
}
|
||||
if m.Over.Denominator.Value != 3 || m.Over.Denominator.Unit != denominatorUnit || m.Over.Denominator.Definition != denominatorDefinition {
|
||||
t.Fatalf("denominator = %+v, want 3 tasks under the harness definition", m.Over.Denominator)
|
||||
}
|
||||
if m.Producer.Commit != "0123456789abcdef0123456789abcdef01234567" || m.Producer.CommitSource != "checkout" {
|
||||
t.Fatalf("producer = %+v, want the checkout commit recorded", m.Producer)
|
||||
}
|
||||
if m.Trust.Ledger.Planned == nil || *m.Trust.Ledger.Planned != 3 {
|
||||
t.Fatalf("planned = %v, want 3", m.Trust.Ledger.Planned)
|
||||
}
|
||||
if m.Trust.Ledger.Scored == nil || *m.Trust.Ledger.Scored != 3 {
|
||||
t.Fatalf("scored = %v, want 3", m.Trust.Ledger.Scored)
|
||||
}
|
||||
if m.Trust.Ledger.Attempted == nil || *m.Trust.Ledger.Attempted != 4 || m.Trust.Ledger.Excluded == nil || *m.Trust.Ledger.Excluded != 1 {
|
||||
t.Fatalf("ledger attempted/excluded = %v/%v, want 4/1", m.Trust.Ledger.Attempted, m.Trust.Ledger.Excluded)
|
||||
}
|
||||
|
||||
// The manifest must not carry the raw task text the rows legitimately do.
|
||||
raw, err := os.ReadFile(filepath.Join(dir, batchManifestName))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if bytes.Contains(raw, []byte("fixture prompt")) {
|
||||
t.Fatalf("the manifest carries raw task text:\n%s", raw)
|
||||
}
|
||||
|
||||
out, err := auditBatch(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("a producer-written batch did not audit: %v\n%s", err, out)
|
||||
}
|
||||
for _, want := range []string{
|
||||
"| solved | 3 | 3 |",
|
||||
"| pass1 | 2 | 2 |",
|
||||
"| prompt_tokens | 6500 | 6500 |",
|
||||
"| cost | 0.065 | 0.065 |",
|
||||
"| scored | 3 | 3 |",
|
||||
"| failed | 1 | 1 |",
|
||||
"| planned | 3 | n/a |",
|
||||
"results_sha256: ok",
|
||||
"the published figures follow from the retained rows.",
|
||||
"aggregation audit, not a reproduction",
|
||||
} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Fatalf("audit output is missing %q:\n%s", want, out)
|
||||
}
|
||||
}
|
||||
receipt := readManifest(t, dir).Audit
|
||||
if receipt == nil || receipt.VerifierVersion != auditToolVersion || receipt.ResultsSHA256 != m.Trust.ResultsSHA256 {
|
||||
t.Fatalf("audit receipt = %+v, want verifier v%d over the same digest", receipt, auditToolVersion)
|
||||
}
|
||||
}
|
||||
|
||||
// The axes carry what changes a figure's meaning, never the operator's machine:
|
||||
// meter/tape/trajectory paths and steer text stay out of the manifest.
|
||||
func TestBatchAxesKeepPathsAndFreeTextOut(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
prov := fixtureProvenance(dir)
|
||||
prov.axes = batchAxes(suiteConfig{
|
||||
profile: "delivery", cacheArm: "warm", effort: "high", anchor: "blind",
|
||||
attempts: 1, trials: 2, budget: 0, segments: 3,
|
||||
meterConfig: "/Users/someone/.reasonix/config.toml",
|
||||
meterFaults: faultScript{at: map[int]int{3: 429}},
|
||||
tape: tapeConfig{mode: tapeRecord, root: "/tmp/tapes"},
|
||||
steers: map[int]string{2: "secret steer text"},
|
||||
trajDir: "/tmp/traj",
|
||||
})
|
||||
if err := writeBatch(prov, fixtureRows()); err != nil {
|
||||
t.Fatalf("write batch: %v", err)
|
||||
}
|
||||
raw, err := os.ReadFile(filepath.Join(dir, batchManifestName))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, forbidden := range []string{"/Users", "/tmp", "secret steer text", ".reasonix"} {
|
||||
if bytes.Contains(raw, []byte(forbidden)) {
|
||||
t.Fatalf("the manifest carries %q:\n%s", forbidden, raw)
|
||||
}
|
||||
}
|
||||
axes := map[string]string{}
|
||||
for _, a := range readManifest(t, dir).Over.Command.Axes {
|
||||
axes[a.Name] = a.Value
|
||||
}
|
||||
want := map[string]string{
|
||||
"profile": "delivery", "effort": "high", "trials": "2", "segments": "3",
|
||||
"meter": "on", "faults": "on", "tape": "record", "steers": "1", "ablate": "full",
|
||||
}
|
||||
for name, value := range want {
|
||||
if axes[name] != value {
|
||||
t.Fatalf("axis %s = %q, want %q (axes: %v)", name, axes[name], value, axes)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The corpus digest is a set digest: order must not move it, and ids that only
|
||||
// differ by a separator must not collide.
|
||||
func TestTaskSetDigestIsSeparatedAndOrderIndependent(t *testing.T) {
|
||||
n1, d1 := taskSetDigest([]task{{ID: "b"}, {ID: "a"}})
|
||||
n2, d2 := taskSetDigest([]task{{ID: "a"}, {ID: "b"}})
|
||||
if n1 != 2 || n2 != 2 || d1 != d2 {
|
||||
t.Fatalf("order moved the corpus digest: %d/%s vs %d/%s", n1, d1, n2, d2)
|
||||
}
|
||||
_, joined := taskSetDigest([]task{{ID: "a\nb"}})
|
||||
if joined == d1 {
|
||||
t.Fatal("a newline inside one id collides with two ids")
|
||||
}
|
||||
}
|
||||
|
||||
// One token count moved after publication: the audit must fail and name the
|
||||
// figure that moved, not just say the digest differs.
|
||||
func TestBatchAuditNamesTheFigureThatMoved(t *testing.T) {
|
||||
dir, m := writeFixtureBatch(t)
|
||||
tampered := fixtureRows()
|
||||
tampered[0].PromptTokens += 1000
|
||||
writeRows(t, dir, tampered)
|
||||
|
||||
out, err := auditBatch(dir)
|
||||
if err == nil {
|
||||
t.Fatalf("a tampered row audited clean:\n%s", out)
|
||||
}
|
||||
for _, want := range []string{
|
||||
"prompt_tokens: manifest 6500, rows 7500",
|
||||
"| prompt_tokens | 6500 | 7500 | **moved** |",
|
||||
"MISMATCH",
|
||||
"the published figures do **not** follow from the retained rows",
|
||||
} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Fatalf("audit output is missing %q:\n%s", want, out)
|
||||
}
|
||||
}
|
||||
if readManifest(t, dir).Audit != nil {
|
||||
t.Fatal("a failed audit recorded a verification receipt")
|
||||
}
|
||||
if m.Trust.ResultsSHA256 == "" {
|
||||
t.Fatal("fixture manifest carries no digest")
|
||||
}
|
||||
}
|
||||
|
||||
// The three ways a batch can be unauditable are three different messages: no
|
||||
// manifest, no commit to tie the figures to, and rows cut short.
|
||||
func TestBatchAuditRefusesUnverifiableBatches(t *testing.T) {
|
||||
t.Run("missing manifest", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
writeRows(t, dir, fixtureRows())
|
||||
out, err := auditBatch(dir)
|
||||
if err == nil {
|
||||
t.Fatalf("rows without a manifest audited clean:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "no batch.json") || !strings.Contains(err.Error(), "kept no manifest") {
|
||||
t.Fatalf("missing-manifest error = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("unknown commit", func(t *testing.T) {
|
||||
dir, m := writeFixtureBatch(t)
|
||||
m.Producer.Commit = "unknown"
|
||||
m.Producer.CommitSource = ""
|
||||
writeManifest(t, dir, m)
|
||||
out, err := auditBatch(dir)
|
||||
if err == nil {
|
||||
t.Fatalf("a manifest with an unknown commit audited clean:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "records no commit") {
|
||||
t.Fatalf("no-commit error = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("absent commit", func(t *testing.T) {
|
||||
dir, m := writeFixtureBatch(t)
|
||||
m.Producer.Commit = ""
|
||||
writeManifest(t, dir, m)
|
||||
if _, err := auditBatch(dir); err == nil || !strings.Contains(err.Error(), "records no commit") {
|
||||
t.Fatalf("an absent commit answered %v, want the no-commit refusal", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("truncated rows", func(t *testing.T) {
|
||||
dir, _ := writeFixtureBatch(t)
|
||||
path := filepath.Join(dir, batchResultsName)
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(path, data[:len(data)-40], 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out, err := auditBatch(dir)
|
||||
if err == nil {
|
||||
t.Fatalf("truncated rows audited clean:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "truncated") {
|
||||
t.Fatalf("truncated-rows error = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("half a row with a newline", func(t *testing.T) {
|
||||
dir, _ := writeFixtureBatch(t)
|
||||
path := filepath.Join(dir, batchResultsName)
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cut := bytes.LastIndexByte(data[:len(data)-1], '\n')
|
||||
if cut < 0 {
|
||||
t.Fatal("fixture has fewer than two rows")
|
||||
}
|
||||
broken := append(append([]byte{}, data[:cut+1]...), data[cut+1:len(data)-20]...)
|
||||
broken = append(broken, '\n')
|
||||
if err := os.WriteFile(path, broken, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := auditBatch(dir); err == nil || !strings.Contains(err.Error(), "not a complete row") {
|
||||
t.Fatalf("a half-written row answered %v, want a truncation refusal", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// manifestWithoutOptionals is pinned JSON, not a re-marshal of the writer's
|
||||
// struct: seed, provider, dirty and cost are absent, and the audit must read
|
||||
// each as unknown rather than as zero. This is the record the contract is
|
||||
// about, so it is written out in full.
|
||||
const manifestWithoutOptionals = `{
|
||||
"schema_version": 1,
|
||||
"scope": %q,
|
||||
"over": {
|
||||
"command": {"mode": "suite", "axes": [{"name": "profile", "value": "baseline"}, {"name": "trials", "value": "1"}]},
|
||||
"tasks": {"count": 3, "digest": %q},
|
||||
"denominator": {"unit": "task", "definition": %q, "value": 3}
|
||||
},
|
||||
"producer": {
|
||||
"commit": "0123456789abcdef0123456789abcdef01234567",
|
||||
"commit_source": "checkout",
|
||||
"model": "deepseek-flash",
|
||||
"started_at": "2026-10-28T00:00:00Z",
|
||||
"finished_at": "2026-10-28T00:10:00Z",
|
||||
"harness_version": "(devel)",
|
||||
"go_version": "go1.26.5"
|
||||
},
|
||||
"trust": {
|
||||
"results_sha256": %q,
|
||||
"claimed": {
|
||||
"solved": 3,
|
||||
"pass1": 2,
|
||||
"prompt_tokens": 6500,
|
||||
"completion_tokens": 650,
|
||||
"cache_hit_tokens": 2100,
|
||||
"cache_miss_tokens": 3900,
|
||||
"accounted": 4,
|
||||
"unaccounted": 0,
|
||||
"partial": 0,
|
||||
"tcts_median_ms": 900
|
||||
},
|
||||
"ledger": {
|
||||
"planned": 3,
|
||||
"attempted": 4,
|
||||
"completed": 4,
|
||||
"scored": 3,
|
||||
"failed": 1,
|
||||
"unmeasured": 0,
|
||||
"excluded": 1,
|
||||
"rows": 4,
|
||||
"skipped": 0
|
||||
},
|
||||
"retention": %q
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
func TestBatchAuditReadsAbsentFieldsAsUnknown(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
writeRows(t, dir, fixtureRows())
|
||||
_, canonical, err := readBatch(filepath.Join(dir, batchResultsName))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, digest := taskSetDigest([]task{{ID: "task-a"}, {ID: "task-b"}, {ID: "task-c"}})
|
||||
manifest := fmt.Sprintf(manifestWithoutOptionals, batchScope, digest, denominatorDefinition, hashOf(canonical), batchRetention)
|
||||
if err := os.WriteFile(filepath.Join(dir, batchManifestName), []byte(manifest), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
out, err := auditBatch(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("a manifest with only optional fields absent did not audit: %v\n%s", err, out)
|
||||
}
|
||||
for _, want := range []string{
|
||||
"seed: unknown (absent from the manifest; an absent seed is not seed 0)",
|
||||
"provider: unknown (absent from the manifest; absent is not zero)",
|
||||
"| cost | unknown | 0.065 | not claimed |",
|
||||
} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Fatalf("audit output is missing %q:\n%s", want, out)
|
||||
}
|
||||
}
|
||||
for _, forbidden := range []string{"seed: 0", "| cost | 0 |", "provider ``"} {
|
||||
if strings.Contains(out, forbidden) {
|
||||
t.Fatalf("an absent field read as %q:\n%s", forbidden, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The digest covers values, not spelling: CRLF line endings and reordered keys
|
||||
// are the same rows, so the same manifest still audits.
|
||||
func TestBatchDigestIgnoresLineEndingsAndKeyOrder(t *testing.T) {
|
||||
dir, m := writeFixtureBatch(t)
|
||||
path := filepath.Join(dir, batchResultsName)
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var rewritten bytes.Buffer
|
||||
for line := range bytes.SplitSeq(data, []byte("\n")) {
|
||||
if len(line) == 0 {
|
||||
continue
|
||||
}
|
||||
rewritten.Write(reorderKeys(t, line))
|
||||
}
|
||||
if !bytes.Contains(rewritten.Bytes(), []byte("\r\n")) {
|
||||
t.Fatal("fixture rewrite did not use CRLF")
|
||||
}
|
||||
if bytes.Equal(rewritten.Bytes(), data) {
|
||||
t.Fatal("fixture rewrite left the bytes unchanged, so it proves nothing")
|
||||
}
|
||||
if err := os.WriteFile(path, rewritten.Bytes(), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
_, canonical, err := readBatch(path)
|
||||
if err != nil {
|
||||
t.Fatalf("re-spelled rows did not parse: %v", err)
|
||||
}
|
||||
if got := hashOf(canonical); got != m.Trust.ResultsSHA256 {
|
||||
t.Fatalf("digest moved with spelling: %s, want %s", got, m.Trust.ResultsSHA256)
|
||||
}
|
||||
if out, err := auditBatch(dir); err != nil {
|
||||
t.Fatalf("re-spelled rows did not audit: %v\n%s", err, out)
|
||||
}
|
||||
}
|
||||
|
||||
// reorderKeys emits the row with its keys in reverse order and a CRLF ending:
|
||||
// both differences are outside the values results_sha256 covers.
|
||||
func reorderKeys(t *testing.T, line []byte) []byte {
|
||||
t.Helper()
|
||||
var obj map[string]json.RawMessage
|
||||
if err := json.Unmarshal(line, &obj); err != nil {
|
||||
t.Fatalf("decode row: %v", err)
|
||||
}
|
||||
keys := make([]string, 0, len(obj))
|
||||
for k := range obj {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Sort(sort.Reverse(sort.StringSlice(keys)))
|
||||
var b bytes.Buffer
|
||||
b.WriteByte('{')
|
||||
for i, k := range keys {
|
||||
if i > 0 {
|
||||
b.WriteByte(',')
|
||||
}
|
||||
key, err := json.Marshal(k)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b.Write(key)
|
||||
b.WriteByte(':')
|
||||
b.Write(obj[k])
|
||||
}
|
||||
b.WriteString("}\r\n")
|
||||
return b.Bytes()
|
||||
}
|
||||
|
||||
// A ledger is a funnel. Counts that cannot all be true together are rejected,
|
||||
// so the published denominator cannot be larger than the runs behind it.
|
||||
func TestBatchAuditRejectsALedgerThatDoesNotAddUp(t *testing.T) {
|
||||
t.Run("scored above completed", func(t *testing.T) {
|
||||
dir, m := writeFixtureBatch(t)
|
||||
m.Trust.Ledger.Scored = new(5)
|
||||
writeManifest(t, dir, m)
|
||||
out, err := auditBatch(dir)
|
||||
if err == nil {
|
||||
t.Fatalf("a ledger with scored > completed audited clean:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, "ledger: scored 5 exceeds completed 4") {
|
||||
t.Fatalf("audit output does not name the broken invariant:\n%s", out)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("denominator is not passes plus failures", func(t *testing.T) {
|
||||
dir, m := writeFixtureBatch(t)
|
||||
m.Trust.Ledger.Failed = new(0)
|
||||
writeManifest(t, dir, m)
|
||||
out, err := auditBatch(dir)
|
||||
if err == nil {
|
||||
t.Fatalf("a ledger whose denominator is not pass1+failed audited clean:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, "ledger: scored 3 is not pass1 2 + failed 0") {
|
||||
t.Fatalf("audit output does not name the denominator split:\n%s", out)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -233,20 +233,9 @@ func (r result) class() string {
|
||||
const defaultSuiteTokenBudget = 800_000
|
||||
|
||||
func main() {
|
||||
flag.Usage = func() {
|
||||
fmt.Fprintf(flag.CommandLine.Output(), "e2ebench — Reasonix end-to-end benchmark.\n\n")
|
||||
fmt.Fprintf(flag.CommandLine.Output(), "Usage of %s:\n", flag.CommandLine.Name())
|
||||
flag.PrintDefaults()
|
||||
fmt.Fprintf(flag.CommandLine.Output(), "\nExamples:\n")
|
||||
fmt.Fprintf(flag.CommandLine.Output(), " # Run the committed suite:\n")
|
||||
fmt.Fprintf(flag.CommandLine.Output(), " %[1]s\n\n", strings.Replace(flag.CommandLine.Name(), "e2ebench", "go run ./cmd/e2ebench", 1))
|
||||
fmt.Fprintf(flag.CommandLine.Output(), " # Grade a PR's diff with a retry budget:\n")
|
||||
fmt.Fprintf(flag.CommandLine.Output(), " %[1]s -mode diff -base origin/main-v2 -repo . -attempts 3 -timeout 1800\n", strings.Replace(flag.CommandLine.Name(), "e2ebench", "go run ./cmd/e2ebench", 1))
|
||||
fmt.Fprintf(flag.CommandLine.Output(), "\n # Run the same suite with the delivery contract:\n")
|
||||
fmt.Fprintf(flag.CommandLine.Output(), " %[1]s -profile delivery\n", strings.Replace(flag.CommandLine.Name(), "e2ebench", "go run ./cmd/e2ebench", 1))
|
||||
}
|
||||
flag.Usage = printUsage
|
||||
|
||||
mode := flag.String("mode", "suite", "suite | diff | swebench | compare | rollout | traj | barrier | sft | serve")
|
||||
mode := flag.String("mode", "suite", "suite | diff | swebench | compare | rollout | traj | barrier | sft | serve | audit")
|
||||
addr := flag.String("addr", "127.0.0.1:7480", "serve mode: live dashboard listen address")
|
||||
subset := flag.String("subset", "benchmarks/swebench/subset.json", "swebench mode: instance subset file")
|
||||
reportIn := flag.String("report", "", "sft mode: the run report (-json output) whose grader verdicts decide which runs are exported")
|
||||
@@ -276,6 +265,7 @@ func main() {
|
||||
forcePlanner := flag.Bool("force-planner", false, "suite mode: prefix each prompt with a plan-first directive so the two-model turn engages regardless of the planner gate")
|
||||
anchorFlag := flag.String("anchor", anchorBlind, "suite mode: hypothesis arm — blind (no hypothesis) | correct | wrong; correct/wrong prefix each prompt with the task's authored seed and skip tasks that have none")
|
||||
outJSON := flag.String("json", "", "write the JSON report here (optional)")
|
||||
batchDir := flag.String("batch", "", "suite mode: write results.jsonl (one row per run) and batch.json (manifest v1) here, so a later -mode audit can re-derive the report's figures; audit mode: read the batch from here")
|
||||
budget := flag.Int("budget", defaultSuiteTokenBudget, "abort once total tokens cross this (0 = no cap)")
|
||||
// diff-mode flags
|
||||
repo := flag.String("repo", ".", "repo root (diff mode)")
|
||||
@@ -298,6 +288,10 @@ func main() {
|
||||
os.Exit(2)
|
||||
}
|
||||
profile, arm, cache, anchor := axes.profile, axes.arm, axes.cache, axes.anchor
|
||||
if *batchDir != "" && *mode != "suite" && *mode != "audit" {
|
||||
fmt.Fprintln(os.Stderr, "e2ebench: -batch is written by -mode suite and read by -mode audit; -mode "+*mode+" does neither")
|
||||
os.Exit(2)
|
||||
}
|
||||
|
||||
if *mode == "swebench" {
|
||||
if _, err := permissionFlag(*permission); err != nil {
|
||||
@@ -321,6 +315,7 @@ func main() {
|
||||
|
||||
if dispatchOfflineMode(*mode, offlineModeArgs{
|
||||
trajDir: *trajDir, suite: *suite, reportIn: *reportIn, outMD: *outMD, outJSON: *outJSON, addr: *addr,
|
||||
batchDir: *batchDir,
|
||||
}) {
|
||||
return
|
||||
}
|
||||
@@ -342,10 +337,10 @@ func main() {
|
||||
cacheArm: cache, effort: *effort, checkpoints: *checkpoints, policy: *policyFlag,
|
||||
meterConfig: meterSource, meterFaults: faults, segments: segments, steers: steers,
|
||||
replyLanguage: agentReplyLanguage(),
|
||||
}, *suite, *taskFilter, *outMD, *outJSON)
|
||||
}, *suite, *taskFilter, *outMD, *outJSON, *batchDir)
|
||||
}
|
||||
|
||||
func runSuiteMode(cfg suiteConfig, suite, taskFilter, outMD, outJSON string) {
|
||||
func runSuiteMode(cfg suiteConfig, suite, taskFilter, outMD, outJSON, batchDir string) {
|
||||
tasks, err := loadTasks(suite)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "load suite:", err)
|
||||
@@ -363,7 +358,19 @@ func runSuiteMode(cfg suiteConfig, suite, taskFilter, outMD, outJSON string) {
|
||||
os.Exit(2)
|
||||
}
|
||||
|
||||
// The provenance is captured before the first run: the commit a run ends
|
||||
// on is not necessarily the commit it started from.
|
||||
var batch batchProvenance
|
||||
if batchDir != "" {
|
||||
batch = captureBatchProvenance(batchDir, cfg, tasks, taskFilter)
|
||||
}
|
||||
results := runSuite(cfg, tasks)
|
||||
if batchDir != "" {
|
||||
if err := writeBatch(batch, results); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "write batch:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
report := render(results)
|
||||
if outMD != "" {
|
||||
|
||||
@@ -58,6 +58,7 @@ type offlineModeArgs struct {
|
||||
outMD string
|
||||
outJSON string
|
||||
addr string
|
||||
batchDir string
|
||||
}
|
||||
|
||||
// dispatchOfflineMode runs mode if it is one of the offline ones and reports
|
||||
@@ -82,6 +83,8 @@ func dispatchOfflineMode(mode string, a offlineModeArgs) bool {
|
||||
emit(report, a.outMD, "")
|
||||
case "sft":
|
||||
fail("sft", runSFTMode(a.trajDir, a.suite, a.reportIn, a.outMD))
|
||||
case "audit":
|
||||
fail("audit", runAuditMode(a.batchDir, a.outMD))
|
||||
case "serve":
|
||||
fail("serve", runServeMode(a.trajDir, a.suite, a.addr))
|
||||
default:
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// printUsage is the harness's help text. It names one thing -mode audit does
|
||||
// not do — replay the experiment — and what a replay would need instead.
|
||||
func printUsage() {
|
||||
fmt.Fprintf(flag.CommandLine.Output(), "e2ebench — Reasonix end-to-end benchmark.\n\n")
|
||||
fmt.Fprintf(flag.CommandLine.Output(), "Usage of %s:\n", flag.CommandLine.Name())
|
||||
flag.PrintDefaults()
|
||||
fmt.Fprintf(flag.CommandLine.Output(), "\nExamples:\n")
|
||||
fmt.Fprintf(flag.CommandLine.Output(), " # Run the committed suite:\n")
|
||||
fmt.Fprintf(flag.CommandLine.Output(), " %[1]s\n\n", strings.Replace(flag.CommandLine.Name(), "e2ebench", "go run ./cmd/e2ebench", 1))
|
||||
fmt.Fprintf(flag.CommandLine.Output(), " # Grade a PR's diff with a retry budget:\n")
|
||||
fmt.Fprintf(flag.CommandLine.Output(), " %[1]s -mode diff -base origin/main-v2 -repo . -attempts 3 -timeout 1800\n", strings.Replace(flag.CommandLine.Name(), "e2ebench", "go run ./cmd/e2ebench", 1))
|
||||
fmt.Fprintf(flag.CommandLine.Output(), "\n # Run the same suite with the delivery contract:\n")
|
||||
fmt.Fprintf(flag.CommandLine.Output(), " %[1]s -profile delivery\n", strings.Replace(flag.CommandLine.Name(), "e2ebench", "go run ./cmd/e2ebench", 1))
|
||||
fmt.Fprintf(flag.CommandLine.Output(), "\n # Keep a batch's rows and manifest, then audit its figures against them:\n")
|
||||
fmt.Fprintf(flag.CommandLine.Output(), " %[1]s -batch runs/today\n", strings.Replace(flag.CommandLine.Name(), "e2ebench", "go run ./cmd/e2ebench", 1))
|
||||
fmt.Fprintf(flag.CommandLine.Output(), " %[1]s -mode audit -batch runs/today\n\n", strings.Replace(flag.CommandLine.Name(), "e2ebench", "go run ./cmd/e2ebench", 1))
|
||||
fmt.Fprintf(flag.CommandLine.Output(), "Auditing is not replaying: -mode audit re-derives the report's headline figures\n"+
|
||||
"from results.jsonl and checks them against batch.json, naming every figure that moved.\n"+
|
||||
"It never re-executes the model, tools or environment, so it cannot show the experiment\n"+
|
||||
"reproduces. A real replay would need the commit, corpus fingerprint, model and provider\n"+
|
||||
"configuration, seed, flags, raw results and runtime version; it is not implemented.\n")
|
||||
}
|
||||
@@ -6,19 +6,20 @@ import (
|
||||
"strings"
|
||||
|
||||
"reasonix/internal/assembly/boot"
|
||||
"reasonix/internal/contract/config"
|
||||
"reasonix/internal/frontend/serve"
|
||||
)
|
||||
|
||||
// launchWorkspace is the folder the first pane opens. The working directory
|
||||
// counts only when someone chose it: a Start-menu or Finder launch inherits the
|
||||
// launchWorkspace is the folder the first pane opens: a chosen directory, then
|
||||
// a remembered project, then the app's own auto workspace, never the home
|
||||
// directory. The working directory counts only when someone chose it: a Start-menu or Finder launch inherits the
|
||||
// install directory or the filesystem root, and taking that as a project puts
|
||||
// the app's own folder in the sidebar again after every removal.
|
||||
func launchWorkspace(appExe string) string {
|
||||
home, _ := os.UserHomeDir()
|
||||
return pickLaunchWorkspace(boot.ResolveWorkspaceRoot(""), appExe, serve.LaunchWorkspaces(), home)
|
||||
return pickLaunchWorkspace(boot.ResolveWorkspaceRoot(""), appExe, serve.LaunchWorkspaces(), config.AutoWorkspaceDir())
|
||||
}
|
||||
|
||||
func pickLaunchWorkspace(cwd, appExe string, remembered []string, home string) string {
|
||||
func pickLaunchWorkspace(cwd, appExe string, remembered []string, auto string) string {
|
||||
if userChoseDir(cwd, appExe) {
|
||||
return cwd
|
||||
}
|
||||
@@ -27,8 +28,8 @@ func pickLaunchWorkspace(cwd, appExe string, remembered []string, home string) s
|
||||
return dir
|
||||
}
|
||||
}
|
||||
if home != "" {
|
||||
return home
|
||||
if auto != "" && os.MkdirAll(auto, 0o755) == nil {
|
||||
return auto
|
||||
}
|
||||
return cwd
|
||||
}
|
||||
|
||||
@@ -4,7 +4,10 @@ import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/contract/config"
|
||||
)
|
||||
|
||||
func TestUserChoseDirRejectsWhatALauncherInherits(t *testing.T) {
|
||||
@@ -40,7 +43,8 @@ func TestPickLaunchWorkspaceFallbackOrder(t *testing.T) {
|
||||
}
|
||||
return d
|
||||
}
|
||||
a, b, home := mk("a"), mk("b"), mk("home")
|
||||
a, b := mk("a"), mk("b")
|
||||
auto := filepath.Join(base, "auto")
|
||||
gone := filepath.Join(base, "gone")
|
||||
proj := mk("proj")
|
||||
|
||||
@@ -53,11 +57,11 @@ func TestPickLaunchWorkspaceFallbackOrder(t *testing.T) {
|
||||
{"chosen directory wins over the list", proj, []string{a}, proj},
|
||||
{"list head", app, []string{a, b}, a},
|
||||
{"stale head skipped", app, []string{gone, b}, b},
|
||||
{"all stale falls to home", app, []string{gone}, home},
|
||||
{"empty list falls to home", app, nil, home},
|
||||
{"all stale falls to the auto workspace", app, []string{gone}, auto},
|
||||
{"empty list falls to the auto workspace", app, nil, auto},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := pickLaunchWorkspace(c.cwd, exe, c.remembered, home); got != c.want {
|
||||
if got := pickLaunchWorkspace(c.cwd, exe, c.remembered, auto); got != c.want {
|
||||
t.Errorf("%s: got %q, want %q", c.name, got, c.want)
|
||||
}
|
||||
}
|
||||
@@ -90,3 +94,54 @@ func TestLaunchWorkspaceKeepsLaunchProjectAfterSidebarReorder(t *testing.T) {
|
||||
t.Fatalf("missing launch project = %q, want next remembered folder %q", got, b)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLaunchWithNothingChosenOrRememberedOpensTheAutoWorkspaceNotHome(t *testing.T) {
|
||||
base := t.TempDir()
|
||||
state := filepath.Join(base, "state")
|
||||
home := filepath.Join(base, "home")
|
||||
app := filepath.Join(base, "app")
|
||||
for _, dir := range []string{state, home, app} {
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
t.Setenv("REASONIX_HOME", state)
|
||||
t.Setenv("HOME", home)
|
||||
t.Setenv("USERPROFILE", home)
|
||||
t.Chdir(app)
|
||||
exe := filepath.Join(app, "Reasonix Studio")
|
||||
|
||||
got := launchWorkspace(exe)
|
||||
if got == home {
|
||||
t.Fatalf("a first launch opened the home directory %q", got)
|
||||
}
|
||||
if want := config.AutoWorkspaceDir(); got != want {
|
||||
t.Fatalf("launchWorkspace = %q, want the auto workspace %q", got, want)
|
||||
}
|
||||
if st, err := os.Stat(got); err != nil || !st.IsDir() {
|
||||
t.Fatalf("the auto workspace was not created: %v", err)
|
||||
}
|
||||
if rel, err := filepath.Rel(state, got); err != nil || strings.HasPrefix(rel, "..") {
|
||||
t.Fatalf("the auto workspace %q is not under the state root %q", got, state)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPickLaunchWorkspaceCreatesTheAutoWorkspaceLast(t *testing.T) {
|
||||
base := t.TempDir()
|
||||
app := filepath.Join(base, "app")
|
||||
exe := filepath.Join(app, "Reasonix Studio")
|
||||
auto := filepath.Join(base, "state", "workspaces", "auto")
|
||||
if got := pickLaunchWorkspace(app, exe, nil, auto); got != auto {
|
||||
t.Fatalf("got %q, want %q", got, auto)
|
||||
}
|
||||
if st, err := os.Stat(auto); err != nil || !st.IsDir() {
|
||||
t.Fatalf("not created: %v", err)
|
||||
}
|
||||
chosen := filepath.Join(base, "proj")
|
||||
if err := os.MkdirAll(chosen, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := pickLaunchWorkspace(chosen, exe, nil, auto); got != chosen {
|
||||
t.Fatalf("a chosen directory must win over the auto workspace: %q", got)
|
||||
}
|
||||
}
|
||||
@@ -516,6 +516,7 @@ func startCloudRemote(ctx context.Context, cfg *config.Config, logs io.Writer, t
|
||||
dialer.NetDialContext = stream.DialContext
|
||||
client := account.New(os.Getenv("REASONIX_ACCOUNTS_URL"), "reasonix-studio/"+version, httpClient)
|
||||
host := remotecloud.New(client, &dialer, os.Getenv("REASONIX_REMOTE_URL"), version, tasks)
|
||||
host.SetGateway(os.Getenv("REASONIX_REMOTE_GATEWAY_ID"))
|
||||
if registrar, ok := tasks.(interface{ SetCloudControllerDisconnect(func(string) error) }); ok {
|
||||
registrar.SetCloudControllerDisconnect(host.DisconnectController)
|
||||
}
|
||||
|
||||
@@ -72,6 +72,57 @@ go run ./cmd/reasonix serve # from the repository root
|
||||
cd desktop/frontend-next && pnpm install && pnpm dev # :5273, proxies API paths
|
||||
```
|
||||
|
||||
## Messages sent during a run
|
||||
|
||||
**Settings → Appearance → Keyboard shortcuts → Messages sent while running** chooses the default:
|
||||
|
||||
- **Steer at the next tool boundary** (default) adds guidance to the current run.
|
||||
- **Send after the whole run** queues a new turn until the active run finishes.
|
||||
|
||||
The choice is local to this device's browser profile, applies across projects,
|
||||
and survives reloads. It does not change idle sends or messages already queued.
|
||||
Skill invocations continue to use follow-up turns.
|
||||
|
||||
The queue still supports hold/resume, editing, and taking a message back into
|
||||
the composer. **Send now** overrides the wait for a selected message by stopping
|
||||
the current response and starting that message as a new turn; it is unavailable
|
||||
while the queue is held.
|
||||
|
||||
Guidance not delivered before a run ends still becomes a follow-up through the
|
||||
kernel's existing queue.
|
||||
|
||||
## Global show/hide shortcut
|
||||
|
||||
In the desktop shell, **Settings → Appearance → Global show / hide shortcut**
|
||||
offers one opt-in binding, disabled by default.
|
||||
|
||||
1. Click **Record shortcut**, then press a modifier and key combination.
|
||||
Escape cancels recording.
|
||||
2. Review the platform-native label, then **Apply shortcut** to register it.
|
||||
3. **Clear shortcut** disables it immediately.
|
||||
|
||||
You can also edit the Electron accelerator directly (up to 128 characters).
|
||||
The shell requires Control, Command, Alt or Super, even through IPC or when
|
||||
restored from disk. Shift may accompany one of them but cannot qualify alone.
|
||||
|
||||
The shortcut hides a focused Studio window; otherwise it restores, shows and
|
||||
focuses the existing window. It does not change in-app shortcuts or tray settings.
|
||||
|
||||
The primary Electron instance owns the binding and stores it in its local shell
|
||||
profile. Changes apply immediately; invalid or unavailable bindings leave the
|
||||
previous saved and registered binding intact.
|
||||
|
||||
Startup registration failures are shown in settings rather than reported as
|
||||
success. An unavailable binding may be occupied, reserved by the OS, denied
|
||||
permission, or unsupported by the desktop.
|
||||
|
||||
Native Wayland environments may not support registration with this Electron
|
||||
backend; no portal authorization or cross-platform success is implied.
|
||||
|
||||
The binding exists only while Studio is running. It cannot launch a stopped
|
||||
application and no external daemon is installed. Browser and network viewers
|
||||
cannot configure a remote machine's global shortcut.
|
||||
|
||||
## Building and testing
|
||||
|
||||
```sh
|
||||
@@ -79,6 +130,21 @@ make studio-test # go test ./... for this modul
|
||||
cd frontend-next && pnpm typecheck && pnpm test && pnpm build
|
||||
```
|
||||
|
||||
The Linux/X11 and macOS shortcut smoke drives the real Studio window, chord
|
||||
capture, and OS key events. After building the host and frontend, run
|
||||
`pnpm shortcut-live` in `electron/`. macOS uses System Events and requires
|
||||
Accessibility/Automation permission for the launching process.
|
||||
|
||||
Use isolated `REASONIX_HOME` and `XDG_CONFIG_HOME` directories; the smoke seeds
|
||||
a test provider at an unreachable loopback port.
|
||||
|
||||
- On Linux/X11, `REASONIX_XDOTOOL` names `xdotool`.
|
||||
- On Linux/X11, `REASONIX_XMESSAGE` optionally names `xmessage` for the separate-app focus check.
|
||||
- `REASONIX_SCREENSHOTS` optionally names the screenshot output directory.
|
||||
|
||||
This test requires a working display. Windows and Wayland are not covered by
|
||||
this native smoke; successful builds do not establish native shortcut behavior.
|
||||
|
||||
Packaging runs through electron-builder; `release-studio.yml` drives it on all
|
||||
three platforms. Linux ships a `.deb` because that is what makes Studio
|
||||
self-updating there. See `docs/STUDIO_RELEASE.md`.
|
||||
@@ -121,3 +121,77 @@
|
||||
${EndIf}
|
||||
Pop $R9
|
||||
!macroend
|
||||
|
||||
# Closing a running copy before an install, an update or an uninstall. The
|
||||
# uninstaller runs from inside the install folder, so the process counting skips
|
||||
# this process by id: matching on the folder alone would find the uninstaller
|
||||
# itself and end it.
|
||||
!macro rxScript
|
||||
FileOpen $0 "$PLUGINSDIR\rxclose.ps1" w
|
||||
FileWrite $0 "param([string]$$Dir, [string]$$Mode, [int]$$Self)$\r$\n"
|
||||
FileWrite $0 "$$names = @('${APP_EXECUTABLE_FILENAME}', 'reasonix-studio-host.exe', 'cua-driver.exe')$\r$\n"
|
||||
FileWrite $0 "$$hit = @(Get-CimInstance -ClassName Win32_Process | ? { $$_.ProcessId -ne $$Self -and (($$_.Path -and $$_.Path.StartsWith($$Dir, 'CurrentCultureIgnoreCase')) -or (-not $$_.Path -and $$names -contains $$_.Name)) })$\r$\n"
|
||||
FileWrite $0 "if ($$Mode -eq 'find') { if ($$hit.Count -gt 0) { exit 0 } else { exit 1 } }$\r$\n"
|
||||
FileWrite $0 "$$hit | % { Stop-Process -Id $$_.ProcessId -Force -ErrorAction SilentlyContinue }$\r$\n"
|
||||
FileClose $0
|
||||
!macroend
|
||||
|
||||
!macro rxFind
|
||||
nsExec::Exec `"$SYSDIR\WindowsPowerShell\v1.0\powershell.exe" -NoProfile -NonInteractive -ExecutionPolicy Bypass -File "$PLUGINSDIR\rxclose.ps1" "$INSTDIR" find $R2`
|
||||
Pop $R0
|
||||
!macroend
|
||||
|
||||
!macro rxKill
|
||||
nsExec::Exec `"$SYSDIR\WindowsPowerShell\v1.0\powershell.exe" -NoProfile -NonInteractive -ExecutionPolicy Bypass -File "$PLUGINSDIR\rxclose.ps1" "$INSTDIR" kill $R2`
|
||||
Pop $0
|
||||
!macroend
|
||||
|
||||
!macro rxKillElevated
|
||||
nsExec::Exec `"$SYSDIR\WindowsPowerShell\v1.0\powershell.exe" -NoProfile -NonInteractive -C "Start-Process -FilePath powershell -ArgumentList '-NoProfile','-ExecutionPolicy','Bypass','-File','$PLUGINSDIR\rxclose.ps1','$INSTDIR','kill','$R2' -Verb RunAs -Wait -WindowStyle Hidden"`
|
||||
Pop $0
|
||||
!macroend
|
||||
|
||||
!macro customCheckAppRunning
|
||||
InitPluginsDir
|
||||
System::Call 'kernel32::GetCurrentProcessId() i .R2'
|
||||
!insertmacro rxScript
|
||||
!insertmacro rxFind
|
||||
${if} $R0 == 0
|
||||
${ifNot} ${isUpdated}
|
||||
${andIfNot} ${Silent}
|
||||
DetailPrint "$(appClosing)"
|
||||
${endIf}
|
||||
StrCpy $R1 0
|
||||
rxCloseLoop:
|
||||
IntOp $R1 $R1 + 1
|
||||
!insertmacro rxKill
|
||||
Sleep 1000
|
||||
!insertmacro rxFind
|
||||
${if} $R0 != 0
|
||||
Goto rxClosed
|
||||
${endIf}
|
||||
${if} $R1 == 4
|
||||
${andIfNot} ${Silent}
|
||||
DetailPrint "Asking Windows for permission to close the elevated copy of ${PRODUCT_NAME}..."
|
||||
!insertmacro rxKillElevated
|
||||
Sleep 1000
|
||||
!insertmacro rxFind
|
||||
${if} $R0 != 0
|
||||
Goto rxClosed
|
||||
${endIf}
|
||||
${endIf}
|
||||
${if} $R1 < 6
|
||||
Goto rxCloseLoop
|
||||
${endIf}
|
||||
StrCmp $LANGUAGE 2052 rxZh
|
||||
MessageBox MB_RETRYCANCEL|MB_ICONEXCLAMATION "${PRODUCT_NAME} is still running and the installer could not close it.$\r$\n$\r$\nRight-click its icon in the system tray (click the ^ if it is hidden) and choose Quit, or press Ctrl+Shift+Esc, end every process named $\"${PRODUCT_NAME}$\" (run Task Manager as administrator if the app was started as administrator), then click Retry." /SD IDCANCEL IDRETRY rxRetry
|
||||
Quit
|
||||
rxZh:
|
||||
MessageBox MB_RETRYCANCEL|MB_ICONEXCLAMATION "${PRODUCT_NAME} 仍在运行,安装程序无法自动关闭它。$\r$\n$\r$\n请在任务栏右下角的托盘图标(看不到就点 ^)上右键,选择“退出”;或按 Ctrl+Shift+Esc 打开任务管理器,结束所有名为“${PRODUCT_NAME}”的进程(如果它是以管理员身份启动的,请用管理员身份打开任务管理器),然后点“重试”。" /SD IDCANCEL IDRETRY rxRetry
|
||||
Quit
|
||||
rxRetry:
|
||||
StrCpy $R1 0
|
||||
Goto rxCloseLoop
|
||||
rxClosed:
|
||||
${endIf}
|
||||
!macroend
|
||||
@@ -10,14 +10,18 @@
|
||||
"start": "electron .",
|
||||
"build:host": "node packaging/build-host.js",
|
||||
"build:linux-payload": "node packaging/linux-payload.js",
|
||||
"test": "node --test test/unit.mjs test/linux-packaging.test.mjs",
|
||||
"test": "node --test test/unit.mjs test/linux-packaging.test.mjs test/globalshortcut.test.mjs",
|
||||
"build:app": "electron-builder --dir",
|
||||
"build:dist": "electron-builder",
|
||||
"smoke": "electron test/smoke.js",
|
||||
"browser-live": "electron test/browser_live.js",
|
||||
"shortcut-live": "electron test/globalshortcut_live.js",
|
||||
"theme-live": "electron test/theme_live.js",
|
||||
"browser-resize-live": "electron test/browser_resize_live.js",
|
||||
"browser-fit-live": "electron test/browser_fit_live.js",
|
||||
"pick-live": "electron test/elementpick_live.js",
|
||||
"browser-fit-pages": "electron test/browser_fit_pages.js",
|
||||
"sleep-live": "electron test/sleep_live.js",
|
||||
"image-live": "electron test/image_live.js",
|
||||
"image-pane-live": "electron test/image_pane_live.js",
|
||||
"sidepanel-live": "electron test/sidepanel_live.js",
|
||||
@@ -30,7 +34,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"electron": "44.7.2",
|
||||
"electron-builder": "^26.15.3",
|
||||
"electron-builder": "^26.18.0",
|
||||
"playwright": "1.64.1"
|
||||
},
|
||||
"pnpm": {
|
||||
|
||||
@@ -12,8 +12,8 @@ importers:
|
||||
specifier: 44.7.2
|
||||
version: 44.7.2([email protected])
|
||||
electron-builder:
|
||||
specifier: ^26.15.3
|
||||
version: 26.15.3([email protected]5.3)([email protected])
|
||||
specifier: ^26.18.0
|
||||
version: 26.18.0([email protected]8.0)([email protected])
|
||||
playwright:
|
||||
specifier: 1.64.1
|
||||
version: 1.64.1
|
||||
@@ -50,8 +50,8 @@ packages:
|
||||
engines: {node: '>=12.0.0'}
|
||||
hasBin: true
|
||||
|
||||
'@electron/[email protected].0':
|
||||
resolution: {integrity: sha512-RKL/O+jGoXJMxrx/5771y1n0xTKmFuOYGO3gMmwypBM6rsH0kou0mswwdXA2JrhIkE4xyC7v9vGk0n6NPzgOxQ==}
|
||||
'@electron/[email protected].1':
|
||||
resolution: {integrity: sha512-IS4pG3BysRrCLJ8xTAfvg+IQDbiUoAzdJy2SfjcYKnLDy5DpCdjnPdhS5Zf5UMfrkB24xu0KC+TqcOxg+eG+6w==}
|
||||
engines: {node: '>=22.12.0'}
|
||||
hasBin: true
|
||||
|
||||
@@ -80,9 +80,9 @@ packages:
|
||||
resolution: {integrity: sha512-V1JJ1WTRUqHHrOSh597hURcMqVKVGL/ea3kv0gSnEdsEZ0/+VyPghM1lMNGc00z7CIQorSvbKpuJkxvuHbvdbg==}
|
||||
engines: {node: '>= 16'}
|
||||
|
||||
'@noble/hashes@2.4.0':
|
||||
resolution: {integrity: sha512-X5XaVWZIBCT7HHZGm5I7ZQXDwLG+bGXuSrMQAW+7Zvl87h1kmc1ZB1VSRJcpUfoUrGQp4Fkoxm5kZ+Ms+aW+eA==}
|
||||
engines: {node: '>= 20.19.0'}
|
||||
'@noble/hashes@1.8.0':
|
||||
resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==}
|
||||
engines: {node: ^14.21.3 || >=16}
|
||||
|
||||
'@peculiar/[email protected]':
|
||||
resolution: {integrity: sha512-GjzePcT9Iw8NzeOPf73iNS9xM+TBhd/FilAfP+RQGkTMQJTVWtytN3JHJACCjf/ABNau5S7mS3g+DcuxmRgYEg==}
|
||||
@@ -154,12 +154,12 @@ packages:
|
||||
resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
[email protected]5.3:
|
||||
resolution: {integrity: sha512-2VnyWkqsP5v5XbBhL3tD5Syx8iNPBYsoU7kY4S2fz7wg8Rj/nztWKCUzGKaFRTv0Xwf3/H058CR1Kvtd/3lRow==}
|
||||
[email protected]8.0:
|
||||
resolution: {integrity: sha512-lx+KkuCSZHcbECf4R3IAcC9VOFk0+cMUwljO63s5y8yqS1Lw0sRGmjPaWsPwP3zv3ohZYbMhUvjXeiQXXZhJXA==}
|
||||
engines: {node: '>=14.0.0'}
|
||||
peerDependencies:
|
||||
dmg-builder: 26.15.3
|
||||
electron-builder-squirrel-windows: 26.15.3
|
||||
dmg-builder: 26.18.0
|
||||
electron-builder-squirrel-windows: 26.18.0
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==}
|
||||
@@ -219,8 +219,8 @@ packages:
|
||||
resolution: {integrity: sha512-g/kR520giAFYkSXTzcmF3kqQq7wi8F6N6SzeDgZrqTBN+VHdmgWOyTdD1yD7AATDId/yXLvuP34CxW46/BwCdw==}
|
||||
engines: {node: '>=12.0.0'}
|
||||
|
||||
[email protected]5.3:
|
||||
resolution: {integrity: sha512-q2hn7Mbo2nFNkVekPiHFx6Nfo3hURmES3tfBn+k5Pqxl2RkmP3QGqZUhH/q9Pch/4G05NRhPjDlVj1O8q4Txvw==}
|
||||
[email protected]6.0:
|
||||
resolution: {integrity: sha512-RLyJhB7Si3YkzKR9ubQslWuXW3Vhs3CGe1i+SeixBZ0qTd1mk3XBmssvY22TlB6CS5blyko8Gu1JzpYk8UkYAg==}
|
||||
engines: {node: '>=14.0.0'}
|
||||
|
||||
[email protected]:
|
||||
@@ -336,8 +336,8 @@ packages:
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-2xMCmOoMrdQIPHdsTawECdNPwlVFB9zGcz3kuhmBO6U3oU+UQjsue0i8ayLKpgBcm+hcXPMVSGUN9d+pvJ6+VQ==}
|
||||
|
||||
[email protected]5.3:
|
||||
resolution: {integrity: sha512-O3zJUFUYHJKgzPqioHxfxzBzlSC1eXCSr79gMSBKBP5AgjjpmrydMsMLotEg9fAJF36vdUncb+4ndRNxoPdlSQ==}
|
||||
[email protected]8.0:
|
||||
resolution: {integrity: sha512-ozCGC54wi2C1RkS2v69y9lQxvoQ1Coh+iT8CmWiyY8y1ZuOKVvuNson2Hd+HEOs9+EZNNsOBbzKgQZ4SNqWd0w==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-zIHwmZPRshsCdpMDyVsqGmgyP0yT8GAgXUnkdAoJisxvf33k7yO6OuoKmcTGuXPWSsm8Oh88nZicRLA9Y0rUeA==}
|
||||
@@ -359,16 +359,16 @@ packages:
|
||||
engines: {node: '>=0.10.0'}
|
||||
hasBin: true
|
||||
|
||||
[email protected]5.3:
|
||||
resolution: {integrity: sha512-Jc19XPV9y9+2bAdZPkXuVNGNIEFBq9poHC61l8Kv6FdK7DRG3+Ic0rerC0DXOaeHNz8yW0fg/JnF8GQROOF5MA==}
|
||||
[email protected]8.0:
|
||||
resolution: {integrity: sha512-a2elgTipboXNIsxqS1HOmHMGp4Pd3LJgIZOdIFTOskBSawvxKyZWYWQ1d7KKA4CtINSchYCYEKzhxRqiKZev9Q==}
|
||||
|
||||
[email protected]5.3:
|
||||
resolution: {integrity: sha512-a1KM5heqS3gQCZzizXEI8RjJy3QVogULPdeSknt76uLDpBIW/HDGsMg/XgP0riP6PI9COsRvFITKKGDqA8fJxA==}
|
||||
[email protected]8.0:
|
||||
resolution: {integrity: sha512-ASgIuQnOq4RuD8jMhS9hI0LS3EMRT1I/M5R6utqifB/BHN80IcCjBdt+DED+NmNPvmBgEtrlExXZitNdA7kXOg==}
|
||||
engines: {node: '>=14.0.0'}
|
||||
hasBin: true
|
||||
|
||||
[email protected]5.3:
|
||||
resolution: {integrity: sha512-g/2bn8YTavY4cuS5F+jOS7zmZbXXBV8KZ8yHKfJjFPoKtzBqrpCdNPxBd3tqdBwP7BVd0lGzf7Bk2s0KesWZ4Q==}
|
||||
[email protected]8.0:
|
||||
resolution: {integrity: sha512-MagnqaZSALSWjcdKu5HEVhYUZD/2BDL3q7XzrqWwub6gf/UfD3ItMgmthVDv9w5y9E0gH1C3JWqKaXwf6f4ROg==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-bO3y10YikuUwUuDUQRM4KfwNkKhnpVO7IPdbsrejwN9/AABJzzTQ4GeHwyzNSrVO+tEH3/Np255a3sVZpZDjvg==}
|
||||
@@ -1111,7 +1111,7 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@electron/[email protected].0([email protected])':
|
||||
'@electron/[email protected].1([email protected])':
|
||||
dependencies:
|
||||
'@malept/cross-spawn-promise': 2.0.0
|
||||
debug: 4.4.3([email protected])
|
||||
@@ -1164,7 +1164,7 @@ snapshots:
|
||||
|
||||
'@noble/[email protected]': {}
|
||||
|
||||
'@noble/hashes@2.4.0': {}
|
||||
'@noble/hashes@1.8.0': {}
|
||||
|
||||
'@peculiar/[email protected]':
|
||||
dependencies:
|
||||
@@ -1244,33 +1244,33 @@ snapshots:
|
||||
dependencies:
|
||||
color-convert: 2.0.1
|
||||
|
||||
[email protected]5.3([email protected]5.3)([email protected]5.3)([email protected]):
|
||||
[email protected]8.0([email protected]8.0)([email protected]8.0)([email protected]):
|
||||
dependencies:
|
||||
'@electron/asar': 3.4.1
|
||||
'@electron/fuses': 1.8.0
|
||||
'@electron/get': 3.1.0([email protected])
|
||||
'@electron/notarize': 2.5.0([email protected])
|
||||
'@electron/osx-sign': 1.3.3([email protected])
|
||||
'@electron/rebuild': 4.2.0([email protected])
|
||||
'@electron/rebuild': 4.2.1([email protected])
|
||||
'@electron/universal': 2.0.3([email protected])
|
||||
'@malept/flatpak-bundler': 0.4.0([email protected])
|
||||
'@noble/hashes': 2.4.0
|
||||
'@noble/hashes': 1.8.0
|
||||
'@peculiar/webcrypto': 1.7.1
|
||||
'@types/fs-extra': 9.0.13
|
||||
ajv: 8.20.0
|
||||
asn1js: 3.0.10
|
||||
async-exit-hook: 2.0.1
|
||||
builder-util: 26.15.3([email protected])
|
||||
builder-util: 26.16.0([email protected])
|
||||
builder-util-runtime: 9.7.0([email protected])
|
||||
chromium-pickle-js: 0.2.0
|
||||
ci-info: 4.3.1
|
||||
debug: 4.4.3([email protected])
|
||||
dmg-builder: 26.15.3([email protected]5.3)([email protected])
|
||||
dmg-builder: 26.18.0([email protected]8.0)([email protected])
|
||||
dotenv: 16.6.1
|
||||
dotenv-expand: 11.0.7
|
||||
ejs: 3.1.10
|
||||
electron-builder-squirrel-windows: 26.15.3([email protected]5.3)([email protected])
|
||||
electron-publish: 26.15.3([email protected])
|
||||
electron-builder-squirrel-windows: 26.18.0([email protected]8.0)([email protected])
|
||||
electron-publish: 26.18.0([email protected])
|
||||
fs-extra: 10.1.0
|
||||
hosted-git-info: 4.1.0
|
||||
isbinaryfile: 5.0.7
|
||||
@@ -1343,7 +1343,7 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
[email protected]5.3([email protected]):
|
||||
[email protected]6.0([email protected]):
|
||||
dependencies:
|
||||
'@types/debug': 4.1.13
|
||||
builder-util-runtime: 9.7.0([email protected])
|
||||
@@ -1470,10 +1470,10 @@ snapshots:
|
||||
minimatch: 3.1.5
|
||||
p-limit: 3.1.0
|
||||
|
||||
[email protected]5.3([email protected]5.3)([email protected]):
|
||||
[email protected]8.0([email protected]8.0)([email protected]):
|
||||
dependencies:
|
||||
app-builder-lib: 26.15.3([email protected]5.3)([email protected]5.3)([email protected])
|
||||
builder-util: 26.15.3([email protected])
|
||||
app-builder-lib: 26.18.0([email protected]8.0)([email protected]8.0)([email protected])
|
||||
builder-util: 26.16.0([email protected])
|
||||
fs-extra: 10.1.0
|
||||
js-yaml: 4.3.2
|
||||
transitivePeerDependencies:
|
||||
@@ -1500,23 +1500,23 @@ snapshots:
|
||||
dependencies:
|
||||
jake: 10.9.4
|
||||
|
||||
[email protected]5.3([email protected]5.3)([email protected]):
|
||||
[email protected]8.0([email protected]8.0)([email protected]):
|
||||
dependencies:
|
||||
app-builder-lib: 26.15.3([email protected]5.3)([email protected]5.3)([email protected])
|
||||
builder-util: 26.15.3([email protected])
|
||||
app-builder-lib: 26.18.0([email protected]8.0)([email protected]8.0)([email protected])
|
||||
builder-util: 26.16.0([email protected])
|
||||
electron-winstaller: 5.4.0([email protected])
|
||||
transitivePeerDependencies:
|
||||
- dmg-builder
|
||||
- supports-color
|
||||
|
||||
[email protected]5.3([email protected]5.3)([email protected]):
|
||||
[email protected]8.0([email protected]8.0)([email protected]):
|
||||
dependencies:
|
||||
app-builder-lib: 26.15.3([email protected]5.3)([email protected]5.3)([email protected])
|
||||
builder-util: 26.15.3([email protected])
|
||||
app-builder-lib: 26.18.0([email protected]8.0)([email protected]8.0)([email protected])
|
||||
builder-util: 26.16.0([email protected])
|
||||
builder-util-runtime: 9.7.0([email protected])
|
||||
chalk: 4.1.2
|
||||
ci-info: 4.4.0
|
||||
dmg-builder: 26.15.3([email protected]5.3)([email protected])
|
||||
dmg-builder: 26.18.0([email protected]8.0)([email protected])
|
||||
fs-extra: 10.1.0
|
||||
lazy-val: 1.0.5
|
||||
simple-update-notifier: 2.0.0
|
||||
@@ -1525,11 +1525,11 @@ snapshots:
|
||||
- electron-builder-squirrel-windows
|
||||
- supports-color
|
||||
|
||||
[email protected]5.3([email protected]):
|
||||
[email protected]8.0([email protected]):
|
||||
dependencies:
|
||||
'@types/fs-extra': 9.0.13
|
||||
aws4: 1.13.2
|
||||
builder-util: 26.15.3([email protected])
|
||||
builder-util: 26.16.0([email protected])
|
||||
builder-util-runtime: 9.7.0([email protected])
|
||||
chalk: 4.1.2
|
||||
form-data: 4.0.6
|
||||
|
||||
@@ -3,6 +3,7 @@ const crypto = require("node:crypto");
|
||||
const { WebContentsView, session } = require("electron");
|
||||
const { installPaneReload } = require("./reload");
|
||||
const { guestNavigationAllowed, typed } = require("./browserguard");
|
||||
const { ARM, STOP } = require("./elementpick");
|
||||
const { InputWake } = require("./inputwake");
|
||||
const { KeyboardKeeper } = require("./keyboard");
|
||||
|
||||
@@ -11,24 +12,48 @@ const { KeyboardKeeper } = require("./keyboard");
|
||||
// a phone layout or not at all.
|
||||
const AGENT_VIEWPORT = { width: 1280, height: 900 };
|
||||
|
||||
// How long a page may be held out of the window while its navigation runs. A
|
||||
// server that accepts the connection and never answers would otherwise keep the
|
||||
// panel blank, with nothing turning, for as long as it likes; past this the page
|
||||
// is shown again, loaded or not.
|
||||
// How long a page may be held out of the window while its navigation runs; past
|
||||
// this it is shown again loaded or not, or a server that accepts the connection
|
||||
// and never answers would keep the panel blank for as long as it likes.
|
||||
const LEAVE_LIMIT_MS = 4000;
|
||||
|
||||
// CSS cannot clip a native view, so the page's corners are rounded here; the
|
||||
// value is .bview's border-radius in studio.css, whose rect the view fills.
|
||||
const PAGE_RADIUS = 8;
|
||||
|
||||
// BrowserViews owns the pages the agent's browser opens in this window. Each is
|
||||
// a view on its own partition — never the session the window's credential
|
||||
// lives in. A page nobody is watching is not hidden and not moved wholly off
|
||||
// screen: Chromium lays out nothing outside the window, and a page it created
|
||||
// while its view was hidden takes no input until shown. It is kept overlapping
|
||||
// the window by one pixel in the bottom-left corner instead.
|
||||
// How many pages may stay asleep at once; past this the oldest is buried.
|
||||
const SLEPT_LIMIT = 18;
|
||||
|
||||
// How long a pick waits for the person. Past it the page is left as it was
|
||||
// found: a picker left armed swallows every click on the page, so the wait is
|
||||
// a minute and a half, not an afternoon.
|
||||
const PICK_LIMIT_MS = 90_000;
|
||||
// A page's own scripts live in world 0; the picker answers from its own.
|
||||
const PICK_WORLD = 999;
|
||||
// The page wrote the answer; these repeat the page script's own limits because
|
||||
// the shell cannot trust it to have run them.
|
||||
const TEXT_CLIP = 4000;
|
||||
const NEARBY_CLIP = 300;
|
||||
const MARKUP_CLIP = 6000;
|
||||
const VALUE_CLIP = 500;
|
||||
const ATTR_LIMIT = 64;
|
||||
const STYLE_CLIP = 200;
|
||||
const ELEMENT_STRINGS = ["pageUrl", "pageTitle", "tagName", "role", "accessibleName", "selector", "xpath", "text", "nearbyText", "htmlExcerpt"];
|
||||
const STYLE_KEYS = ["backgroundColor", "color", "display", "fontFamily", "fontSize", "fontWeight"];
|
||||
const SELECTOR_BASIS = ["testid", "aria-label", "id", "class"];
|
||||
const CANCEL_REASONS = ["escape", "dismissed", "timeout", "hidden", "navigation", "gone"];
|
||||
const clip = (value, limit) => (value.length > limit ? value.slice(0, limit) : value);
|
||||
|
||||
// A command that switches a CDP domain on. The kernel sends these once at
|
||||
// attach, and a sleep throws away the session they were said on, so they are
|
||||
// kept per page and said again to every session a wake builds.
|
||||
const ENABLE_SHAPE = /^[A-Za-z]+\.enable$/;
|
||||
|
||||
// BrowserViews owns the pages the agent's browser opens in this window, each a
|
||||
// view on its own partition, never the session the window's credential lives in.
|
||||
// An unwatched page is parked at a one-pixel corner; an idle one sleeps whole.
|
||||
class BrowserViews {
|
||||
constructor({ win, kernelOrigin }) {
|
||||
constructor({ win, kernelOrigin, sleepAfterMs = 15 * 60_000, sweepMs = 60_000, maxAwake = 6 }) {
|
||||
this.win = win;
|
||||
this.kernelOrigin = kernelOrigin;
|
||||
this.entries = new Map();
|
||||
@@ -39,12 +64,23 @@ class BrowserViews {
|
||||
this.trusted = new Set();
|
||||
this.shown = "";
|
||||
this.keyboard = new KeyboardKeeper({ win });
|
||||
this.wake = new InputWake({ win });
|
||||
this.inputWake = new InputWake({ win });
|
||||
// A page whose renderer has sat untouched for sleepAfterMs is slept by the
|
||||
// sweep: a slept page keeps its entry and its targetId, so the kernel's
|
||||
// handle outlives the sleep.
|
||||
this.sleepAfterMs = sleepAfterMs;
|
||||
this.maxAwake = maxAwake;
|
||||
this.sweeper = setInterval(() => this.sweep(), sweepMs);
|
||||
// The timer holds no process open by itself: the Studio's loop is kept by
|
||||
// its windows, and a test run full of these must still be able to end.
|
||||
if (typeof this.sweeper.unref === "function") this.sweeper.unref();
|
||||
// Moves with every show and hide, so a freeze that finishes after one does
|
||||
// not put away a page shown since.
|
||||
this.placed = 0;
|
||||
win.on("resize", () => {
|
||||
for (const [id, entry] of this.entries) if (id !== this.shown) this.putAway(entry.view);
|
||||
for (const [id, entry] of this.entries) {
|
||||
if (id !== this.shown && entry.view) this.putAway(entry.view);
|
||||
}
|
||||
});
|
||||
// A page in a minimized window keeps its widget hidden, and a hidden widget
|
||||
// drops the input the agent sends it — the protocol call does not even
|
||||
@@ -72,7 +108,7 @@ class BrowserViews {
|
||||
ses.on("will-download", (event, item, contents) => {
|
||||
event.preventDefault();
|
||||
for (const entry of this.entries.values()) {
|
||||
if (entry.view.webContents === contents) {
|
||||
if (entry.view && entry.view.webContents === contents) {
|
||||
entry.onDownload({ url: item.getURL(), suggestedFilename: item.getFilename() });
|
||||
}
|
||||
}
|
||||
@@ -82,9 +118,93 @@ class BrowserViews {
|
||||
create({ partition, url, onEvent, onClosed, onPopup, onDownload }) {
|
||||
this.guard(partition);
|
||||
const targetId = crypto.randomUUID();
|
||||
// The cap on awake pages obeys the same switch as the sweep — off means
|
||||
// nothing sleeps, cap included: the least recently touched page is slept to
|
||||
// make room, never the one the person is looking at. A wake yields no room.
|
||||
let awake = 0;
|
||||
let victim = null;
|
||||
for (const [id, other] of this.entries) {
|
||||
if (other.slept) continue;
|
||||
awake++;
|
||||
if (id !== this.shown && !other.sleeping && !other.returning && (!victim || other.lastTouch < victim.lastTouch)) victim = other;
|
||||
}
|
||||
if (Number.isFinite(this.sleepAfterMs) && awake >= this.maxAwake && victim) this.sleep(victim).catch(() => {});
|
||||
const entry = {
|
||||
targetId,
|
||||
partition,
|
||||
url,
|
||||
onEvent,
|
||||
onPopup,
|
||||
onDownload,
|
||||
view: null,
|
||||
mainFrame: "",
|
||||
enables: [],
|
||||
typed: 0,
|
||||
synthUntil: 0,
|
||||
attached: false,
|
||||
panel: null,
|
||||
override: null,
|
||||
emulated: null,
|
||||
scale: 1,
|
||||
lastTouch: Date.now(),
|
||||
slept: null,
|
||||
sleeping: false,
|
||||
rising: null,
|
||||
pending: new Set(),
|
||||
showing: false,
|
||||
dialog: false,
|
||||
closed: false,
|
||||
};
|
||||
// The burial of a target is the one thing the kernel is told of, so it
|
||||
// lives on the entry rather than on the view: a page that sleeps and wakes
|
||||
// grows a new view, and it is buried by its close, not by a view's death.
|
||||
const close = () => {
|
||||
if (entry.closed) return;
|
||||
entry.closed = true;
|
||||
this.entries.delete(targetId);
|
||||
clearTimeout(entry.returning);
|
||||
if (entry.attached && !this.win.isDestroyed() && entry.view) this.win.contentView.removeChildView(entry.view);
|
||||
entry.attached = false;
|
||||
const contents = entry.view && entry.view.webContents;
|
||||
if (contents && !contents.isDestroyed()) contents.close();
|
||||
onClosed();
|
||||
};
|
||||
entry.close = close;
|
||||
this.entries.set(targetId, entry);
|
||||
try {
|
||||
this.spawn(entry);
|
||||
} catch (err) {
|
||||
// A spawn that fails half-built leaves no entry behind: the target never
|
||||
// happened, so nothing is closed and the kernel is told nothing of it.
|
||||
entry.closed = true;
|
||||
this.entries.delete(targetId);
|
||||
const contents = entry.view && entry.view.webContents;
|
||||
if (contents && !contents.isDestroyed()) contents.close();
|
||||
throw err;
|
||||
}
|
||||
return {
|
||||
targetId,
|
||||
send: (method, params) => {
|
||||
entry.lastTouch = Date.now();
|
||||
// A slept page is woken first and the call dispatched to what it comes
|
||||
// back as; a page still rising takes the call when it has landed, since
|
||||
// mid-load answers the page it was.
|
||||
const rising = entry.slept ? this.wake(entry) : entry.rising;
|
||||
if (rising) return rising.then(() => this.dispatch(entry, method, params));
|
||||
return this.dispatch(entry, method, params);
|
||||
},
|
||||
close,
|
||||
mainFrame: () => (entry.slept ? "" : entry.mainFrame),
|
||||
};
|
||||
}
|
||||
|
||||
// spawn builds the view a page lives in and loads its address. It runs for
|
||||
// create() and again for every wake, so a page that slept comes back as
|
||||
// exactly what a new page is, under the handle it never lost.
|
||||
spawn(entry) {
|
||||
const view = new WebContentsView({
|
||||
webPreferences: {
|
||||
partition: "persist:" + partition,
|
||||
partition: "persist:" + entry.partition,
|
||||
sandbox: true,
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
@@ -94,8 +214,9 @@ class BrowserViews {
|
||||
},
|
||||
});
|
||||
view.setBorderRadius(PAGE_RADIUS);
|
||||
entry.view = view;
|
||||
entry.mainFrame = "";
|
||||
const contents = view.webContents;
|
||||
let mainFrame = "";
|
||||
const allowed = (to) => guestNavigationAllowed(to, this.kernelOrigin);
|
||||
contents.on("will-navigate", (event, to) => {
|
||||
if (!allowed(to)) event.preventDefault();
|
||||
@@ -104,58 +225,218 @@ class BrowserViews {
|
||||
if (!allowed(to)) event.preventDefault();
|
||||
});
|
||||
contents.setWindowOpenHandler(({ url: to }) => {
|
||||
if (allowed(to)) onPopup(to);
|
||||
if (allowed(to)) entry.onPopup(to);
|
||||
return { action: "deny" };
|
||||
});
|
||||
installPaneReload(contents);
|
||||
contents.debugger.attach("1.3");
|
||||
contents.debugger.on("message", (_event, method, params, sessionId) => {
|
||||
entry.lastTouch = Date.now();
|
||||
if (sessionId) return;
|
||||
if (method === "Page.frameNavigated" && params && params.frame && !params.frame.parentId) {
|
||||
mainFrame = params.frame.id;
|
||||
entry.mainFrame = params.frame.id;
|
||||
}
|
||||
onEvent(method, params);
|
||||
// The kernel enables Page at attach, so the dialog events reach the
|
||||
// shell: a page waiting on its answer is not slept under the question.
|
||||
if (method === "Page.javascriptDialogOpening") entry.dialog = true;
|
||||
else if (method === "Page.javascriptDialogClosed") entry.dialog = false;
|
||||
entry.onEvent(method, params);
|
||||
});
|
||||
const entry = { view, onDownload, typed: 0, synthUntil: 0, attached: false, panel: null, override: null, scale: 1 };
|
||||
this.keyboard.watch(contents, () => Date.now() < entry.synthUntil);
|
||||
this.watchLoads(targetId, entry);
|
||||
let closed = false;
|
||||
const close = () => {
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
this.entries.delete(targetId);
|
||||
clearTimeout(entry.returning);
|
||||
if (entry.attached && !this.win.isDestroyed()) this.win.contentView.removeChildView(view);
|
||||
entry.attached = false;
|
||||
if (!contents.isDestroyed()) contents.close();
|
||||
onClosed();
|
||||
};
|
||||
contents.once("destroyed", close);
|
||||
contents.on("render-process-gone", close);
|
||||
this.entries.set(targetId, entry);
|
||||
// The page loads before the window holds it: a page that finishes loading in a
|
||||
// view already attached is given the keyboard by the platform, and the
|
||||
// person's composition in the app page ends with it.
|
||||
void contents.loadURL(url).catch(() => {});
|
||||
return {
|
||||
targetId,
|
||||
send: (method, params) => {
|
||||
if (method === "Emulation.setDeviceMetricsOverride") return this.emulate(entry, params);
|
||||
if (method === "Emulation.clearDeviceMetricsOverride") return this.emulate(entry, null);
|
||||
try {
|
||||
params = scaleInput(method, params, entry.scale);
|
||||
} catch (e) {
|
||||
return Promise.reject(e);
|
||||
}
|
||||
// A capture puts the page's emulation back without its scale.
|
||||
if (method === "Page.captureScreenshot") return contents.debugger.sendCommand(method, params).finally(() => void this.fit(entry, true));
|
||||
if (!method.startsWith("Input.")) return contents.debugger.sendCommand(method, params);
|
||||
entry.synthUntil = Date.now() + 250;
|
||||
return this.wake.run(() => contents.debugger.sendCommand(method, params));
|
||||
},
|
||||
close,
|
||||
mainFrame: () => mainFrame,
|
||||
// The picker's key belongs to the page while the page holds the keyboard:
|
||||
// a press that went there never reaches the window's own page, so it is
|
||||
// taken here and the picker is started where the person is looking.
|
||||
contents.on("before-input-event", (event, input) => {
|
||||
if (!input || input.type !== "keyDown" || input.isAutoRepeat || !input.key || !input.shift) return;
|
||||
if (String(input.key).toLowerCase() !== "c") return;
|
||||
if (process.platform === "darwin" ? !input.meta : !input.control) return;
|
||||
if (entry.targetId !== this.shown) return;
|
||||
event.preventDefault();
|
||||
this.send("browser:pick-shortcut", { targetId: entry.targetId });
|
||||
});
|
||||
this.watchLoads(entry.targetId, entry);
|
||||
const gone = () => {
|
||||
// A sleep closes the contents itself, so a death under it is the sleep
|
||||
// landing, not the target dying: the entry stays a tombstone for its next
|
||||
// send() to raise or its close() to bury.
|
||||
if (entry.sleeping) {
|
||||
entry.sleeping = false;
|
||||
return;
|
||||
}
|
||||
if (entry.slept || entry.view?.webContents !== contents) return;
|
||||
entry.close();
|
||||
};
|
||||
contents.once("destroyed", gone);
|
||||
contents.on("render-process-gone", gone);
|
||||
// A woken page must come back laid out at the viewport the agent set, not
|
||||
// at whatever its view happens to be while it is parked.
|
||||
if (entry.emulated) contents.debugger.sendCommand("Emulation.setDeviceMetricsOverride", entry.emulated).catch(() => {});
|
||||
// The page loads before the window holds it, or the platform hands it the
|
||||
// keyboard and ends the person's composition. The load's promise is what a
|
||||
// wake waits out; a create leaves the waiting to the kernel.
|
||||
return contents.loadURL(entry.url).catch(() => {});
|
||||
}
|
||||
|
||||
// dispatch carries one protocol call to the page's debugger. It runs on a
|
||||
// live view only; every caller has seen to the page being awake first.
|
||||
dispatch(entry, method, params) {
|
||||
// An enable-shape command is remembered as it is sent — latest parameters,
|
||||
// first-seen order — and a domain switched off leaves the set, so the next
|
||||
// wake does not switch it back on.
|
||||
if (ENABLE_SHAPE.test(method) || method === "Page.setLifecycleEventsEnabled") {
|
||||
const known = entry.enables.find((one) => one[0] === method);
|
||||
if (known) known[1] = params;
|
||||
else entry.enables.push([method, params]);
|
||||
} else if (ENABLE_SHAPE.test(method.replace(/\.disable$/, ".enable"))) {
|
||||
const enable = method.slice(0, -".disable".length) + ".enable";
|
||||
entry.enables = entry.enables.filter(([one]) => one !== enable);
|
||||
}
|
||||
if (method === "Emulation.setDeviceMetricsOverride") return this.emulate(entry, params);
|
||||
if (method === "Emulation.clearDeviceMetricsOverride") return this.emulate(entry, null);
|
||||
try {
|
||||
params = scaleInput(method, params, entry.scale);
|
||||
} catch (e) {
|
||||
return Promise.reject(e);
|
||||
}
|
||||
// A capture puts the page's emulation back without its scale.
|
||||
if (method === "Page.captureScreenshot") return this.command(entry, method, params).finally(() => void this.fit(entry, true));
|
||||
if (!method.startsWith("Input.")) return this.command(entry, method, params);
|
||||
entry.synthUntil = Date.now() + 250;
|
||||
return this.inputWake.run(() => this.command(entry, method, params));
|
||||
}
|
||||
|
||||
// command is one debugger call the kernel is waiting on. A session that dies
|
||||
// never settles its calls on its own, so a sleep fails them here: the kernel
|
||||
// reads an error it can retry, not a timeout it cannot tell from a vanishing.
|
||||
command(entry, method, params) {
|
||||
return new Promise((resolve, reject) => {
|
||||
entry.pending.add(reject);
|
||||
entry.view.webContents.debugger.sendCommand(method, params).then(
|
||||
(value) => {
|
||||
entry.pending.delete(reject);
|
||||
resolve(value);
|
||||
},
|
||||
(err) => {
|
||||
entry.pending.delete(reject);
|
||||
reject(err);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// sleep puts a page away whole: its renderer is closed, and what is kept is
|
||||
// the address it was on and a picture to draw while it is gone. Nothing the
|
||||
// kernel holds changes, so its handle survives and the next call wakes it.
|
||||
async sleep(entry) {
|
||||
if (entry.slept || entry.closed || !this.maySleep(entry)) return;
|
||||
const contents = entry.view && entry.view.webContents;
|
||||
if (!contents) return;
|
||||
// The picture is what the window draws while the page is gone, taken the
|
||||
// way freeze() takes its own; a page that cannot be photographed is slept
|
||||
// all the same.
|
||||
let url = entry.url;
|
||||
let picture = "";
|
||||
try {
|
||||
url = contents.getURL() || entry.url;
|
||||
const image = await contents.capturePage();
|
||||
if (!image.isEmpty()) picture = "data:image/jpeg;base64," + image.toJPEG(85).toString("base64");
|
||||
} catch {
|
||||
picture = "";
|
||||
}
|
||||
// The page may have been shown, woken, closed, or begun a navigation while
|
||||
// the picture was taken; a sleep that lost that race must not tear anything
|
||||
// down, and a navigation killed mid-flight hangs the kernel waiting on it.
|
||||
if (entry.slept || entry.closed || !entry.view || !this.maySleep(entry)) return;
|
||||
entry.slept = { url, picture };
|
||||
// The calls still open on the session that is about to die are closed
|
||||
// first, as errors: the kernel hears nothing of a sleep, so no one else
|
||||
// will tell them.
|
||||
for (const fail of entry.pending) fail(new Error("the page went to sleep"));
|
||||
entry.pending.clear();
|
||||
entry.sleeping = true;
|
||||
if (entry.attached && !this.win.isDestroyed()) this.win.contentView.removeChildView(entry.view);
|
||||
entry.attached = false;
|
||||
if (!contents.isDestroyed()) contents.close();
|
||||
entry.view = null;
|
||||
entry.at = null;
|
||||
entry.panel = null;
|
||||
entry.override = null;
|
||||
entry.scale = 1;
|
||||
// A settle timer left running would put a view back that no longer
|
||||
// exists, or worse, the one a wake builds next.
|
||||
clearTimeout(entry.returning);
|
||||
entry.returning = null;
|
||||
// The ceiling on the dead: a tombstone is only a picture and an address,
|
||||
// but a driver that never comes back leaves one for every page it opened.
|
||||
// Past this the oldest-touched is buried whole and the kernel is told.
|
||||
let tombs = 0;
|
||||
let oldest = null;
|
||||
for (const other of this.entries.values()) {
|
||||
if (!other.slept) continue;
|
||||
tombs++;
|
||||
if (!oldest || other.lastTouch < oldest.lastTouch) oldest = other;
|
||||
}
|
||||
if (tombs > SLEPT_LIMIT && oldest) oldest.close();
|
||||
}
|
||||
|
||||
// wake raises a slept page: the view is built again on the address it dozed
|
||||
// off on, its viewport and the kernel's enabled domains are said again to the
|
||||
// new session, and the promise answers only once the page has loaded.
|
||||
wake(entry) {
|
||||
if (!entry.slept) return entry.rising || Promise.resolve();
|
||||
entry.url = entry.slept.url;
|
||||
entry.slept = null;
|
||||
// The sleep has landed, so whatever death its old contents still reports
|
||||
// is nothing for this one to answer for.
|
||||
entry.sleeping = false;
|
||||
const load = this.spawn(entry);
|
||||
// The recorded emulation is the view-fitting state a sleep threw away;
|
||||
// restoring it lets show() fit the panel the way it did before the sleep.
|
||||
entry.override = entry.emulated ? { ...entry.emulated } : null;
|
||||
for (const [method, params] of entry.enables) {
|
||||
entry.view.webContents.debugger.sendCommand(method, params).catch(() => {});
|
||||
}
|
||||
entry.rising = load.finally(() => {
|
||||
entry.rising = null;
|
||||
});
|
||||
return entry.rising;
|
||||
}
|
||||
|
||||
// maySleep says whether a page may be put away: not while the person is looking
|
||||
// at it, a navigation or a settle is pending, a certificate or a dialog waits,
|
||||
// it is sounding, or DevTools is open. Downloads need no guard here.
|
||||
maySleep(entry) {
|
||||
if (entry.targetId === this.shown || entry.picking || entry.returning || entry.certificate || entry.dialog) return false;
|
||||
const contents = entry.view && entry.view.webContents;
|
||||
if (typeof contents?.isCurrentlyAudible === "function" && contents.isCurrentlyAudible()) return false;
|
||||
if (typeof contents?.isDevToolsOpened === "function" && contents.isDevToolsOpened()) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
// The sweep puts pages nobody has touched to sleep. One page's failure must
|
||||
// not take the timer down with it: the pages after it are still paying.
|
||||
sweep() {
|
||||
const now = Date.now();
|
||||
for (const entry of this.entries.values()) {
|
||||
try {
|
||||
if (entry.slept || entry.sleeping) continue;
|
||||
if (now - entry.lastTouch <= this.sleepAfterMs) continue;
|
||||
if (!this.maySleep(entry)) continue;
|
||||
this.sleep(entry).catch(() => {});
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
|
||||
// A preference saved while the app runs takes effect on the next sweep.
|
||||
setSleepAfterMs(ms) {
|
||||
this.sleepAfterMs = ms;
|
||||
}
|
||||
|
||||
// What a test may read, and nothing more: whether a target is asleep, and
|
||||
// the address it is asleep on.
|
||||
asleep(targetId) {
|
||||
const entry = this.entries.get(targetId);
|
||||
return entry && entry.slept ? { url: entry.slept.url } : null;
|
||||
}
|
||||
|
||||
// show draws one page over the rectangle the page reserved for it, in window
|
||||
@@ -163,6 +444,25 @@ class BrowserViews {
|
||||
show(targetId, rect) {
|
||||
const entry = this.entries.get(targetId);
|
||||
if (!entry || !validRect(rect)) return this.hide();
|
||||
entry.lastTouch = Date.now();
|
||||
// A slept page has no view to place, and the panel does not send its
|
||||
// rectangle twice: the show is retried once the wake has built the view,
|
||||
// and only once — two shows racing would both think they placed it.
|
||||
if (entry.slept) {
|
||||
if (!entry.showing) {
|
||||
entry.showing = true;
|
||||
this.wake(entry).then(
|
||||
() => {
|
||||
entry.showing = false;
|
||||
this.show(targetId, rect);
|
||||
},
|
||||
() => {
|
||||
entry.showing = false;
|
||||
},
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const at = {
|
||||
x: Math.round(rect.x),
|
||||
y: Math.round(rect.y),
|
||||
@@ -170,13 +470,12 @@ class BrowserViews {
|
||||
height: Math.round(rect.height),
|
||||
};
|
||||
// The panel reports its rectangle whenever anything in the window changes,
|
||||
// which during a turn is constantly, and nearly always the same rectangle:
|
||||
// re-adding the view and setting the bounds it already has is work for
|
||||
// every one of those.
|
||||
// nearly always the same one: re-adding the view and setting the bounds it
|
||||
// already has is work for every one of those.
|
||||
if (this.shown === targetId && sameRect(entry.panel, at)) return;
|
||||
this.placed++;
|
||||
for (const [id, other] of this.entries) {
|
||||
if (id !== targetId) this.putAway(other.view, other);
|
||||
if (id !== targetId && other.view) this.putAway(other.view, other);
|
||||
}
|
||||
this.shown = targetId;
|
||||
// A page that was parked comes back as a view that is attached but whose
|
||||
@@ -213,8 +512,10 @@ class BrowserViews {
|
||||
|
||||
// emulate records the viewport a page was told to lay out in, which the view
|
||||
// is then fitted to; null is the page going back to the size of its view.
|
||||
// The recording is what survives a sleep and comes back with the page.
|
||||
async emulate(entry, params) {
|
||||
entry.override = sized(params) ? { ...params } : null;
|
||||
entry.emulated = sized(params) ? { ...params } : null;
|
||||
if (!entry.override) {
|
||||
entry.scale = 1;
|
||||
if (entry.panel) this.fit(entry);
|
||||
@@ -239,7 +540,7 @@ class BrowserViews {
|
||||
|
||||
reattach() {
|
||||
for (const [id, entry] of this.entries) {
|
||||
if (this.win.isDestroyed() || entry.view.webContents.isDestroyed() || !entry.attached) continue;
|
||||
if (this.win.isDestroyed() || !entry.view || entry.view.webContents.isDestroyed() || !entry.attached) continue;
|
||||
this.win.contentView.removeChildView(entry.view);
|
||||
this.win.contentView.addChildView(entry.view);
|
||||
if (id !== this.shown) this.putAway(entry.view, entry);
|
||||
@@ -248,10 +549,8 @@ class BrowserViews {
|
||||
}
|
||||
|
||||
// A navigation that starts in an attached view takes the keyboard from the app
|
||||
// page when it finishes, and a composition in progress there is committed as
|
||||
// the raw letters. While the app page holds the keyboard the view is held out of
|
||||
// the window for the length of the navigation, and put back when it ends; a
|
||||
// person who is in the page themselves keeps it where it is.
|
||||
// page and would commit a composition there as raw letters. While the app page
|
||||
// holds the keyboard the view is held out of the window until it ends.
|
||||
leave(entry) {
|
||||
if (!entry.attached || this.win.isDestroyed() || this.keyboard.owner !== "app") return;
|
||||
this.win.contentView.removeChildView(entry.view);
|
||||
@@ -263,7 +562,7 @@ class BrowserViews {
|
||||
settle(entry) {
|
||||
clearTimeout(entry.returning);
|
||||
entry.returning = undefined;
|
||||
if (entry.attached || this.win.isDestroyed() || entry.view.webContents.isDestroyed()) return;
|
||||
if (entry.attached || !entry.view || this.win.isDestroyed() || entry.view.webContents.isDestroyed()) return;
|
||||
this.win.contentView.addChildView(entry.view);
|
||||
entry.attached = true;
|
||||
if (entry.at) entry.view.setBounds(entry.at);
|
||||
@@ -271,12 +570,15 @@ class BrowserViews {
|
||||
}
|
||||
|
||||
// freeze answers with a picture of the page on screen, taken before it is put
|
||||
// away, for the window to draw in its place while something of its own is
|
||||
// over it. Only the page already shown to the person is ever taken, and the
|
||||
// picture goes to this window's page and nowhere else.
|
||||
// away. Only the page already shown is ever taken, and the picture goes to
|
||||
// this window's page and nowhere else.
|
||||
async freeze() {
|
||||
const id = this.shown;
|
||||
const entry = this.entries.get(id);
|
||||
// A slept page was photographed when it went to sleep and has no view to
|
||||
// take another with: its picture is the answer, and there is nothing to
|
||||
// put away.
|
||||
if (entry && entry.slept) return entry.slept.picture;
|
||||
if (!entry || entry.view.webContents.isDestroyed()) {
|
||||
this.hide();
|
||||
return "";
|
||||
@@ -296,7 +598,16 @@ class BrowserViews {
|
||||
hide() {
|
||||
this.placed++;
|
||||
this.shown = "";
|
||||
for (const entry of this.entries.values()) this.putAway(entry.view, entry);
|
||||
for (const entry of this.entries.values()) {
|
||||
if (entry.view) this.putAway(entry.view, entry);
|
||||
}
|
||||
// A pick behind a page nobody can see cannot be used: the person is looking
|
||||
// at a picture, so the pick is settled instead of swallowing clicks unseen.
|
||||
for (const entry of this.entries.values()) {
|
||||
if (!entry.picking) continue;
|
||||
entry.picking.reason = "hidden";
|
||||
void this.cancelPick(entry.targetId);
|
||||
}
|
||||
}
|
||||
|
||||
putAway(view, entry) {
|
||||
@@ -310,8 +621,15 @@ class BrowserViews {
|
||||
}
|
||||
|
||||
control(targetId, action) {
|
||||
const contents = this.entries.get(targetId)?.view.webContents;
|
||||
if (!contents) return;
|
||||
const entry = this.entries.get(targetId);
|
||||
const view = entry?.view;
|
||||
if (!view) {
|
||||
// A slept page has no contents to steer; waking it first is what makes
|
||||
// the action land on the page it comes back as, instead of nowhere.
|
||||
if (entry?.slept) this.wake(entry).then(() => this.control(targetId, action)).catch(() => {});
|
||||
return;
|
||||
}
|
||||
const contents = view.webContents;
|
||||
const history = contents.navigationHistory;
|
||||
switch (action) {
|
||||
case "back":
|
||||
@@ -329,6 +647,170 @@ class BrowserViews {
|
||||
}
|
||||
}
|
||||
|
||||
// pick arms one page's element picker and answers what the person pointed
|
||||
// at, or { cancelled: true } when they pressed Escape, navigated away, or
|
||||
// left it longer than a pick may wait. The highlight and the hover card are
|
||||
// drawn inside the page: a native view sits above anything this window's own
|
||||
// page could draw over it.
|
||||
// The page wrote the answer, so the shell decides what an answer is: fields
|
||||
// from an allow-list, strings clipped to the page script's own limits, and a
|
||||
// shape it cannot read treated as gone.
|
||||
clean(answer) {
|
||||
const gone = { cancelled: true, reason: "gone" };
|
||||
if (!answer || typeof answer !== "object") return gone;
|
||||
if (answer.cancelled) {
|
||||
const reason = CANCEL_REASONS.includes(answer.reason) ? answer.reason : "gone";
|
||||
return { cancelled: true, reason };
|
||||
}
|
||||
const el = answer.element;
|
||||
if (!el || typeof el !== "object") return gone;
|
||||
const out = {};
|
||||
for (const field of ELEMENT_STRINGS) {
|
||||
const value = el[field];
|
||||
if (typeof value !== "string") continue;
|
||||
out[field] = clip(value, field === "htmlExcerpt" ? MARKUP_CLIP : field === "nearbyText" ? NEARBY_CLIP : TEXT_CLIP);
|
||||
}
|
||||
if (SELECTOR_BASIS.includes(el.selectorBasis)) out.selectorBasis = el.selectorBasis;
|
||||
if (el.attributes && typeof el.attributes === "object") {
|
||||
const attributes = {};
|
||||
for (const [key, value] of Object.entries(el.attributes)) {
|
||||
if (Object.keys(attributes).length >= ATTR_LIMIT) break;
|
||||
if (typeof value === "string") attributes[clip(key, VALUE_CLIP)] = clip(value, VALUE_CLIP);
|
||||
}
|
||||
out.attributes = attributes;
|
||||
}
|
||||
if (Number.isFinite(el.capturedAt)) out.capturedAt = el.capturedAt;
|
||||
if (el.rect && typeof el.rect === "object") {
|
||||
const { x, y, width, height } = el.rect;
|
||||
if ([x, y, width, height].every(Number.isFinite)) out.rect = { x, y, width, height };
|
||||
}
|
||||
if (el.style && typeof el.style === "object") {
|
||||
out.style = {};
|
||||
for (const key of STYLE_KEYS) if (typeof el.style[key] === "string") out.style[key] = clip(el.style[key], STYLE_CLIP);
|
||||
}
|
||||
if (!out.selector || !out.tagName) return gone;
|
||||
return { cancelled: false, element: out };
|
||||
}
|
||||
|
||||
async pick(targetId, labels) {
|
||||
const entry = this.entries.get(targetId);
|
||||
if (!entry) return { cancelled: true, reason: "gone" };
|
||||
// A pick draws inside the page that is on screen; a page that is not shown
|
||||
// would pick behind the one the person is looking at.
|
||||
if (entry.targetId !== this.shown) return { cancelled: true, reason: "hidden" };
|
||||
if (entry.slept) await this.wake(entry).catch(() => {});
|
||||
const contents = entry.view && entry.view.webContents;
|
||||
if (!contents || contents.isDestroyed()) return { cancelled: true, reason: "gone" };
|
||||
// A second pick on the same page replaces the first: one picker, one
|
||||
// overlay, and the older call answers as cancelled.
|
||||
if (entry.picking) {
|
||||
entry.picking.reason = "dismissed";
|
||||
await this.cancelPick(targetId).catch(() => {});
|
||||
}
|
||||
// Why the pick ends when it ends without an element: Escape and a dismissal
|
||||
// come from the page, the other three from here. The person is told which,
|
||||
// so "nothing happened" is never the answer.
|
||||
const picking = (entry.picking = { reason: "" });
|
||||
const timer = setTimeout(() => {
|
||||
picking.reason = "timeout";
|
||||
void this.cancelPick(targetId);
|
||||
}, PICK_LIMIT_MS);
|
||||
if (typeof timer.unref === "function") timer.unref();
|
||||
try {
|
||||
const answer = this.clean(await contents.executeJavaScript(ARM(labels), false));
|
||||
if (answer.cancelled) return picking.reason ? { cancelled: true, reason: picking.reason } : answer;
|
||||
// The page cannot invent an element: the shell re-finds the selector in
|
||||
// its own world before the answer is trusted.
|
||||
if (!(await this.lands(contents, answer.element))) return { cancelled: true, reason: "gone" };
|
||||
// The picture is what the person saw: a complaint about how something
|
||||
// looks is answered by the element, not only by its markup.
|
||||
const shot = await this.shot(entry, answer.element.rect).catch(() => "");
|
||||
if (shot) answer.shot = shot;
|
||||
return answer;
|
||||
} catch {
|
||||
// The page navigated, reloaded, slept, or was closed under the pick.
|
||||
return { cancelled: true, reason: picking.reason || "navigation" };
|
||||
} finally {
|
||||
if (entry.picking === picking) entry.picking = null;
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
// lands asks the DOM from the shell's own world whether a selector still
|
||||
// names an element of the tag the pick reported. A page script cannot answer
|
||||
// this one in the page's place.
|
||||
async lands(contents, element) {
|
||||
if (!element || !element.selector || !element.tagName) return false;
|
||||
const tag = await contents
|
||||
.executeJavaScriptInIsolatedWorld(PICK_WORLD, [
|
||||
{
|
||||
code: `(() => {
|
||||
const el = document.querySelector(${JSON.stringify(element.selector)});
|
||||
return el ? el.tagName.toLowerCase() : "";
|
||||
})()`,
|
||||
},
|
||||
])
|
||||
.catch(() => "");
|
||||
return tag === String(element.tagName).toLowerCase();
|
||||
}
|
||||
|
||||
// shot captures the element as the view drew it. The page's rect is in CSS
|
||||
// pixels and the view may be fitted, so the scale is applied and the result
|
||||
// is kept inside the view; an element scrolled out of sight has no picture.
|
||||
async shot(entry, rect) {
|
||||
const contents = entry.view && entry.view.webContents;
|
||||
if (!contents || contents.isDestroyed() || !rect) return "";
|
||||
const scale = entry.scale || 1;
|
||||
const bounds = entry.view.getBounds();
|
||||
const at = {
|
||||
x: Math.max(0, Math.round(rect.x * scale)),
|
||||
y: Math.max(0, Math.round(rect.y * scale)),
|
||||
width: Math.round(rect.width * scale),
|
||||
height: Math.round(rect.height * scale),
|
||||
};
|
||||
at.width = Math.min(at.width, bounds.width - at.x);
|
||||
at.height = Math.min(at.height, bounds.height - at.y);
|
||||
if (at.width < 1 || at.height < 1) return "";
|
||||
const image = await contents.capturePage(at);
|
||||
return image.isEmpty() ? "" : image.toDataURL();
|
||||
}
|
||||
|
||||
// verify re-finds a selector after the page changed, so a person can see
|
||||
// whether an edit landed where the pick pointed.
|
||||
async verify(targetId, selector) {
|
||||
const entry = this.entries.get(targetId);
|
||||
if (!entry || !selector) return { found: false };
|
||||
const contents = entry.view && entry.view.webContents;
|
||||
if (!contents || contents.isDestroyed()) return { found: false };
|
||||
const at = await contents
|
||||
.executeJavaScriptInIsolatedWorld(PICK_WORLD, [
|
||||
{
|
||||
code: `(() => {
|
||||
const el = document.querySelector(${JSON.stringify(selector)});
|
||||
if (!el) return { found: false };
|
||||
const r = el.getBoundingClientRect();
|
||||
return { found: true, tag: el.tagName.toLowerCase(), rect: { x: r.x, y: r.y, width: r.width, height: r.height } };
|
||||
})()`,
|
||||
},
|
||||
])
|
||||
.catch(() => ({ found: false }));
|
||||
if (!at || !at.found || !at.rect) return { found: false };
|
||||
const shot = await this.shot(entry, at.rect).catch(() => "");
|
||||
return { found: true, tag: at.tag, rect: at.rect, shot };
|
||||
}
|
||||
|
||||
// cancelPick disarms the picker, which settles a pick still waiting on the
|
||||
// page. A page that has gone answers nothing, which reads as cancelled.
|
||||
cancelPick(targetId) {
|
||||
const entry = this.entries.get(targetId);
|
||||
const contents = entry && entry.view && entry.view.webContents;
|
||||
if (!contents || contents.isDestroyed()) return Promise.resolve(false);
|
||||
return contents.executeJavaScript(STOP, false).then(
|
||||
() => true,
|
||||
() => false,
|
||||
);
|
||||
}
|
||||
|
||||
// navigate starts loading what the person typed and answers "", or the code
|
||||
// of why the window will not: "scheme" or "network_file".
|
||||
navigate(targetId, address) {
|
||||
@@ -336,6 +818,11 @@ class BrowserViews {
|
||||
const { url, fallback, refusal } = typed(address, this.kernelOrigin);
|
||||
if (!entry) return "scheme";
|
||||
if (refusal) return refusal;
|
||||
// An address typed at a slept page is for the page it wakes up as.
|
||||
if (entry.slept) {
|
||||
this.wake(entry).then(() => this.loadTyped(targetId, entry, url, fallback)).catch(() => {});
|
||||
return "";
|
||||
}
|
||||
void this.loadTyped(targetId, entry, url, fallback);
|
||||
return "";
|
||||
}
|
||||
@@ -435,6 +922,11 @@ class BrowserViews {
|
||||
if (!cert) return false;
|
||||
this.trusted.add(trustKey(cert.host, cert.fingerprint));
|
||||
entry.certificate = null;
|
||||
// The page the certificate was refused for may be asleep by now.
|
||||
if (entry.slept) {
|
||||
this.wake(entry).then(() => this.loadTyped(targetId, entry, cert.url, "")).catch(() => {});
|
||||
return true;
|
||||
}
|
||||
void this.loadTyped(targetId, entry, cert.url, "");
|
||||
return true;
|
||||
}
|
||||
@@ -444,8 +936,15 @@ class BrowserViews {
|
||||
}
|
||||
|
||||
closeAll() {
|
||||
// The sweep's timer must not outlive the pages it sweeps: the pages go,
|
||||
// and a timer left holding the process open keeps a test run from ever
|
||||
// ending.
|
||||
if (this.sweeper) {
|
||||
clearInterval(this.sweeper);
|
||||
this.sweeper = null;
|
||||
}
|
||||
for (const id of [...this.logins.keys()]) this.answerLogin(id);
|
||||
for (const entry of [...this.entries.values()]) entry.view.webContents.close();
|
||||
for (const entry of [...this.entries.values()]) entry.close();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -488,11 +987,9 @@ function placement(panel, emulated) {
|
||||
return { at: { x, y, width, height }, scale };
|
||||
}
|
||||
|
||||
// Input that carries a point. The page reads it in the viewport it was told it
|
||||
// has, the view draws that viewport scaled, and a point meant for the page lands
|
||||
// where it is drawn only after the same scaling. The mouse is measured to; the
|
||||
// rest are not, and a point that silently lands elsewhere is worse than a
|
||||
// refusal, so they are refused while the page is scaled.
|
||||
// Input that carries a point lands where it is drawn only after the same scaling
|
||||
// the view was drawn at. The mouse is measured to; a point that lands elsewhere
|
||||
// silently is worse than a refusal, so the rest are refused while scaled.
|
||||
const POINTED_INPUT = new Set([
|
||||
"Input.dispatchTouchEvent",
|
||||
"Input.dispatchDragEvent",
|
||||
|
||||
@@ -19,4 +19,15 @@ function keepCredential(ses, cookie, log) {
|
||||
return () => ses.cookies.removeListener("changed", onChanged);
|
||||
}
|
||||
|
||||
module.exports = { keepCredential };
|
||||
// The change event is not the only way the jar loses the cookie: a network
|
||||
// service restart drops session cookies without announcing a removal. A request
|
||||
// the kernel refused is the moment that shows, so the shell reads the jar then.
|
||||
async function restoreIfStale(ses, cookie, log) {
|
||||
const held = await ses.cookies.get({ url: cookie.url, name: cookie.name });
|
||||
const found = held.find((c) => c.name === cookie.name);
|
||||
if (found && found.value === cookie.value) return;
|
||||
log(`credential cookie ${found ? "differs from this launch's" : "missing"} after a refused request; restoring`);
|
||||
await ses.cookies.set(cookie).catch((err) => log(`credential cookie restore failed: ${err.message}`));
|
||||
}
|
||||
|
||||
module.exports = { keepCredential, restoreIfStale };
|
||||
@@ -13,6 +13,7 @@ const WORDS = {
|
||||
paste: "粘贴",
|
||||
selectAll: "全选",
|
||||
pasteAndMatchStyle: "粘贴并匹配样式",
|
||||
quote: "加入对话",
|
||||
delete: "删除",
|
||||
substitutions: "替换",
|
||||
showSubstitutions: "显示替换",
|
||||
@@ -33,6 +34,7 @@ const WORDS = {
|
||||
selectAll: "Select All",
|
||||
pasteAndMatchStyle: "Paste and Match Style",
|
||||
delete: "Delete",
|
||||
quote: "Add to conversation",
|
||||
substitutions: "Substitutions",
|
||||
showSubstitutions: "Show Substitutions",
|
||||
smartQuotes: "Smart Quotes",
|
||||
@@ -70,7 +72,8 @@ function editItems(lang, can) {
|
||||
// click: this only decides what to offer.
|
||||
function contextTemplate(params, lang) {
|
||||
if (!params.isEditable && !params.selectionText) return [];
|
||||
return editItems(lang, params.editFlags ?? {});
|
||||
const items = editItems(lang, params.editFlags ?? {});
|
||||
return params.isEditable ? items : items.filter((item) => item.role === "copy" || item.role === "selectAll");
|
||||
}
|
||||
|
||||
// Everything the platform's own Edit menu carries, with the same roles, so
|
||||
@@ -128,4 +131,4 @@ function menuInstaller(Menu, platform = process.platform) {
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { contextTemplate, editMenuTemplate, applicationMenuTemplate, menuInstaller };
|
||||
module.exports = { contextTemplate, editItems, wordsFor, editMenuTemplate, applicationMenuTemplate, menuInstaller };
|
||||
@@ -0,0 +1,572 @@
|
||||
"use strict";
|
||||
|
||||
// The page half of the element picker, kept as a function so it can be
|
||||
// stringified into the page. The highlight and the hover card are drawn there
|
||||
// because a native view sits above anything this window's own page draws.
|
||||
//
|
||||
// Nothing here may reach out of the function: the page runs it alone, with no
|
||||
// module and no binding of this process to refer to.
|
||||
|
||||
function pagePicker(labels = {}) {
|
||||
// The window owns the words and sends them in: the page only draws them.
|
||||
const L = {
|
||||
color: "Color",
|
||||
background: "Background",
|
||||
font: "Font",
|
||||
frame: "Element inside an embedded frame — not supported",
|
||||
shadow: "Element inside a shadow root — not supported",
|
||||
...labels,
|
||||
};
|
||||
const KEY = "__reasonixElementPicker";
|
||||
const CLIP = 4000;
|
||||
// Any words of its own already say what the element is, in any language;
|
||||
// only one that cannot speak — an icon, an image — is given the words
|
||||
// around it. A Chinese heading is a sentence in a dozen characters, so the
|
||||
// test is whether it has text at all, not how much.
|
||||
const NEARBY_OWN = 2;
|
||||
const NEARBY_LIMIT = 300;
|
||||
let settlePick = null;
|
||||
// The promise is the pick: it settles on the click, on Escape, or when the
|
||||
// page takes the picker away from under it.
|
||||
const answer = new Promise((done) => {
|
||||
settlePick = done;
|
||||
});
|
||||
const previous = window[KEY];
|
||||
if (previous) previous.stop();
|
||||
|
||||
const TAG_ROLES = {
|
||||
A: "link",
|
||||
BUTTON: "button",
|
||||
IMG: "img",
|
||||
NAV: "navigation",
|
||||
MAIN: "main",
|
||||
FORM: "form",
|
||||
SELECT: "combobox",
|
||||
TEXTAREA: "textbox",
|
||||
H1: "heading",
|
||||
H2: "heading",
|
||||
H3: "heading",
|
||||
H4: "heading",
|
||||
H5: "heading",
|
||||
H6: "heading",
|
||||
};
|
||||
const INPUT_ROLES = {
|
||||
checkbox: "checkbox",
|
||||
radio: "radio",
|
||||
range: "slider",
|
||||
button: "button",
|
||||
submit: "button",
|
||||
reset: "button",
|
||||
};
|
||||
const KEPT = new Set(["id", "class", "href", "src", "alt", "title", "name", "type", "placeholder", "data-testid"]);
|
||||
// The roles an element can have while saying nothing to a reader by itself:
|
||||
// an icon button, an image, a field. Context is for these. A decorative
|
||||
// span has no role, and the page around it does not give it one.
|
||||
const SPEAKING = new Set(["button", "link", "textbox", "checkbox", "radio", "slider", "combobox", "img"]);
|
||||
|
||||
const clip = (text, limit) => (text.length > limit ? text.slice(0, limit) : text);
|
||||
const textOf = (node) => (node.innerText || node.textContent || "").trim();
|
||||
|
||||
// rgb()/rgba() as the page computed them, written the way a person reads a
|
||||
// colour; a translucent one is left as computed so its alpha is not lost.
|
||||
const hex = (css) => {
|
||||
const match = /^rgba?\(([^)]+)\)$/.exec(css || "");
|
||||
if (!match) return css || "";
|
||||
const parts = match[1].split(/[\s,/]+/).filter(Boolean).map(Number);
|
||||
if (parts.length > 3 && parts[3] < 1) return css;
|
||||
const [r, g, b] = parts;
|
||||
return ("#" + [r, g, b].map((one) => Math.round(one).toString(16).padStart(2, "0")).join("")).toUpperCase();
|
||||
};
|
||||
|
||||
const roleOf = (el) => {
|
||||
const stated = el.getAttribute("role");
|
||||
if (stated) return stated;
|
||||
if (el.tagName === "INPUT") return INPUT_ROLES[el.type] || "textbox";
|
||||
return TAG_ROLES[el.tagName] || "";
|
||||
};
|
||||
|
||||
const nameOf = (el) => {
|
||||
const ids = (el.getAttribute("aria-labelledby") || "").split(/\s+/).filter(Boolean);
|
||||
if (ids.length) {
|
||||
const text = ids
|
||||
.map((id) => {
|
||||
const node = document.getElementById(id);
|
||||
return node ? textOf(node) : "";
|
||||
})
|
||||
.join(" ")
|
||||
.trim();
|
||||
if (text) return clip(text, 500);
|
||||
}
|
||||
for (const attr of ["aria-label", "alt", "title", "placeholder"]) {
|
||||
const value = el.getAttribute(attr);
|
||||
if (value && value.trim()) return clip(value.trim(), 500);
|
||||
}
|
||||
return clip(textOf(el), 500);
|
||||
};
|
||||
|
||||
// What the page declares as an identity outlives what it builds for styling:
|
||||
// a hash class is gone after the next deploy, a data-testid is not.
|
||||
const attrValue = (value) => '"' + value.replace(/["\\]/g, "\\$&") + '"';
|
||||
|
||||
// A CSS path a person can paste, preferring the page's own identity over its
|
||||
// styling. Falls back to tag.class with :nth-of-type where a sibling would
|
||||
// answer the same, up to eight levels of parent.
|
||||
const selectorOf = (el) => {
|
||||
const testId = el.getAttribute("data-testid");
|
||||
if (testId) return { selector: `[data-testid=${attrValue(testId)}]`, basis: "testid" };
|
||||
const label = el.getAttribute("aria-label");
|
||||
if (label) return { selector: `${el.tagName.toLowerCase()}[aria-label=${attrValue(label)}]`, basis: "aria-label" };
|
||||
const own = el.getAttribute("id");
|
||||
if (own) return { selector: "#" + CSS.escape(own), basis: "id" };
|
||||
const parts = [];
|
||||
let node = el;
|
||||
for (let depth = 0; node && node.nodeType === 1 && depth < 8; depth++) {
|
||||
const id = node.getAttribute("id");
|
||||
if (id) {
|
||||
parts.unshift("#" + CSS.escape(id));
|
||||
break;
|
||||
}
|
||||
let part = node.tagName.toLowerCase();
|
||||
for (const name of [...node.classList].filter(Boolean).slice(0, 3)) part += "." + CSS.escape(name);
|
||||
const parent = node.parentElement;
|
||||
if (parent) {
|
||||
const same = [...parent.children].filter((child) => child.tagName === node.tagName);
|
||||
if (same.length > 1) part += `:nth-of-type(${same.indexOf(node) + 1})`;
|
||||
}
|
||||
parts.unshift(part);
|
||||
node = node.parentElement;
|
||||
}
|
||||
return { selector: parts.join(" > "), basis: "class" };
|
||||
};
|
||||
|
||||
const xpathOf = (el) => {
|
||||
const parts = [];
|
||||
let node = el;
|
||||
for (let depth = 0; node && node.nodeType === 1 && depth < 12; depth++) {
|
||||
let index = 1;
|
||||
for (let sib = node.previousElementSibling; sib; sib = sib.previousElementSibling) {
|
||||
if (sib.tagName === node.tagName) index++;
|
||||
}
|
||||
parts.unshift(`${node.tagName.toLowerCase()}[${index}]`);
|
||||
node = node.parentElement;
|
||||
}
|
||||
return "/" + parts.join("/");
|
||||
};
|
||||
|
||||
// What the element says. A password field never hands over what was typed in
|
||||
// it: the pick leaves with the fact that it was one, and nothing else.
|
||||
const textFor = (el) => {
|
||||
// An editable region holds what the person is writing right now.
|
||||
if (el.isContentEditable) return "[masked editable text]";
|
||||
if (el.tagName === "INPUT" && el.type === "password") return "[masked password input]";
|
||||
if (el.tagName === "INPUT" || el.tagName === "TEXTAREA") {
|
||||
return clip([el.getAttribute("aria-label"), el.getAttribute("placeholder"), el.getAttribute("name"), el.type].filter(Boolean).join(" "), CLIP);
|
||||
}
|
||||
return clip(textOf(el), CLIP);
|
||||
};
|
||||
|
||||
// What surrounds an element, for the ones that cannot speak for themselves.
|
||||
// An element with text of its own asks for nothing: the nearest article
|
||||
// around a title is the whole page, which is not context but noise.
|
||||
// What surrounds an element, for the ones that cannot speak for themselves.
|
||||
// An element with text of its own asks for nothing, and neither does one
|
||||
// with no role at all: a decorative mark is not made meaningful by the page
|
||||
// around it, so the words there would be noise rather than context.
|
||||
const nearbyFor = (el) => {
|
||||
if (textOf(el).trim().length >= NEARBY_OWN) return "";
|
||||
if (!SPEAKING.has(roleOf(el))) return "";
|
||||
const near = el.closest("article, section, main, form, li, tr, dialog") || el.parentElement;
|
||||
return near ? clip(textOf(near), NEARBY_LIMIT) : "";
|
||||
};
|
||||
|
||||
// The element's own markup with its scripts and the values a person typed
|
||||
// taken out, so what goes to the chat is structure, not their input.
|
||||
const excerptFor = (el) => {
|
||||
const clone = el.cloneNode(true);
|
||||
for (const drop of clone.querySelectorAll("script, style, noscript, template")) drop.remove();
|
||||
for (const field of clone.querySelectorAll("input")) field.removeAttribute("value");
|
||||
if (clone.tagName === "INPUT") clone.removeAttribute("value");
|
||||
for (const area of clone.querySelectorAll("textarea")) area.textContent = "";
|
||||
if (clone.isContentEditable) clone.textContent = "";
|
||||
return clip(clone.outerHTML, 6000);
|
||||
};
|
||||
|
||||
const describe = (el) => {
|
||||
const rect = el.getBoundingClientRect();
|
||||
const computed = getComputedStyle(el);
|
||||
const attributes = {};
|
||||
for (const attr of el.attributes) {
|
||||
const name = attr.name.toLowerCase();
|
||||
if (name === "value") continue;
|
||||
if (!KEPT.has(name) && !name.startsWith("aria-")) continue;
|
||||
attributes[name] = clip(attr.value, 500);
|
||||
}
|
||||
const style = {
|
||||
color: hex(computed.color),
|
||||
display: computed.display,
|
||||
fontFamily: computed.fontFamily,
|
||||
fontSize: computed.fontSize,
|
||||
fontWeight: computed.fontWeight,
|
||||
};
|
||||
if (computed.backgroundColor && computed.backgroundColor !== "rgba(0, 0, 0, 0)" && computed.backgroundColor !== "transparent") {
|
||||
style.backgroundColor = hex(computed.backgroundColor);
|
||||
}
|
||||
const path = selectorOf(el);
|
||||
const info = {
|
||||
pageUrl: location.href,
|
||||
pageTitle: document.title,
|
||||
tagName: el.tagName.toLowerCase(),
|
||||
selector: path.selector,
|
||||
xpath: xpathOf(el),
|
||||
htmlExcerpt: excerptFor(el),
|
||||
attributes,
|
||||
rect: { x: rect.x, y: rect.y, width: rect.width, height: rect.height },
|
||||
style,
|
||||
capturedAt: Date.now(),
|
||||
};
|
||||
// Which of the four the selector came from, so a reader can tell a stable
|
||||
// identity from one that a deploy will change.
|
||||
info.selectorBasis = path.basis;
|
||||
// A field with no answer is left out rather than sent empty: "there is no
|
||||
// role" and "the role is an empty string" are different answers, and the
|
||||
// second is not one this page can give.
|
||||
const role = roleOf(el);
|
||||
if (role) info.role = role;
|
||||
const name = nameOf(el);
|
||||
if (name) info.accessibleName = name;
|
||||
const own = textFor(el);
|
||||
if (own) info.text = own;
|
||||
const nearby = nearbyFor(el);
|
||||
if (nearby) info.nearbyText = nearby;
|
||||
return info;
|
||||
};
|
||||
|
||||
const box = document.createElement("div");
|
||||
box.dataset.reasonixPick = "box";
|
||||
box.setAttribute("aria-hidden", "true");
|
||||
box.style.cssText =
|
||||
"position:fixed;z-index:2147483646;pointer-events:none;border:2px solid #3b82f6;" +
|
||||
"border-radius:3px;background:rgba(59,130,246,.12);box-sizing:border-box";
|
||||
const card = document.createElement("div");
|
||||
card.dataset.reasonixPick = "card";
|
||||
card.setAttribute("aria-hidden", "true");
|
||||
card.style.cssText =
|
||||
"position:fixed;z-index:2147483647;pointer-events:none;background:#1f2328;color:#e6edf3;" +
|
||||
"border-radius:8px;padding:9px 12px;font:12px/1.6 ui-sans-serif,system-ui,sans-serif;" +
|
||||
"box-shadow:0 8px 24px rgba(0,0,0,.28);min-width:190px;max-width:340px";
|
||||
|
||||
// One card line. The label is the quiet side, the value the loud one; both
|
||||
// are set as text, never as markup, because the page chose both.
|
||||
const line = (label, value, loud) => {
|
||||
const row = document.createElement("div");
|
||||
row.style.cssText = "display:flex;gap:12px;justify-content:space-between;align-items:baseline";
|
||||
const key = document.createElement("span");
|
||||
key.textContent = label;
|
||||
key.style.cssText = `flex:none;color:${loud ? "#e6edf3" : "#8b949e"};font-weight:${loud ? 600 : 400}`;
|
||||
const val = document.createElement("span");
|
||||
val.textContent = value;
|
||||
val.style.cssText =
|
||||
"color:#e6edf3;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;overflow:hidden;" +
|
||||
"text-overflow:ellipsis;white-space:nowrap" + (loud ? ";font-weight:600" : "");
|
||||
row.append(key, val);
|
||||
card.append(row);
|
||||
};
|
||||
|
||||
let target = null;
|
||||
// The chain of children walked up from, so the down keys can walk back.
|
||||
let descended = [];
|
||||
// Shift-click keeps a set: the person is naming a region, not one node.
|
||||
const chosen = [];
|
||||
const marks = [];
|
||||
let armed = true;
|
||||
let listeners = [];
|
||||
|
||||
const clear = () => {
|
||||
for (const [type, handler, capture] of listeners) document.removeEventListener(type, handler, capture);
|
||||
listeners = [];
|
||||
for (const { cover } of frameCovers) cover.remove();
|
||||
frameCovers.length = 0;
|
||||
for (const at of marks) at.remove();
|
||||
marks.length = 0;
|
||||
box.remove();
|
||||
card.remove();
|
||||
if (window[KEY] && window[KEY].stop === stop) delete window[KEY];
|
||||
};
|
||||
|
||||
const settle = (result) => {
|
||||
if (!armed) return;
|
||||
armed = false;
|
||||
clear();
|
||||
settlePick(result);
|
||||
};
|
||||
|
||||
const stop = (reason = "dismissed") => settle({ cancelled: true, reason });
|
||||
|
||||
// Where the card sits: under the element when the viewport has room for it,
|
||||
// above otherwise, and never past either edge.
|
||||
// A frame's own document takes the pointer, so the top document never hears a
|
||||
// move over it. A cover over each frame keeps the picker's events coming and
|
||||
// says why that area is not pickable instead of going quiet.
|
||||
const frameCovers = [];
|
||||
const positionCovers = () => {
|
||||
for (const { cover, frame } of frameCovers) {
|
||||
const rect = frame.getBoundingClientRect();
|
||||
cover.style.left = rect.left + "px";
|
||||
cover.style.top = rect.top + "px";
|
||||
cover.style.width = rect.width + "px";
|
||||
cover.style.height = rect.height + "px";
|
||||
cover.style.display = rect.width > 0 && rect.height > 0 ? "block" : "none";
|
||||
}
|
||||
};
|
||||
const coverFrames = () => {
|
||||
for (const { cover } of frameCovers) cover.remove();
|
||||
frameCovers.length = 0;
|
||||
for (const frame of document.querySelectorAll("iframe, frame")) {
|
||||
const cover = document.createElement("div");
|
||||
cover.dataset.reasonixFrame = "1";
|
||||
cover.style.cssText =
|
||||
"position:fixed;z-index:2147483646;pointer-events:auto;background:rgba(59,130,246,0.08);border:1px dashed rgba(59,130,246,0.55);box-sizing:border-box";
|
||||
cover.addEventListener("pointermove", () => show(frame), true);
|
||||
cover.addEventListener("click", (event) => {
|
||||
swallow(event);
|
||||
show(frame);
|
||||
}, true);
|
||||
document.documentElement.append(cover);
|
||||
frameCovers.push({ cover, frame });
|
||||
}
|
||||
positionCovers();
|
||||
};
|
||||
|
||||
const place = () => {
|
||||
if (!target) return;
|
||||
const rect = target.getBoundingClientRect();
|
||||
const width = Math.max(0, rect.width);
|
||||
const height = Math.max(0, rect.height);
|
||||
box.style.left = rect.left + "px";
|
||||
box.style.top = rect.top + "px";
|
||||
box.style.width = width + "px";
|
||||
box.style.height = height + "px";
|
||||
card.style.visibility = "hidden";
|
||||
card.style.left = "0px";
|
||||
card.style.top = "0px";
|
||||
const size = card.getBoundingClientRect();
|
||||
const below = rect.bottom + 8;
|
||||
const top = below + size.height <= innerHeight ? below : Math.max(8, rect.top - size.height - 8);
|
||||
const left = Math.min(Math.max(8, rect.left), Math.max(8, innerWidth - size.width - 8));
|
||||
card.style.left = left + "px";
|
||||
card.style.top = top + "px";
|
||||
card.style.visibility = "visible";
|
||||
};
|
||||
|
||||
// The chain from the document down to the element, short enough to read in
|
||||
// the card: a person walking up with the arrow keys needs to see where they
|
||||
// are, not the whole tree.
|
||||
const ancestorsOf = (el) => {
|
||||
const chain = [];
|
||||
for (let node = el; node && node.nodeType === 1; node = node.parentElement) {
|
||||
chain.unshift(node);
|
||||
if (chain.length > 8) break;
|
||||
}
|
||||
return chain;
|
||||
};
|
||||
const crumbLabel = (el) => el.tagName.toLowerCase() + (el.id ? "#" + el.id : "");
|
||||
|
||||
// A frame or a shadow host cannot be read from here: the picker only ever
|
||||
// sees the host element, so it says so instead of handing over a useless
|
||||
// answer that looks like the real thing.
|
||||
const blockedOf = (el) => {
|
||||
if (!el) return "";
|
||||
if (el.tagName === "IFRAME" || el.tagName === "FRAME") return "iframe";
|
||||
if (el.shadowRoot) return "shadow";
|
||||
return "";
|
||||
};
|
||||
|
||||
const show = (el) => {
|
||||
target = el;
|
||||
const rect = el.getBoundingClientRect();
|
||||
const computed = getComputedStyle(el);
|
||||
card.textContent = "";
|
||||
const blocked = blockedOf(el);
|
||||
if (blocked) {
|
||||
const note = document.createElement("div");
|
||||
note.textContent = blocked === "iframe" ? L.frame : L.shadow;
|
||||
note.style.cssText = "max-width:260px;color:#e6edf3;font-size:12px;line-height:1.5";
|
||||
card.append(note);
|
||||
box.style.display = "block";
|
||||
card.style.display = "block";
|
||||
place();
|
||||
return;
|
||||
}
|
||||
const crumb = document.createElement("div");
|
||||
crumb.style.cssText = "display:flex;gap:4px;overflow:hidden;white-space:nowrap;margin-bottom:4px;color:#8b949e;font-size:11px";
|
||||
const chain = ancestorsOf(el).slice(-5);
|
||||
chain.forEach((node, index) => {
|
||||
if (index) {
|
||||
const sep = document.createElement("span");
|
||||
sep.textContent = "›";
|
||||
sep.style.opacity = "0.6";
|
||||
crumb.append(sep);
|
||||
}
|
||||
const part = document.createElement("span");
|
||||
part.textContent = crumbLabel(node);
|
||||
if (node === el) {
|
||||
part.style.color = "#e6edf3";
|
||||
part.style.fontWeight = "600";
|
||||
}
|
||||
crumb.append(part);
|
||||
});
|
||||
card.append(crumb);
|
||||
line(el.tagName.toLowerCase(), `${Math.round(rect.width)}×${Math.round(rect.height)}`, true);
|
||||
line(L.color, hex(computed.color), false);
|
||||
if (computed.backgroundColor && computed.backgroundColor !== "rgba(0, 0, 0, 0)" && computed.backgroundColor !== "transparent") {
|
||||
line(L.background, hex(computed.backgroundColor), false);
|
||||
}
|
||||
line(L.font, `${computed.fontSize} ${computed.fontFamily}`, false);
|
||||
box.style.display = "block";
|
||||
card.style.display = "block";
|
||||
place();
|
||||
};
|
||||
|
||||
document.documentElement.append(box, card);
|
||||
coverFrames();
|
||||
box.style.display = "none";
|
||||
card.style.display = "none";
|
||||
|
||||
// Every gesture is taken from the page while the pick is armed: a click
|
||||
// chooses an element instead of pressing a button, and no page handler hears
|
||||
// it either. Escape is the way out that changes nothing.
|
||||
const swallow = (event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
event.stopImmediatePropagation();
|
||||
};
|
||||
let moveAt = null;
|
||||
let moveFrame = 0;
|
||||
const onMove = (event) => {
|
||||
// One hit test per frame: a page with a heavy layout is not asked to answer
|
||||
// a move it will not draw.
|
||||
moveAt = event;
|
||||
if (moveFrame) return;
|
||||
moveFrame = requestAnimationFrame(() => {
|
||||
moveFrame = 0;
|
||||
if (!armed) return;
|
||||
const at = moveAt;
|
||||
moveAt = null;
|
||||
if (!at) return;
|
||||
const el = document.elementFromPoint(at.clientX, at.clientY);
|
||||
if (!el || el === box || el === card) return;
|
||||
if (el.dataset && el.dataset.reasonixFrame) return;
|
||||
if (el === target) return;
|
||||
descended = [];
|
||||
show(el);
|
||||
});
|
||||
};
|
||||
// A picked element keeps a mark with its order, so a set stays visible while
|
||||
// the person keeps adding to it.
|
||||
const mark = (el) => {
|
||||
const rect = el.getBoundingClientRect();
|
||||
const at = document.createElement("div");
|
||||
at.dataset.reasonixPicked = "1";
|
||||
at.setAttribute("aria-hidden", "true");
|
||||
at.style.cssText = `position:fixed;z-index:2147483645;pointer-events:none;border:1px solid #22c55e;background:rgba(34,197,94,0.12);box-sizing:border-box;left:${rect.left}px;top:${rect.top}px;width:${rect.width}px;height:${rect.height}px`;
|
||||
const order = document.createElement("span");
|
||||
order.textContent = String(marks.length + 1);
|
||||
order.style.cssText = "position:absolute;top:-17px;left:-1px;background:#22c55e;color:#04170a;font:600 10px ui-sans-serif,system-ui,sans-serif;padding:1px 4px;border-radius:3px";
|
||||
at.append(order);
|
||||
document.documentElement.append(at);
|
||||
marks.push(at);
|
||||
};
|
||||
const onPick = (event) => {
|
||||
if (!armed) return;
|
||||
swallow(event);
|
||||
// The pointer may have climbed with the arrow keys: what is picked is the
|
||||
// level the person is looking at, not what happens to be under the cursor.
|
||||
try {
|
||||
// A frame or shadow host is not an answer: the click leaves the pick
|
||||
// armed while the card already says why.
|
||||
if (target) {
|
||||
if (blockedOf(target)) return;
|
||||
if (event.shiftKey) {
|
||||
chosen.push(target);
|
||||
mark(target);
|
||||
return;
|
||||
}
|
||||
return settle(chosen.length ? { cancelled: false, elements: [...chosen, target].map(describe) } : { cancelled: false, element: describe(target) });
|
||||
}
|
||||
const el = document.elementFromPoint(event.clientX, event.clientY);
|
||||
if (!el) return settle({ cancelled: true });
|
||||
if (blockedOf(el)) return;
|
||||
if (event.shiftKey) {
|
||||
chosen.push(el);
|
||||
mark(el);
|
||||
return;
|
||||
}
|
||||
settle(chosen.length ? { cancelled: false, elements: [...chosen, el].map(describe) } : { cancelled: false, element: describe(el) });
|
||||
} catch (error) {
|
||||
console.error("reasonix element pick failed", error);
|
||||
settle({ cancelled: true, reason: "gone" });
|
||||
}
|
||||
};
|
||||
const onKey = (event) => {
|
||||
if (event.key === "Escape") {
|
||||
swallow(event);
|
||||
stop("escape");
|
||||
return;
|
||||
}
|
||||
// Up walks to the parent and down back to the child it left, with the chain
|
||||
// drawn in the card so neither move is a guess.
|
||||
if (event.key === "ArrowUp" || event.key === "ArrowLeft") {
|
||||
swallow(event);
|
||||
const parent = target && target.parentElement;
|
||||
if (parent && parent !== document.documentElement) {
|
||||
descended.push(target);
|
||||
show(parent);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (event.key === "ArrowDown" || event.key === "ArrowRight") {
|
||||
swallow(event);
|
||||
const child = descended.pop();
|
||||
if (child && target && child.parentElement === target) show(child);
|
||||
return;
|
||||
}
|
||||
// Every other key belongs to the picker as well: Enter on a focused button
|
||||
// would run the page action a click is not allowed to run, and the pick is
|
||||
// not using the page.
|
||||
swallow(event);
|
||||
};
|
||||
const onScroll = () => {
|
||||
place();
|
||||
positionCovers();
|
||||
};
|
||||
|
||||
for (const [type, handler, capture] of [
|
||||
["pointerdown", swallow, true],
|
||||
["pointerup", swallow, true],
|
||||
["mousedown", swallow, true],
|
||||
["mouseup", swallow, true],
|
||||
["dblclick", swallow, true],
|
||||
["contextmenu", swallow, true],
|
||||
["click", onPick, true],
|
||||
["pointermove", onMove, true],
|
||||
["mousemove", onMove, true],
|
||||
["keydown", onKey, true],
|
||||
["scroll", onScroll, true],
|
||||
["resize", onScroll, false],
|
||||
]) {
|
||||
document.addEventListener(type, handler, capture);
|
||||
listeners.push([type, handler, capture]);
|
||||
}
|
||||
|
||||
window[KEY] = { stop };
|
||||
return answer;
|
||||
}
|
||||
|
||||
// ARM runs the picker in the page's own world; the promise it answers with is
|
||||
// what executeJavaScript returns, so a pick takes as long as the person does.
|
||||
const ARM = (labels) => `(${pagePicker.toString()})(${JSON.stringify(labels || {})})`;
|
||||
|
||||
// STOP disarms whatever picker is armed, settling that pick as cancelled.
|
||||
const STOP = `(() => { const picker = window.__reasonixElementPicker; if (picker) picker.stop(); })()`;
|
||||
|
||||
module.exports = { ARM, STOP };
|
||||
@@ -0,0 +1,65 @@
|
||||
"use strict";
|
||||
|
||||
const PREF_KEY = "shell-global-shortcut";
|
||||
const MAX_BINDING_LENGTH = 128;
|
||||
|
||||
function createGlobalShortcut({ globalShortcut, binding = "", save, windowOf, showWindow }) {
|
||||
let state = { binding, status: binding ? "unavailable" : "disabled" };
|
||||
let closed = false;
|
||||
const press = () => {
|
||||
if (closed) return;
|
||||
const win = windowOf();
|
||||
if (win && !win.isDestroyed() && win.isVisible() && !win.isMinimized() && win.isFocused()) win.hide();
|
||||
else showWindow();
|
||||
};
|
||||
function register(value) {
|
||||
try {
|
||||
return globalShortcut.register(value, press) ? "" : "unavailable";
|
||||
} catch {
|
||||
return "invalid";
|
||||
}
|
||||
}
|
||||
function apply(value, persist) {
|
||||
if (closed) return { ...state, error: "quitting" };
|
||||
if (typeof value !== "string" || value.length > MAX_BINDING_LENGTH) return { ...state, error: "invalid" };
|
||||
const next = value.trim();
|
||||
if (next && !next.split("+").slice(0, -1).some(part => /^(CommandOrControl|CmdOrCtrl|Command|Cmd|Control|Ctrl|Alt|Option|AltGr|Super|Meta)$/i.test(part))) {
|
||||
return { ...state, error: "invalid" };
|
||||
}
|
||||
if (next === state.binding && state.status === "registered") return { ...state };
|
||||
if (next) {
|
||||
const error = register(next);
|
||||
if (error) return { ...state, error };
|
||||
}
|
||||
try {
|
||||
if (persist) save(next);
|
||||
} catch {
|
||||
if (next) globalShortcut.unregister(next);
|
||||
return { ...state, error: "save" };
|
||||
}
|
||||
if (state.status === "registered" && state.binding !== next) globalShortcut.unregister(state.binding);
|
||||
state = { binding: next, status: next ? "registered" : "disabled" };
|
||||
return { ...state };
|
||||
}
|
||||
return {
|
||||
start() {
|
||||
const result = apply(binding, false);
|
||||
if (result.error) state = { binding, status: result.error };
|
||||
return { ...state };
|
||||
},
|
||||
read: () => ({ ...state }),
|
||||
apply: value => apply(value, true),
|
||||
close() {
|
||||
closed = true;
|
||||
if (state.status === "registered") globalShortcut.unregister(state.binding);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function registerGlobalShortcutIPC(ipcMain, fromWindow, shortcutOf) {
|
||||
const authorized = event => fromWindow(event) && event.senderFrame === event.sender.mainFrame;
|
||||
ipcMain.handle("shell:global-shortcut", event => authorized(event) ? shortcutOf()?.read() ?? { binding: "", status: "unavailable" } : null);
|
||||
ipcMain.handle("shell:set-global-shortcut", (event, value) => authorized(event) ? shortcutOf()?.apply(value) ?? { binding: "", status: "unavailable" } : null);
|
||||
}
|
||||
|
||||
module.exports = { PREF_KEY, MAX_BINDING_LENGTH, createGlobalShortcut, registerGlobalShortcutIPC };
|
||||
@@ -5,17 +5,20 @@
|
||||
// compositor leaves uncovered at a fractional scale matches the app.
|
||||
const GROUND = { light: "#f4f1ea", dark: "#060a10" };
|
||||
|
||||
// "auto" and anything unrecognised follow the system, as the page does.
|
||||
function groundFor(prefs, systemDark) {
|
||||
const theme = prefs?.["rx-theme"];
|
||||
const dark = theme === "dark" ? true : theme === "light" ? false : systemDark;
|
||||
function groundFor(prefs, systemDark, painted) {
|
||||
const source = themeSourceFor(prefs, painted);
|
||||
const dark = source === "system" ? systemDark : source === "dark";
|
||||
return dark ? GROUND.dark : GROUND.light;
|
||||
}
|
||||
|
||||
// The scheme the window frame takes. Windows draws a frameless window's border
|
||||
// from it, so a dark page on a light system needs it set to stay borderless.
|
||||
function themeSourceFor(prefs) {
|
||||
const theme = prefs?.["rx-theme"];
|
||||
function themeSourceFor(prefs, painted) {
|
||||
const theme = painted?.mode ?? prefs?.["rx-theme"];
|
||||
if (theme === "scheduled") {
|
||||
const scheme = painted?.scheme ?? prefs?.["rx-theme-resolved"];
|
||||
return scheme === "dark" || scheme === "light" ? scheme : "system";
|
||||
}
|
||||
return theme === "dark" || theme === "light" ? theme : "system";
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
"use strict";
|
||||
const { app, BrowserWindow, dialog, ipcMain, nativeTheme, powerSaveBlocker, screen, session, shell } = require("electron");
|
||||
const { app, BrowserWindow, dialog, globalShortcut, ipcMain, Menu, nativeTheme, powerSaveBlocker, screen, session, shell } = require("electron");
|
||||
const { relaunchForOzonePlatform } = require("./ozone");
|
||||
|
||||
// Before the instance lock: this process must hold nothing its relaunch needs.
|
||||
@@ -15,6 +15,7 @@ const { installTray } = require("./tray");
|
||||
const { instanceID, profileFor } = require("./instance");
|
||||
const { watchLauncher, consoleEcho } = require("./launcher");
|
||||
const { installApplicationMenu, installContextMenu } = require("./menu");
|
||||
const { textContextMenuHandler } = require("./textcontextmenu");
|
||||
const { uiLanguage } = require("./uilang");
|
||||
const { installFullScreenKey } = require("./fullscreen");
|
||||
const { installReload } = require("./reload");
|
||||
@@ -30,7 +31,8 @@ const { BrowserProtocol } = require("./browserprotocol");
|
||||
const { BrowserViews } = require("./browserviews");
|
||||
const { startBrowserRelay } = require("./browserrelay");
|
||||
const { groundFor, themeSourceFor } = require("./ground");
|
||||
const { loadPrefs, prefsFile, registerPrefs } = require("./prefs");
|
||||
const { loadPrefs, savePrefs, pageSleepAfterMs, prefsFile, registerPrefs } = require("./prefs");
|
||||
const { PREF_KEY: SHORTCUT_PREF, createGlobalShortcut, registerGlobalShortcutIPC } = require("./globalshortcut");
|
||||
const { stateFile, openWindow } = require("./windowstate");
|
||||
const { accelerationOff, shouldDisableGpu, graphicsHandler } = require("./graphics");
|
||||
const { processMemoryHandler } = require("./procmem");
|
||||
@@ -38,7 +40,7 @@ const { captureHandler } = require("./capture");
|
||||
const { createPowerGuard, keepsAwake } = require("./powerguard");
|
||||
const { openLogs, redactArgv, failStartup } = require("./shelllog");
|
||||
const { raise } = require("./raise");
|
||||
const { keepCredential } = require("./credential");
|
||||
const { keepCredential, restoreIfStale } = require("./credential");
|
||||
const { actHandler } = require("./acts");
|
||||
const { crashDir, recordHostExit, pendingHostExit, clearHostExit, hostExitNotice } = require("./hostexit");
|
||||
|
||||
@@ -89,6 +91,7 @@ let browserRelay = null;
|
||||
let reload = null;
|
||||
let powerGuard = null;
|
||||
let logs = null;
|
||||
let windowShortcut = null;
|
||||
let handshaken = false;
|
||||
|
||||
let starting = null;
|
||||
@@ -198,8 +201,9 @@ cua = await startEmbedded(computerDriver, contract, text => logs.host.raw(text))
|
||||
powerGuard.begin();
|
||||
// The poll alone leaves a turn unprotected for up to its period; the page's
|
||||
// own submit is the moment one starts, and this process sees it complete.
|
||||
session.defaultSession.webRequest.onCompleted({ urls: [`${ready.origin}/*submit`] }, (details) => {
|
||||
if (details.method === "POST" && details.statusCode < 300) void powerGuard?.refresh();
|
||||
session.defaultSession.webRequest.onCompleted({ urls: [`${ready.origin}/*`] }, (details) => {
|
||||
if (details.statusCode === 403) void restoreIfStale(session.defaultSession, credentialCookie, (line) => logs.shell.line(line));
|
||||
if (details.method === "POST" && details.statusCode < 300 && details.url.endsWith("submit")) void powerGuard?.refresh();
|
||||
});
|
||||
followTheme();
|
||||
win = createWindow();
|
||||
@@ -230,8 +234,10 @@ cua = await startEmbedded(computerDriver, contract, text => logs.host.raw(text))
|
||||
|
||||
// The agent's browser draws its pages as views in this window: the kernel
|
||||
// drives them through the relay, and the page decides where one is shown.
|
||||
// Idle pages sleep only once the person turns rx-page-sleep on; prefs.js owns
|
||||
// the threshold, and a save reaches the running window through onSave below.
|
||||
function hostAgentBrowser() {
|
||||
browserViews = new BrowserViews({ win, kernelOrigin: origin });
|
||||
browserViews = new BrowserViews({ win, kernelOrigin: origin, sleepAfterMs: pageSleepAfterMs(storedPrefs()) });
|
||||
const protocol = new BrowserProtocol({
|
||||
createView: (spec) => browserViews.create(spec),
|
||||
post: (frame) => browserRelay?.post(frame),
|
||||
@@ -280,6 +286,8 @@ function cleanUpLegacyInstalls() {
|
||||
});
|
||||
}
|
||||
|
||||
let credentialCookie;
|
||||
|
||||
// Set before anything is loaded, or the first request answers 403 and the
|
||||
// window opens on a refusal. HttpOnly because nothing in the page ever reads
|
||||
// it, Strict because no cross-site navigation ever needs to carry it, and no
|
||||
@@ -294,6 +302,7 @@ async function armCredential(ready) {
|
||||
secure: false,
|
||||
sameSite: "strict",
|
||||
};
|
||||
credentialCookie = cookie;
|
||||
await session.defaultSession.cookies.set(cookie);
|
||||
keepCredential(session.defaultSession, cookie, (line) => logs.shell.line(line));
|
||||
}
|
||||
@@ -328,9 +337,12 @@ function fitted() {
|
||||
}
|
||||
|
||||
const storedPrefs = () => loadPrefs(prefsFile(app.getPath("userData")));
|
||||
const ground = () => groundFor(storedPrefs(), nativeTheme.shouldUseDarkColors);
|
||||
let paintedTheme = null;
|
||||
const ground = () => groundFor(storedPrefs(), nativeTheme.shouldUseDarkColors, paintedTheme);
|
||||
const followTheme = () => {
|
||||
nativeTheme.themeSource = themeSourceFor(storedPrefs());
|
||||
const source = themeSourceFor(storedPrefs(), paintedTheme);
|
||||
if (nativeTheme.themeSource !== source) nativeTheme.themeSource = source;
|
||||
if (win && !win.isDestroyed()) win.setBackgroundColor(ground());
|
||||
};
|
||||
|
||||
function createWindow() {
|
||||
@@ -391,14 +403,20 @@ const uiLang = () => uiLanguage(loadPrefs(prefsFile(app.getPath("userData"))), a
|
||||
registerPrefs(ipcMain, () => prefsFile(app.getPath("userData")), fromWindow, () => {
|
||||
installApplicationMenu(uiLang);
|
||||
followTheme();
|
||||
if (win && !win.isDestroyed()) win.setBackgroundColor(ground());
|
||||
void powerGuard?.refresh();
|
||||
browserViews?.setSleepAfterMs(pageSleepAfterMs(storedPrefs()));
|
||||
}, paint => {
|
||||
paintedTheme = paint;
|
||||
followTheme();
|
||||
});
|
||||
nativeTheme.on("updated", () => {
|
||||
if (win && !win.isDestroyed()) win.setBackgroundColor(ground());
|
||||
});
|
||||
|
||||
ipcMain.handle("shell:graphics", (event) => graphicsHandler(app, fromWindow, gpuLaunch)(event));
|
||||
registerGlobalShortcutIPC(ipcMain, fromWindow, () => windowShortcut);
|
||||
const showTextContextMenu = textContextMenuHandler(Menu, fromWindow, () => origin, uiLang);
|
||||
ipcMain.handle("text:context-menu", showTextContextMenu);
|
||||
ipcMain.handle("shell:process-memory", (event) => processMemoryHandler(app, fromWindow)(event));
|
||||
|
||||
ipcMain.handle("window:unread-badge", (event, unread) => {
|
||||
@@ -433,6 +451,15 @@ ipcMain.handle("browser:control", (event, targetId, action) => {
|
||||
ipcMain.handle("browser:navigate", (event, targetId, address) =>
|
||||
fromWindow(event) ? (browserViews?.navigate(String(targetId), String(address)) ?? "scheme") : "scheme",
|
||||
);
|
||||
ipcMain.handle("browser:pick", (event, targetId, labels) =>
|
||||
fromWindow(event) ? (browserViews?.pick(String(targetId), labels) ?? { cancelled: true }) : { cancelled: true },
|
||||
);
|
||||
ipcMain.handle("browser:pick-cancel", (event, targetId) =>
|
||||
fromWindow(event) ? (browserViews?.cancelPick(String(targetId)) ?? false) : false,
|
||||
);
|
||||
ipcMain.handle("browser:verify", (event, targetId, selector) =>
|
||||
fromWindow(event) ? (browserViews?.verify(String(targetId), String(selector)) ?? { found: false }) : { found: false },
|
||||
);
|
||||
ipcMain.handle("browser:trust-certificate", (event, targetId) =>
|
||||
fromWindow(event) ? (browserViews?.trustCertificate(String(targetId)) ?? false) : false,
|
||||
);
|
||||
@@ -544,7 +571,18 @@ if (!primary) {
|
||||
}
|
||||
|
||||
app.whenReady().then(() => {
|
||||
if (!primary) return;
|
||||
if (!primary || quitting) return;
|
||||
windowShortcut = createGlobalShortcut({
|
||||
globalShortcut,
|
||||
binding: savedPrefs[SHORTCUT_PREF] ?? "",
|
||||
save: binding => {
|
||||
const file = prefsFile(app.getPath("userData"));
|
||||
savePrefs(file, { ...loadPrefs(file), [SHORTCUT_PREF]: binding });
|
||||
},
|
||||
windowOf: () => win,
|
||||
showWindow,
|
||||
});
|
||||
windowShortcut.start();
|
||||
installApplicationMenu(uiLang);
|
||||
boot().catch((err) => {
|
||||
console.error("reasonix-studio:", err.message);
|
||||
@@ -591,6 +629,7 @@ module.exports = { current: () => ({ win, tray, client, origin }) };
|
||||
const drainCua = createQuitGate(() => cua, () => app.quit(), error => logs.shell.line(`CUA cleanup: ${error.message}`));
|
||||
app.on("before-quit", event => {
|
||||
quitting = true;
|
||||
windowShortcut?.close();
|
||||
logs.shell.line("shell: quitting");
|
||||
drainCua(event);
|
||||
browserRelay?.stop();
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use strict";
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const { PREF_KEY: SHORTCUT_PREF } = require("./globalshortcut");
|
||||
|
||||
// The page's own preferences, kept past the origin that stored them. The kernel
|
||||
// listens on a fresh loopback port each launch and localStorage is keyed by
|
||||
@@ -12,13 +13,20 @@ function prefsFile(userData) {
|
||||
return path.join(userData, "window-prefs.json");
|
||||
}
|
||||
|
||||
// Anything that is not one of the page's own string preferences is dropped, in
|
||||
// both directions: a hand-edited file or a page bug cannot widen what is kept.
|
||||
// How long a page the agent's browser holds may sit undriven before the shell
|
||||
// puts it to sleep. Off until the person turns it on; "on" waits 15 minutes.
|
||||
const PAGE_SLEEP_MS = 15 * 60_000;
|
||||
|
||||
function pageSleepAfterMs(prefs) {
|
||||
return prefs && prefs["rx-page-sleep"] === "on" ? PAGE_SLEEP_MS : Infinity;
|
||||
}
|
||||
|
||||
// Only page string preferences and the shell-owned shortcut survive a disk read.
|
||||
function pick(value) {
|
||||
const out = {};
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return out;
|
||||
for (const [key, v] of Object.entries(value)) {
|
||||
if (key.startsWith(PREFIX) && typeof v === "string") out[key] = v;
|
||||
if ((key.startsWith(PREFIX) || key === SHORTCUT_PREF) && typeof v === "string") out[key] = v;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -44,14 +52,17 @@ function savePrefs(file, prefs) {
|
||||
// in the same process and must not reach this file.
|
||||
// fileOf is asked on each use: the profile directory is settled during boot,
|
||||
// after these handlers exist.
|
||||
function registerPrefs(ipcMain, fileOf, fromWindow, onSave = () => {}) {
|
||||
function registerPrefs(ipcMain, fileOf, fromWindow, onSave = () => {}, onTheme = () => {}) {
|
||||
ipcMain.on("prefs:load", (event) => {
|
||||
event.returnValue = fromWindow(event) ? loadPrefs(fileOf()) : {};
|
||||
event.returnValue = fromWindow(event) ? pickPagePrefs(loadPrefs(fileOf())) : {};
|
||||
});
|
||||
ipcMain.on("prefs:save", (event, prefs) => {
|
||||
if (fromWindow(event)) {
|
||||
try {
|
||||
savePrefs(fileOf(), prefs);
|
||||
const current = loadPrefs(fileOf());
|
||||
const next = pickPagePrefs(prefs);
|
||||
if (current[SHORTCUT_PREF] !== undefined) next[SHORTCUT_PREF] = current[SHORTCUT_PREF];
|
||||
savePrefs(fileOf(), next);
|
||||
onSave();
|
||||
} catch {
|
||||
// A preference that did not save is one relaunch of lost state, not a
|
||||
@@ -60,6 +71,21 @@ function registerPrefs(ipcMain, fileOf, fromWindow, onSave = () => {}) {
|
||||
}
|
||||
event.returnValue = true;
|
||||
});
|
||||
ipcMain.on("prefs:theme", (event, paint) => {
|
||||
if (!fromWindow(event) || !event.senderFrame || event.senderFrame !== event.sender.mainFrame) return;
|
||||
if (!paint || typeof paint !== "object" || Array.isArray(paint)) return;
|
||||
const { mode, scheme } = paint;
|
||||
if (mode !== "auto" && mode !== "scheduled" && mode !== "light" && mode !== "dark") return;
|
||||
if (scheme !== "light" && scheme !== "dark") return;
|
||||
if ((mode === "light" || mode === "dark") && mode !== scheme) return;
|
||||
onTheme({ mode, scheme });
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { PREFIX, prefsFile, pick, loadPrefs, savePrefs, registerPrefs };
|
||||
function pickPagePrefs(prefs) {
|
||||
const page = pick(prefs);
|
||||
delete page[SHORTCUT_PREF];
|
||||
return page;
|
||||
}
|
||||
|
||||
module.exports = { PREFIX, PAGE_SLEEP_MS, pageSleepAfterMs, prefsFile, pick, loadPrefs, savePrefs, registerPrefs };
|
||||
@@ -22,14 +22,18 @@ contextBridge.exposeInMainWorld("reasonixHost", {
|
||||
platform: process.platform,
|
||||
titleBar,
|
||||
setUnreadBadge: (unread) => ipcRenderer.invoke("window:unread-badge", unread),
|
||||
syncTheme: (mode, scheme) => ipcRenderer.send("prefs:theme", { mode, scheme }),
|
||||
minimiseWindow: () => ipcRenderer.invoke("window:minimise"),
|
||||
toggleMaximiseWindow: () => ipcRenderer.invoke("window:toggle-maximise"),
|
||||
isWindowMaximised: () => ipcRenderer.invoke("window:is-maximised"),
|
||||
closeWindow: () => ipcRenderer.invoke("window:close"),
|
||||
// A PNG of this window's own page, taking nothing: there is no way to name another.
|
||||
captureWindow: () => ipcRenderer.invoke("window:capture"),
|
||||
showTextContextMenu: (request) => ipcRenderer.invoke("text:context-menu", request),
|
||||
// What the shell's graphics are running as this launch.
|
||||
graphics: () => ipcRenderer.invoke("shell:graphics"),
|
||||
globalShortcut: () => ipcRenderer.invoke("shell:global-shortcut"),
|
||||
setGlobalShortcut: (binding) => ipcRenderer.invoke("shell:set-global-shortcut", binding),
|
||||
// The window's processes as type and two sizes, for the memory a report may carry.
|
||||
processMemory: () => ipcRenderer.invoke("shell:process-memory"),
|
||||
openExternal: (url) => ipcRenderer.invoke("shell:open-external", String(url)),
|
||||
@@ -61,10 +65,17 @@ contextBridge.exposeInMainWorld("reasonixHost", {
|
||||
freezeBrowserView: () => ipcRenderer.invoke("browser:freeze"),
|
||||
controlBrowserView: (targetId, action) => ipcRenderer.invoke("browser:control", String(targetId), String(action)),
|
||||
navigateBrowserView: (targetId, address) => ipcRenderer.invoke("browser:navigate", String(targetId), String(address)),
|
||||
// Arm this page's element picker, and let a person put it away again. The
|
||||
// answer is the element they pointed at, or that they picked nothing.
|
||||
pickBrowserElement: (targetId, labels) => ipcRenderer.invoke("browser:pick", String(targetId), labels),
|
||||
cancelBrowserPick: (targetId) => ipcRenderer.invoke("browser:pick-cancel", String(targetId)),
|
||||
verifyBrowserElement: (targetId, selector) => ipcRenderer.invoke("browser:verify", String(targetId), String(selector)),
|
||||
// Why a page did not load, and a login a page or proxy asks for. The password
|
||||
// goes back to this window's main process only; the kernel never sees it.
|
||||
onBrowserLoadState: (listener) => subscribe("browser:load-state", listener),
|
||||
onBrowserLogin: (listener) => subscribe("browser:login", listener),
|
||||
// The picker's key, pressed in the page rather than in this one.
|
||||
onBrowserPickShortcut: (listener) => subscribe("browser:pick-shortcut", listener),
|
||||
trustBrowserCertificate: (targetId) => ipcRenderer.invoke("browser:trust-certificate", String(targetId)),
|
||||
answerBrowserLogin: (id, username, password) =>
|
||||
ipcRenderer.invoke("browser:login-answer", String(id), String(username || ""), String(password || "")),
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
"use strict";
|
||||
const { editItems, wordsFor } = require("./editmenu");
|
||||
|
||||
const FLAGS = ["editable", "selected", "canUndo", "canRedo", "quote"];
|
||||
function validRequest(request) {
|
||||
return request !== null && typeof request === "object" && !Array.isArray(request)
|
||||
&& Object.keys(request).length === FLAGS.length + 2
|
||||
&& FLAGS.every((key) => Object.hasOwn(request, key) && typeof request[key] === "boolean")
|
||||
&& Number.isFinite(request.x) && Number.isFinite(request.y);
|
||||
}
|
||||
|
||||
function textContextTemplate(request, lang, choose) {
|
||||
if (!request.editable && !request.selected) return [];
|
||||
let items = editItems(lang, {
|
||||
canUndo: request.canUndo, canRedo: request.canRedo,
|
||||
canCut: request.selected, canCopy: request.selected, canPaste: request.editable, canSelectAll: true,
|
||||
});
|
||||
if (!request.editable) items = items.filter((item) => item.role === "copy" || item.role === "selectAll");
|
||||
const accelerators = {
|
||||
undo: "CommandOrControl+Z",
|
||||
redo: process.platform === "darwin" ? "CommandOrControl+Shift+Z" : "CommandOrControl+Y",
|
||||
selectAll: "CommandOrControl+A",
|
||||
};
|
||||
items = items.map((item) => {
|
||||
const accelerator = accelerators[item.role];
|
||||
if (!accelerator) return item;
|
||||
return { id: item.role, label: item.label, enabled: item.enabled, accelerator,
|
||||
click: () => choose(item.role) };
|
||||
});
|
||||
if (request.quote && request.selected) {
|
||||
items.push({ type: "separator" }, { label: wordsFor(lang).quote, click: () => choose("quote") });
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
function textContextMenuHandler(Menu, fromWindow, originOf, languageOf) {
|
||||
const pending = new WeakMap();
|
||||
return function showTextContextMenu(event, request) {
|
||||
const window = fromWindow(event);
|
||||
if (!window || !validRequest(request)) return "";
|
||||
const contents = window.webContents;
|
||||
if (!event.senderFrame || event.senderFrame !== contents.mainFrame) return "";
|
||||
try {
|
||||
if (new URL(event.senderFrame.url).origin !== new URL(originOf()).origin) return "";
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
pending.get(window)?.();
|
||||
const template = textContextTemplate(request, languageOf(), (action) => finish(action));
|
||||
if (!template.length) return "";
|
||||
const menu = Menu.buildFromTemplate(template);
|
||||
let finish;
|
||||
return new Promise((resolve, reject) => {
|
||||
let settled = false;
|
||||
const cancel = () => {
|
||||
finish("");
|
||||
if (!window.isDestroyed() && !contents.isDestroyed?.()) menu.closePopup(window);
|
||||
};
|
||||
const navigation = (details) => {
|
||||
if (details.isMainFrame) cancel();
|
||||
};
|
||||
finish = (result, error) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
contents.removeListener("did-start-navigation", navigation);
|
||||
contents.removeListener("destroyed", cancel);
|
||||
window.removeListener("closed", cancel);
|
||||
pending.delete(window);
|
||||
if (error) reject(error);
|
||||
else resolve(result);
|
||||
};
|
||||
pending.set(window, cancel);
|
||||
contents.on("did-start-navigation", navigation);
|
||||
contents.once("destroyed", cancel);
|
||||
window.once("closed", cancel);
|
||||
const zoom = contents.getZoomFactor();
|
||||
try {
|
||||
menu.popup({ window, x: Math.round(request.x * zoom), y: Math.round(request.y * zoom), callback: () => finish("") });
|
||||
} catch (error) {
|
||||
finish("", error);
|
||||
}
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { textContextTemplate, textContextMenuHandler };
|
||||
@@ -112,13 +112,8 @@ async function main() {
|
||||
|
||||
const js = (src) => win.webContents.executeJavaScript(src);
|
||||
const reserved = () => js(`(() => { const e = document.querySelector('.bview'); if (!e) return null; const b = e.getBoundingClientRect(); return { x: b.left, y: b.top, width: b.width, height: b.height }; })()`);
|
||||
// The browser tab beside the conversation is a switch for that view itself, so
|
||||
// the page is widened with the browser's own wide control, which is how the
|
||||
// person makes it the whole pane.
|
||||
await until("the wide control on the browser panel", () => js(`!!document.querySelector('[data-action="browser.wide"]')`));
|
||||
await js(`document.querySelector('[data-action="browser.wide"]').click()`);
|
||||
// A page that is put away is as wide as an agent's viewport, so wide means drawn
|
||||
// and wider than it was beside the conversation.
|
||||
await until("the full-view control on the browser panel", () => js(`!!document.querySelector('[data-action="browser.full"]')`));
|
||||
await js(`document.querySelector('[data-action="browser.full"]').click()`);
|
||||
await until("the page drawn full width", async () => (!beneath() && width() > aside.width ? guest.getBounds() : null));
|
||||
// The panel widens over a moment; the rectangle is read once it has stopped.
|
||||
const same = still;
|
||||
@@ -149,7 +144,7 @@ async function main() {
|
||||
process.stdout.write(` (no screenshot: ${err.message})\n`);
|
||||
}
|
||||
|
||||
await js(`document.querySelector('[data-action="browser.wide"]').click()`);
|
||||
await js(`document.querySelector('[data-action="browser.full"]').click()`);
|
||||
const back = await until("the page beside the conversation again", async () => (!beneath() && width() < placed.width ? guest.getBounds() : null));
|
||||
// The column settles a pixel or two after the first measurement, so beside means
|
||||
// the same place and size to within a few pixels.
|
||||
|
||||
@@ -0,0 +1,319 @@
|
||||
"use strict";
|
||||
// Drives the element picker in the real shell: a person's own mouse and keys
|
||||
// over a page of our own, in a user-data directory of our own. Run with
|
||||
// `pnpm pick-live`; it needs no kernel and no network.
|
||||
const http = require("node:http");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const fs = require("node:fs");
|
||||
const { app, BrowserWindow } = require("electron");
|
||||
const { BrowserViews } = require("../src/browserviews");
|
||||
|
||||
// Nothing this test writes may land in the Studio's own profile: a run that
|
||||
// picked a password field must not leave that page cached in anyone's session.
|
||||
app.setPath("userData", fs.mkdtempSync(path.join(os.tmpdir(), "rx-pick-live-")));
|
||||
|
||||
const wait = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
const fails = [];
|
||||
const check = (name, ok, got) => {
|
||||
console.log((ok ? "ok " : "FAIL ") + name + (ok ? "" : " — " + JSON.stringify(got)));
|
||||
if (!ok) fails.push(name);
|
||||
};
|
||||
|
||||
const GIF = "data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7";
|
||||
const PAGE = `<!doctype html><html><head><meta charset="utf-8"><title>Pick lab</title>
|
||||
<style>
|
||||
body { margin: 0; font: 16px sans-serif; }
|
||||
#hero { position: absolute; left: 40px; top: 60px; width: 300px; height: 120px; background: #c0392b; color: #fff; }
|
||||
#logo { width: 40px; height: 20px; margin: 10px; }
|
||||
#go { position: absolute; left: 40px; top: 220px; }
|
||||
#pw { position: absolute; left: 40px; top: 270px; }
|
||||
</style></head><body>
|
||||
<div id="hero">Hero <img id="logo" alt="logo" src="${GIF}"></div>
|
||||
<button id="go" class="btn__a1b2c3" data-testid="go-button" aria-label="投放" onclick="window.__clicked = true">Go</button>
|
||||
<label>Password <input id="pw" type="password" value="secret-value"></label>
|
||||
<script>window.__enter = false; addEventListener("keydown", (event) => { if (event.key === "Enter") window.__enter = true; });</script>
|
||||
<h1 id="head" style="position:absolute;left:40px;top:340px">A heading long enough to carry its own words and ask for no page context around it</h1>
|
||||
<span id="dot" style="position:absolute;left:40px;top:420px;width:7px;height:7px;background:#2ecc71"></span>
|
||||
<div id="badge" class="badge__x9y8z7" aria-label="状态徽标" style="position:absolute;left:40px;top:470px">ok</div>
|
||||
<em class="em__a1b2c3" style="position:absolute;left:40px;top:520px">hash</em>
|
||||
<iframe id="frame" srcdoc="<p>inside</p>" style="position:absolute;left:40px;top:570px;width:120px;height:60px"></iframe>
|
||||
<div id="shadow" style="position:absolute;left:200px;top:570px;width:80px;height:40px"></div>
|
||||
<script>document.getElementById("shadow").attachShadow({ mode: "open" }).innerHTML = "<span>inside shadow</span>";</script>
|
||||
</body></html>`;
|
||||
|
||||
app.whenReady().then(async () => {
|
||||
const server = http.createServer((_q, s) => {
|
||||
s.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
||||
s.end(PAGE);
|
||||
});
|
||||
await new Promise((r) => server.listen(0, "127.0.0.1", r));
|
||||
const origin = `http://127.0.0.1:${server.address().port}/`;
|
||||
|
||||
const win = new BrowserWindow({ width: 1400, height: 1000, show: true, webPreferences: { sandbox: true } });
|
||||
await win.loadURL("data:text/html,<body style='margin:0'><p>studio page</p></body>");
|
||||
const views = new BrowserViews({ win, kernelOrigin: "http://127.0.0.1:1" });
|
||||
let loaded;
|
||||
const ready = new Promise((r) => (loaded = r));
|
||||
const page = views.create({
|
||||
partition: "pick-live",
|
||||
url: origin,
|
||||
onEvent: (method) => method === "Page.loadEventFired" && loaded(),
|
||||
onClosed() {},
|
||||
onPopup() {},
|
||||
onDownload() {},
|
||||
});
|
||||
await page.send("Page.enable");
|
||||
await Promise.race([ready, wait(4000)]);
|
||||
const view = views.entries.get(page.targetId).view;
|
||||
view.webContents.on("console-message", (_event, _level, message) => console.log("page console:", message));
|
||||
views.show(page.targetId, { x: 0, y: 0, width: 1280, height: 900 });
|
||||
await wait(600);
|
||||
// Input only reaches a window the platform considers live; a background
|
||||
// window drops the pressed half of a click while still delivering moves.
|
||||
win.show();
|
||||
win.focus();
|
||||
await wait(300);
|
||||
view.webContents.focus();
|
||||
|
||||
const evalIn = (expression) => page.send("Runtime.evaluate", { expression, returnByValue: true }).then((r) => r.result.value);
|
||||
const at = (id) =>
|
||||
evalIn(`(() => { const b = document.getElementById(${JSON.stringify(id)}).getBoundingClientRect();
|
||||
return { x: b.x + b.width / 2, y: b.y + b.height / 2, width: b.width, height: b.height }; })()`);
|
||||
const atSel = (selector) =>
|
||||
evalIn(`(() => { const b = document.querySelector(${JSON.stringify(selector)}).getBoundingClientRect();
|
||||
return { x: b.x + b.width / 2, y: b.y + b.height / 2, width: b.width, height: b.height }; })()`);
|
||||
const move = (point) => page.send("Input.dispatchMouseEvent", { type: "mouseMoved", x: point.x, y: point.y, button: "none", clickCount: 0 });
|
||||
const click = async (point) => {
|
||||
await move(point);
|
||||
// Let the hover settle before the press: a new pick arms a frame later.
|
||||
await wait(80);
|
||||
// A native input event rather than a CDP press: the page half is what this
|
||||
// guards, and the window's own input path is what a person's click takes.
|
||||
const x = Math.round(point.x);
|
||||
const y = Math.round(point.y);
|
||||
view.webContents.sendInputEvent({ type: "mouseDown", x, y, button: "left", clickCount: 1 });
|
||||
view.webContents.sendInputEvent({ type: "mouseUp", x, y, button: "left", clickCount: 1 });
|
||||
};
|
||||
const overlays = () =>
|
||||
evalIn(`[...document.querySelectorAll('[data-reasonix-pick="box"], [data-reasonix-pick="card"]')].map((el) => ({
|
||||
kind: el.dataset.reasonixPick, text: el.innerText, shown: getComputedStyle(el).display !== "none" }))`);
|
||||
|
||||
await evalIn("window.__clicked = false");
|
||||
|
||||
// 1. A person points at the image: the highlight and the card are drawn in
|
||||
// the page, and what comes back is the element and where it sits.
|
||||
const picking = views.pick(page.targetId);
|
||||
await wait(200);
|
||||
const logo = await at("logo");
|
||||
await move(logo);
|
||||
await wait(150);
|
||||
const drawn = await overlays();
|
||||
check("hovering draws the highlight and the card inside the page", drawn.length === 2 && drawn.every((one) => one.shown), drawn);
|
||||
check(
|
||||
"the card names the colour and the font, and adds no background for a transparent one",
|
||||
/Color/.test(drawn[1].text) && /Font/.test(drawn[1].text) && !/Background/.test(drawn[1].text),
|
||||
drawn[1].text,
|
||||
);
|
||||
await click(logo);
|
||||
const picked = await picking;
|
||||
check("a click answers with the element under the pointer", picked.cancelled === false && picked.element.tagName === "img", picked);
|
||||
check("the element comes with a picture of itself", /^data:image\/png/.test(picked.shot || ""), (picked.shot || "").slice(0, 24));
|
||||
check("the selector names it again", picked.element.selector === "#logo", picked.element.selector);
|
||||
check(
|
||||
"the box is the one the page laid out",
|
||||
Math.abs(picked.element.rect.width - logo.width) <= 1 && Math.abs(picked.element.rect.height - logo.height) <= 1,
|
||||
{ rect: picked.element.rect, logo },
|
||||
);
|
||||
check("it carries the page it came from", picked.element.pageUrl === origin, picked.element.pageUrl);
|
||||
check("an element with little text of its own gets its container's", picked.element.nearbyText === "Hero", picked.element.nearbyText);
|
||||
check("the card is put away once the pick settles", (await overlays()).length === 0, await overlays());
|
||||
|
||||
// 2. A pick is not a click for the page: the button's own handler never runs.
|
||||
const buttonPick = views.pick(page.targetId);
|
||||
await wait(200);
|
||||
await click(await at("go"));
|
||||
const button = await buttonPick;
|
||||
check("the page's own click handler does not run", (await evalIn("window.__clicked")) === false, await evalIn("window.__clicked"));
|
||||
check(
|
||||
"a button says what it is and what it is called",
|
||||
button.element.tagName === "button" && button.element.role === "button" && button.element.accessibleName === "投放",
|
||||
button.element,
|
||||
);
|
||||
check("a button with its own word asks for no context", button.element.nearbyText === undefined, button.element.nearbyText);
|
||||
|
||||
// 3. What a person typed into a password field never leaves the page.
|
||||
const passwordPick = views.pick(page.targetId);
|
||||
await wait(200);
|
||||
await click(await at("pw"));
|
||||
const password = await passwordPick;
|
||||
check("a password field answers with a placeholder, not its value", password.element.text === "[masked password input]", password.element.text);
|
||||
check("neither the markup nor the attributes carry what was typed", !JSON.stringify(password.element).includes("secret-value"), password.element.htmlExcerpt);
|
||||
|
||||
// 4. A heading says what it is already: the nearest article around it is the
|
||||
// whole page, and that must not be handed over as context.
|
||||
const headingPick = views.pick(page.targetId);
|
||||
await wait(200);
|
||||
await click(await at("head"));
|
||||
const heading = await headingPick;
|
||||
check("a heading carries no page-wide context", heading.element.nearbyText === undefined, heading.element.nearbyText);
|
||||
|
||||
// 5. A decorative mark has no words and no role: nothing around it makes it
|
||||
// meaningful, so no context comes along either, and the fields that have no
|
||||
// answer are left out instead of sent empty.
|
||||
const dotPick = views.pick(page.targetId);
|
||||
await wait(200);
|
||||
await click(await at("dot"));
|
||||
const dot = await dotPick;
|
||||
check(
|
||||
"a decorative mark carries no context and no empty fields",
|
||||
dot.element.nearbyText === undefined && dot.element.role === undefined && dot.element.text === undefined,
|
||||
dot.element,
|
||||
);
|
||||
|
||||
// 6. The page's own identity wins over its styling: a hash class is gone
|
||||
// after the next deploy, a data-testid is not.
|
||||
const identity = views.pick(page.targetId);
|
||||
await wait(200);
|
||||
await click(await at("go"));
|
||||
const byTestId = await identity;
|
||||
check(
|
||||
"a data-testid is the selector when the page has one",
|
||||
byTestId.element.selector === '[data-testid="go-button"]' && byTestId.element.selectorBasis === "testid",
|
||||
byTestId.element,
|
||||
);
|
||||
check("the hash class behind it is not used", !byTestId.element.selector.includes("btn__a1b2c3"), byTestId.element.selector);
|
||||
|
||||
const labelled = views.pick(page.targetId);
|
||||
await wait(200);
|
||||
await click(await at("badge"));
|
||||
const byLabel = await labelled;
|
||||
check(
|
||||
"an aria-label is the next choice",
|
||||
byLabel.element.selector === 'div[aria-label="状态徽标"]' && byLabel.element.selectorBasis === "aria-label",
|
||||
byLabel.element,
|
||||
);
|
||||
|
||||
const styled = views.pick(page.targetId);
|
||||
await wait(200);
|
||||
await click(await atSel(".em__a1b2c3"));
|
||||
const byClass = await styled;
|
||||
check(
|
||||
"with neither, the styling class is used and labelled as such",
|
||||
byClass.element.selectorBasis === "class" && byClass.element.selector.includes("em__a1b2c3"),
|
||||
byClass.element,
|
||||
);
|
||||
check(
|
||||
"and it still finds the element",
|
||||
(await evalIn(`document.querySelector(${JSON.stringify(byClass.element.selector)})?.tagName`)) === "EM",
|
||||
byClass.element.selector,
|
||||
);
|
||||
|
||||
// 7. Stepping up with the arrow key: what the card shows and what comes back
|
||||
// are the same level.
|
||||
const climbing = views.pick(page.targetId);
|
||||
await wait(200);
|
||||
const logoPoint = await at("logo");
|
||||
await move(logoPoint);
|
||||
await wait(100);
|
||||
const boxWidth = () => evalIn(`parseFloat(document.querySelector('[data-reasonix-pick="box"]').style.width)`);
|
||||
check("the pointer starts on the image", (await boxWidth()) === 40, await boxWidth());
|
||||
view.webContents.sendInputEvent({ type: "keyDown", keyCode: "Up" });
|
||||
await wait(120);
|
||||
view.webContents.sendInputEvent({ type: "keyUp", keyCode: "Up" });
|
||||
check("one step up is the hero", (await boxWidth()) === 300, await boxWidth());
|
||||
const chainText = await evalIn(`document.querySelector('[data-reasonix-pick="card"]').innerText`);
|
||||
check("the card shows the chain it climbed", /div#hero/.test(chainText) && !/img/.test(chainText), chainText);
|
||||
// Pressed without moving: a mousemove would put the pointer back on the image.
|
||||
const climbX = Math.round(logoPoint.x);
|
||||
const climbY = Math.round(logoPoint.y);
|
||||
view.webContents.sendInputEvent({ type: "mouseDown", x: climbX, y: climbY, button: "left", clickCount: 1 });
|
||||
view.webContents.sendInputEvent({ type: "mouseUp", x: climbX, y: climbY, button: "left", clickCount: 1 });
|
||||
const climbed = await climbing;
|
||||
check("the answer is the level stepped up to", climbed.element.tagName === "div" && climbed.element.selector === "#hero", climbed.element);
|
||||
|
||||
// 9. A frame or a shadow host is named, not guessed at.
|
||||
const framePick = views.pick(page.targetId);
|
||||
await wait(200);
|
||||
await move(await at("frame"));
|
||||
await wait(100);
|
||||
const frameNote = await evalIn(`document.querySelector('[data-reasonix-pick="card"]').innerText`);
|
||||
check("an embedded frame says why it cannot be picked", /embedded frame/.test(frameNote), frameNote);
|
||||
await click(await at("frame"));
|
||||
await wait(300);
|
||||
const stillArmed = await evalIn(`!!document.querySelector('[data-reasonix-pick="box"]')`);
|
||||
check("clicking the frame leaves the pick armed", stillArmed === true, stillArmed);
|
||||
void views.cancelPick(page.targetId);
|
||||
const frameAnswer = await framePick;
|
||||
check("and the frame is not reported as an element", frameAnswer.cancelled === true, frameAnswer);
|
||||
|
||||
const shadowPick = views.pick(page.targetId);
|
||||
await wait(200);
|
||||
await move(await at("shadow"));
|
||||
await wait(100);
|
||||
const shadowNote = await evalIn(`document.querySelector('[data-reasonix-pick="card"]').innerText`);
|
||||
check("a shadow host says why it cannot be picked", /shadow root/.test(shadowNote), shadowNote);
|
||||
void views.cancelPick(page.targetId);
|
||||
await shadowPick;
|
||||
|
||||
// 10. A second pick on the same page replaces the first instead of stacking.
|
||||
const firstPick = views.pick(page.targetId);
|
||||
await wait(200);
|
||||
const secondPick = views.pick(page.targetId);
|
||||
await wait(300);
|
||||
const draws = await evalIn(
|
||||
`document.querySelectorAll('[data-reasonix-pick="box"]').length + "/" + document.querySelectorAll('[data-reasonix-pick="card"]').length`,
|
||||
);
|
||||
check("a second pick leaves one picker on the page", draws === "1/1", draws);
|
||||
const firstAnswer = await firstPick;
|
||||
check("the first pick answers as cancelled", firstAnswer.cancelled === true, firstAnswer);
|
||||
void views.cancelPick(page.targetId);
|
||||
await secondPick;
|
||||
|
||||
// 11. Shift-click keeps a set: the click that ends it carries them all.
|
||||
const multi = views.pick(page.targetId);
|
||||
await wait(200);
|
||||
const goAt = await at("go");
|
||||
await move(goAt);
|
||||
await wait(100);
|
||||
view.webContents.sendInputEvent({ type: "mouseDown", x: Math.round(goAt.x), y: Math.round(goAt.y), button: "left", clickCount: 1, modifiers: ["shift"] });
|
||||
view.webContents.sendInputEvent({ type: "mouseUp", x: Math.round(goAt.x), y: Math.round(goAt.y), button: "left", clickCount: 1, modifiers: ["shift"] });
|
||||
await wait(150);
|
||||
const marked = await evalIn(`document.querySelectorAll("[data-reasonix-picked]").length`);
|
||||
check("shift-click keeps the element marked", marked === 1, marked);
|
||||
await click(await at("badge"));
|
||||
const many = await multi;
|
||||
check("the set comes back as several elements", many.elements?.length === 2, many.elements?.map((e) => e.tagName));
|
||||
|
||||
// 12. Verifying the selector after the page changed.
|
||||
const still = await views.verify(page.targetId, "#logo");
|
||||
check("a selector that still exists comes back with a fresh picture", still.found === true && /^data:image\/png/.test(still.shot || ""), still);
|
||||
const gone = await views.verify(page.targetId, "#nope");
|
||||
check("a selector that is gone says so", gone.found === false, gone);
|
||||
|
||||
// 8. The keyboard is the picker's too while it is armed: Enter must not press
|
||||
// a focused button, and Escape is the one key that ends the pick — saying so.
|
||||
const escaping = views.pick(page.targetId);
|
||||
await wait(200);
|
||||
await page.send("Input.dispatchKeyEvent", { type: "keyDown", key: "Enter", code: "Enter", windowsVirtualKeyCode: 13, nativeVirtualKeyCode: 13 });
|
||||
await page.send("Input.dispatchKeyEvent", { type: "keyUp", key: "Enter", code: "Enter", windowsVirtualKeyCode: 13, nativeVirtualKeyCode: 13 });
|
||||
check("Enter does not reach the page", (await evalIn("window.__enter")) === false, await evalIn("window.__enter"));
|
||||
await page.send("Input.dispatchKeyEvent", { type: "keyDown", key: "Escape", code: "Escape", windowsVirtualKeyCode: 27, nativeVirtualKeyCode: 27 });
|
||||
const escaped = await escaping;
|
||||
check("Escape cancels the pick, and says why", escaped.cancelled === true && escaped.reason === "escape", escaped);
|
||||
check("and leaves nothing drawn", (await overlays()).length === 0);
|
||||
|
||||
// 7. A page put away under the picker ends the pick instead of swallowing
|
||||
// the person's clicks on a picture they cannot reach.
|
||||
const hiding = views.pick(page.targetId);
|
||||
await wait(200);
|
||||
views.hide();
|
||||
const hidden = await hiding;
|
||||
check("a page put away cancels the pick, and says why", hidden.cancelled === true && hidden.reason === "hidden", hidden);
|
||||
|
||||
views.hide();
|
||||
page.close();
|
||||
server.close();
|
||||
console.log(fails.length ? `${fails.length} failed` : "all checks passed");
|
||||
app.exit(fails.length ? 1 : 0);
|
||||
});
|
||||
@@ -0,0 +1,150 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { createRequire } from "node:module";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
const require = createRequire(import.meta.url);
|
||||
const { createGlobalShortcut, registerGlobalShortcutIPC, PREF_KEY } = require("../src/globalshortcut");
|
||||
const { savePrefs, loadPrefs, registerPrefs } = require("../src/prefs");
|
||||
|
||||
function fixture(binding = "") {
|
||||
const registered = new Map();
|
||||
const saved = [];
|
||||
const win = { visible: true, focused: true, minimized: false, destroyed: false,
|
||||
isVisible() { return this.visible; }, isFocused() { return this.focused; },
|
||||
isMinimized() { return this.minimized; }, isDestroyed() { return this.destroyed; },
|
||||
hide() { this.visible = false; this.focused = false; },
|
||||
};
|
||||
let shown = 0;
|
||||
let failSave = false;
|
||||
const api = {
|
||||
register(key, callback) {
|
||||
if (key === "Control+invalid") throw new TypeError("invalid accelerator");
|
||||
if (key === "Control+occupied") return false;
|
||||
registered.set(key, callback); return true;
|
||||
},
|
||||
unregister(key) { registered.delete(key); },
|
||||
};
|
||||
const shortcut = createGlobalShortcut({ globalShortcut: api, binding,
|
||||
save(value) { if (failSave) throw new Error("disk full"); saved.push(value); },
|
||||
windowOf: () => win, showWindow() { shown++; win.visible = true; win.focused = true; win.minimized = false; },
|
||||
});
|
||||
return { shortcut, registered, saved, win, get shown() { return shown; }, failSave() { failSave = true; } };
|
||||
}
|
||||
|
||||
test("starts disabled without claiming an OS binding and restores a saved binding before any renderer", () => {
|
||||
const off = fixture();
|
||||
assert.deepEqual(off.shortcut.start(), { binding: "", status: "disabled" });
|
||||
assert.equal(off.registered.size, 0);
|
||||
const on = fixture("Control+Space");
|
||||
assert.deepEqual(on.shortcut.start(), { binding: "Control+Space", status: "registered" });
|
||||
assert.equal(on.registered.size, 1);
|
||||
assert.deepEqual(on.saved, []);
|
||||
});
|
||||
|
||||
test("bare keys and Shift-only chords cannot claim machine-wide input through apply or restored preferences", () => {
|
||||
for (const binding of ["C", "Space", "F9", "Plus", " Shift ", "Shift+A", "Shift+Space"]) {
|
||||
const restored = fixture(binding);
|
||||
assert.deepEqual(restored.shortcut.start(), { binding, status: "invalid" });
|
||||
assert.equal(restored.registered.size, 0);
|
||||
const f = fixture("Control+Space"); f.shortcut.start();
|
||||
assert.deepEqual(f.shortcut.apply(binding), { binding: "Control+Space", status: "registered", error: "invalid" });
|
||||
assert.deepEqual([...f.registered.keys()], ["Control+Space"]);
|
||||
assert.deepEqual(f.saved, []);
|
||||
}
|
||||
for (const modifier of ["CommandOrControl", "CmdOrCtrl", "Command", "Cmd", "Control", "Ctrl", "Alt", "Option", "AltGr", "Super", "Meta"]) {
|
||||
const f = fixture();
|
||||
assert.equal(f.shortcut.apply(`${modifier}+Space`).status, "registered");
|
||||
assert.equal(f.shortcut.apply(`${modifier}+Shift+Space`).status, "registered");
|
||||
}
|
||||
});
|
||||
|
||||
test("focused hides; unfocused, hidden and minimized show through the existing path", () => {
|
||||
for (const changed of [{}, { focused: false }, { visible: false, focused: false }, { minimized: true }]) {
|
||||
const f = fixture("Control+Space"); f.shortcut.start(); Object.assign(f.win, changed);
|
||||
f.registered.get("Control+Space")();
|
||||
assert.equal(f.shown, Object.keys(changed).length ? 1 : 0);
|
||||
assert.equal(f.win.visible, Object.keys(changed).length > 0);
|
||||
}
|
||||
});
|
||||
|
||||
test("failed rebind preserves both registration and saved choice, successful replacement removes only the old binding", () => {
|
||||
const f = fixture("Control+Space"); f.shortcut.start();
|
||||
for (const [binding, error] of [["Control+invalid", "invalid"], ["Control+occupied", "unavailable"], [42, "invalid"]]) {
|
||||
assert.deepEqual(f.shortcut.apply(binding), { binding: "Control+Space", status: "registered", error });
|
||||
assert.deepEqual([...f.registered.keys()], ["Control+Space"]);
|
||||
assert.deepEqual(f.saved, []);
|
||||
}
|
||||
assert.deepEqual(f.shortcut.apply(" Control+Shift+Space "), { binding: "Control+Shift+Space", status: "registered" });
|
||||
assert.deepEqual([...f.registered.keys()], ["Control+Shift+Space"]);
|
||||
assert.deepEqual(f.saved, ["Control+Shift+Space"]);
|
||||
f.shortcut.apply("");
|
||||
assert.equal(f.registered.size, 0);
|
||||
assert.deepEqual(f.saved, ["Control+Shift+Space", ""]);
|
||||
});
|
||||
|
||||
test("disk failure rolls back the prospective registration, including disabling", () => {
|
||||
const f = fixture("Control+Space"); f.shortcut.start(); f.failSave();
|
||||
for (const value of ["Control+Shift+Space", ""]) {
|
||||
assert.deepEqual(f.shortcut.apply(value), { binding: "Control+Space", status: "registered", error: "save" });
|
||||
assert.deepEqual([...f.registered.keys()], ["Control+Space"]);
|
||||
}
|
||||
});
|
||||
|
||||
test("startup failure is honest and may be replaced or disabled", () => {
|
||||
for (const [binding, status] of [["Control+invalid", "invalid"], ["Control+occupied", "unavailable"]]) {
|
||||
const f = fixture(binding);
|
||||
assert.deepEqual(f.shortcut.start(), { binding, status });
|
||||
assert.equal(f.registered.size, 0);
|
||||
assert.deepEqual(f.shortcut.apply(""), { binding: "", status: "disabled" });
|
||||
}
|
||||
});
|
||||
|
||||
test("quit unregisters its binding, suppresses queued presses and refuses new registration", () => {
|
||||
const f = fixture("Control+Space"); f.shortcut.start();
|
||||
const queued = f.registered.get("Control+Space");
|
||||
f.shortcut.close(); f.shortcut.close(); queued();
|
||||
assert.equal(f.registered.size, 0); assert.equal(f.shown, 0); assert.equal(f.win.visible, true);
|
||||
assert.equal(f.shortcut.apply("Control+Shift+Space").error, "quitting");
|
||||
});
|
||||
|
||||
test("only the authorized Studio main frame can read or immediately apply a shortcut", () => {
|
||||
const f = fixture(); f.shortcut.start();
|
||||
const handlers = new Map(); const sender = { mainFrame: {} };
|
||||
registerGlobalShortcutIPC({ handle: (key, fn) => handlers.set(key, fn) }, event => event.sender === sender, () => f.shortcut);
|
||||
for (const event of [{ sender: { mainFrame: {} }, senderFrame: {} }, { sender, senderFrame: {} }]) {
|
||||
assert.equal(handlers.get("shell:global-shortcut")(event), null);
|
||||
assert.equal(handlers.get("shell:set-global-shortcut")(event, "Control+Space"), null);
|
||||
}
|
||||
assert.equal(f.registered.size, 0);
|
||||
const event = { sender, senderFrame: sender.mainFrame };
|
||||
assert.equal(handlers.get("shell:set-global-shortcut")(event, "Control+Space").status, "registered");
|
||||
assert.equal(handlers.get("shell:global-shortcut")(event).binding, "Control+Space");
|
||||
assert.deepEqual(handlers.get("shell:set-global-shortcut")(event, " ".repeat(128) + "Control+Space"), { binding: "Control+Space", status: "registered", error: "invalid" });
|
||||
assert.deepEqual([...f.registered.keys()], ["Control+Space"]);
|
||||
assert.deepEqual(f.saved, ["Control+Space"]);
|
||||
});
|
||||
|
||||
test("page preference flushing cannot overwrite the shell-owned saved binding", t => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rx-shortcut-"));
|
||||
t.after(() => fs.rmSync(dir, { recursive: true, force: true }));
|
||||
const file = path.join(dir, "window-prefs.json");
|
||||
savePrefs(file, { [PREF_KEY]: "Control+Space", "rx-lang": "en" });
|
||||
const handlers = {};
|
||||
registerPrefs({ on: (key, fn) => handlers[key] = fn }, () => file, () => true);
|
||||
const event = {};
|
||||
handlers["prefs:load"](event);
|
||||
assert.deepEqual(event.returnValue, { "rx-lang": "en" });
|
||||
handlers["prefs:save"](event, { "rx-lang": "zh", [PREF_KEY]: "occupied" });
|
||||
assert.deepEqual(loadPrefs(file), { "rx-lang": "zh", [PREF_KEY]: "Control+Space" });
|
||||
});
|
||||
|
||||
test("oversized restored preferences never reach native registration", () => {
|
||||
const binding = "Control+" + "x".repeat(128);
|
||||
const f = fixture(binding);
|
||||
assert.deepEqual(f.shortcut.start(), { binding, status: "invalid" });
|
||||
assert.equal(f.registered.size, 0);
|
||||
assert.deepEqual(f.saved, []);
|
||||
assert.deepEqual(f.shortcut.apply(""), { binding: "", status: "disabled" });
|
||||
});
|
||||
@@ -0,0 +1,115 @@
|
||||
"use strict";
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const { execFileSync, spawn } = require("node:child_process");
|
||||
const { app, globalShortcut } = require("electron");
|
||||
const { wait } = require("./livekit");
|
||||
|
||||
const home = process.env.REASONIX_HOME;
|
||||
if (!home) throw new Error("Use an isolated REASONIX_HOME for the native shortcut smoke");
|
||||
fs.mkdirSync(home, { recursive: true });
|
||||
fs.writeFileSync(path.join(home, "config.toml"), 'default_model = "smoke/smoke-model"\n[desktop]\nwelcomed = true\n[[providers]]\nname = "smoke"\nkind = "openai"\nbase_url = "http://127.0.0.1:9/v1"\nmodels = ["smoke-model"]\ndefault = "smoke-model"\napi_key_env = "SMOKE_API_KEY"\n');
|
||||
fs.writeFileSync(path.join(home, ".env"), "SMOKE_API_KEY=smoke\n");
|
||||
const { current } = require("../src/main");
|
||||
const input = process.env.REASONIX_XDOTOOL || "xdotool";
|
||||
const binding = "Control+Shift+F9";
|
||||
let neighbour;
|
||||
|
||||
async function until(read, description) {
|
||||
const end = Date.now() + 40000;
|
||||
while (Date.now() < end) {
|
||||
if (await read()) return;
|
||||
await wait(100);
|
||||
}
|
||||
throw new Error(`Timed out: ${description}`);
|
||||
}
|
||||
|
||||
async function run() {
|
||||
await until(() => current().win && !current().win.webContents.isLoading(), "Studio window");
|
||||
const win = current().win;
|
||||
const js = source => win.webContents.executeJavaScript(source);
|
||||
await until(() => js('!!document.querySelector(".chrome")'), "real Studio workbench");
|
||||
await until(() => js('!document.getElementById("boot")'), "startup overlay finishes");
|
||||
assert.equal(await js('window.reasonixHost.globalShortcut().then(value => value.status)'), "disabled");
|
||||
await js('document.querySelector(\'[data-action="chrome.settings"]\').click()');
|
||||
await until(() => js('!!document.querySelector(\'[data-action="settings.section"][data-value="appearance"]\')'), "settings navigation");
|
||||
await js('document.querySelector(\'[data-action="settings.section"][data-value="appearance"]\').click()');
|
||||
await until(() => js('!!document.querySelector("#set-global-shortcut input:not(:disabled)")'), "shortcut settings");
|
||||
const out = process.env.REASONIX_SCREENSHOTS;
|
||||
async function screenshots(stage) {
|
||||
if (!out) return;
|
||||
fs.mkdirSync(out, { recursive: true });
|
||||
for (const width of [1280, 390]) {
|
||||
win.setSize(1280, 1000);
|
||||
win.webContents.enableDeviceEmulation({ screenPosition: "desktop", screenSize: { width, height: 1000 }, viewSize: { width, height: 1000 }, deviceScaleFactor: 1, scale: 1 });
|
||||
for (const mode of ["light", "dark"]) {
|
||||
await js(`document.querySelector('[data-action="appearance.scheme"][data-value="${mode}"]').click()`);
|
||||
await js('document.querySelector("#set-global-shortcut").scrollIntoView({block:"center"})');
|
||||
await wait(350);
|
||||
const png = await win.webContents.capturePage({ x: 0, y: 0, width, height: 1000 });
|
||||
fs.writeFileSync(path.join(out, `${stage}-${mode}-${width}.png`), png.toPNG());
|
||||
}
|
||||
}
|
||||
win.webContents.disableDeviceEmulation();
|
||||
}
|
||||
await screenshots("before-disabled");
|
||||
await js('document.querySelector(\'button[data-action="window.global-shortcut"][data-value="record"]\').click()');
|
||||
win.webContents.sendInputEvent({ type: "keyDown", keyCode: "F9", modifiers: ["control", "shift"] });
|
||||
win.webContents.sendInputEvent({ type: "keyUp", keyCode: "F9", modifiers: ["control", "shift"] });
|
||||
await until(() => js(`document.querySelector("#set-global-shortcut input").value === "${binding}"`), "captured modifier chord");
|
||||
await js('document.querySelector(\'button[data-action="window.global-shortcut"][data-value="apply"]\').click()');
|
||||
await until(() => js('window.reasonixHost.globalShortcut().then(value => value.status === "registered")'), "real OS registration");
|
||||
assert.equal(globalShortcut.isRegistered(binding), true);
|
||||
console.log(`ok: real Studio settings capture/apply registered the native ${process.platform} binding`);
|
||||
await screenshots("after-registered");
|
||||
if (process.platform === "darwin") {
|
||||
execFileSync("osascript", ["-e", `tell application "System Events" to set frontmost of first application process whose unix id is ${process.pid} to true`], { timeout: 10000 });
|
||||
}
|
||||
win.show(); win.focus();
|
||||
await until(() => win.isFocused(), "focused Studio window");
|
||||
const press = () => process.platform === "darwin"
|
||||
? execFileSync("osascript", ["-e", 'tell application "System Events" to key code 101 using {control down, shift down}'], { timeout: 10000 })
|
||||
: execFileSync(input, ["key", "--clearmodifiers", "ctrl+shift+F9"]);
|
||||
press();
|
||||
await until(() => !win.isVisible(), "OS key hides focused Studio");
|
||||
press();
|
||||
await until(() => win.isVisible() && win.isFocused(), "OS key restores hidden Studio");
|
||||
console.log("ok: real OS key hides the focused window and restores it from hidden");
|
||||
win.minimize();
|
||||
await until(() => win.isMinimized(), "minimized Studio");
|
||||
press();
|
||||
await until(() => !win.isMinimized() && win.isVisible() && win.isFocused(), "OS key restores minimized Studio");
|
||||
console.log("ok: real OS key restores and focuses the minimized window");
|
||||
if (process.env.REASONIX_XMESSAGE) {
|
||||
neighbour = spawn(process.env.REASONIX_XMESSAGE, ["-title", "Reasonix shortcut smoke neighbour", "-buttons", "OK", "Separate native application"], { stdio: "ignore" });
|
||||
await until(() => {
|
||||
try { return execFileSync(input, ["search", "--name", "Reasonix shortcut smoke neighbour"]).toString().trim(); } catch { return false; }
|
||||
}, "external application window");
|
||||
const id = execFileSync(input, ["search", "--name", "Reasonix shortcut smoke neighbour"]).toString().trim().split("\n")[0];
|
||||
execFileSync(input, ["windowfocus", "--sync", id]);
|
||||
await until(() => !win.isFocused() && win.isVisible(), "external application takes focus");
|
||||
press();
|
||||
await until(() => win.isFocused() && win.isVisible(), "OS key raises Studio from another app");
|
||||
console.log("ok: real OS key raises visible, unfocused Studio while a separate native application has focus");
|
||||
neighbour.kill();
|
||||
}
|
||||
assert.equal(globalShortcut.register("Control+Shift+F10", () => {}), true);
|
||||
const failed = await js('window.reasonixHost.setGlobalShortcut("Control+Shift+F10")');
|
||||
assert.deepEqual(failed, { binding, status: "registered", error: "unavailable" });
|
||||
globalShortcut.unregister("Control+Shift+F10");
|
||||
const saved = JSON.parse(fs.readFileSync(path.join(app.getPath("userData"), "window-prefs.json"), "utf8"));
|
||||
assert.equal(saved["shell-global-shortcut"], binding);
|
||||
assert.equal((await js('window.reasonixHost.setGlobalShortcut("C")')).error, "invalid");
|
||||
assert.equal(globalShortcut.isRegistered("C"), false);
|
||||
assert.equal((await js('window.reasonixHost.setGlobalShortcut("invalid shortcut value")')).error, "invalid");
|
||||
assert.equal((await js('window.reasonixHost.setGlobalShortcut("")')).status, "disabled");
|
||||
assert.equal(globalShortcut.isRegistered(binding), false);
|
||||
console.log("ok: native conflict/invalid entries preserve prior binding and disabling unregisters it");
|
||||
}
|
||||
|
||||
run().then(() => { app.quit(); }).catch(error => {
|
||||
neighbour?.kill();
|
||||
console.error(error.stack || error);
|
||||
app.exit(1);
|
||||
});
|
||||
@@ -36,33 +36,32 @@ async function main() {
|
||||
await client.request("POST", `${base}/submit`, { input: "open the page" });
|
||||
const guest = await until("a page view", async () => guests()[0], 60000);
|
||||
await until("the page beside the conversation", async () => (guest.getBounds().x >= 0 ? true : null), 30000);
|
||||
await until("the wide control", () => js(`!!document.querySelector('[data-action="browser.wide"]')`));
|
||||
await until("the full-view control", () => js(`!!document.querySelector('[data-action="browser.full"]')`));
|
||||
|
||||
const panelRect = () => js(`(() => { const e = document.querySelector('.sidepanel'); if (!e) return null; const b = e.getBoundingClientRect(); return { x: b.left, y: b.top, width: b.width, height: b.height }; })()`);
|
||||
const covers = (view, rect) => view.x < rect.x + rect.width && view.x + view.width > rect.x && view.y < rect.y + rect.height && view.y + view.height > rect.y;
|
||||
|
||||
for (const [width, height, wide] of [[1440, 900, false], [1440, 900, true], [1100, 760, false], [1100, 760, true], [900, 700, true], [700, 700, true], [1440, 900, "panel-first"], [1100, 760, "panel-first"]]) {
|
||||
for (const [width, height, full] of [[1440, 900, false], [1440, 900, true], [1100, 760, false], [1100, 760, true], [900, 700, true], [700, 700, true], [1440, 900, "panel-first"], [1100, 760, "panel-first"]]) {
|
||||
win.setContentSize(width, height);
|
||||
await wait(900);
|
||||
const label = `${width}px ${wide === "panel-first" ? "side question open, then the browser widened" : wide ? "widened" : "beside the conversation"}`;
|
||||
if (wide === true && (await js(`document.querySelector('.pane')?.getAttribute('data-browser-size')`)) !== "wide") {
|
||||
await js(`document.querySelector('[data-action="browser.wide"]')?.click()`);
|
||||
const label = `${width}px ${full === "panel-first" ? "side question open, then full view" : full ? "full view" : "beside the conversation"}`;
|
||||
if (full === true && (await js(`document.querySelector('.pane')?.getAttribute('data-browser-size')`)) !== "full") {
|
||||
await js(`document.querySelector('[data-action="browser.full"]')?.click()`);
|
||||
await wait(1200);
|
||||
}
|
||||
if (wide === false && (await js(`document.querySelector('.pane')?.getAttribute('data-browser-size')`)) === "wide") {
|
||||
await js(`document.querySelector('[data-action="browser.wide"]')?.click()`);
|
||||
if (full === false && (await js(`document.querySelector('.pane')?.getAttribute('data-browser-size')`)) === "full") {
|
||||
await js(`document.querySelector('[data-action="browser.full"]')?.click()`);
|
||||
await wait(1200);
|
||||
}
|
||||
if (wide === "panel-first") {
|
||||
// Back beside the conversation first, then the question, then the widening.
|
||||
if ((await js(`document.querySelector('.pane')?.getAttribute('data-browser-size')`)) === "wide") {
|
||||
await js(`document.querySelector('[data-action="browser.wide"]')?.click()`);
|
||||
if (full === "panel-first") {
|
||||
if ((await js(`document.querySelector('.pane')?.getAttribute('data-browser-size')`)) === "full") {
|
||||
await js(`document.querySelector('[data-action="browser.full"]')?.click()`);
|
||||
await wait(1000);
|
||||
}
|
||||
await js(`document.querySelector('[data-action="side.toggle"]').click()`);
|
||||
await until("the side panel", async () => (await panelRect()) || null, 10000);
|
||||
await wait(800);
|
||||
await js(`document.querySelector('[data-action="browser.wide"]')?.click()`);
|
||||
await js(`document.querySelector('[data-action="browser.full"]')?.click()`);
|
||||
await wait(1500);
|
||||
} else {
|
||||
await js(`document.querySelector('[data-action="side.toggle"]').click()`);
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
"use strict";
|
||||
// Drives page sleep in the real shell with no kernel: real Chromium renderers
|
||||
// through BrowserViews, a tiny sleep threshold, and the shell's own process
|
||||
// memory read around the sleep. A wake must answer a command against the page
|
||||
// as it loaded, not as it was. Run with `pnpm sleep-live`; it needs no network.
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const { app, BrowserWindow } = require("electron");
|
||||
const { BrowserViews } = require("../src/browserviews.js");
|
||||
|
||||
const PAGE = `<!doctype html><meta charset="utf-8"><title>heavy</title>
|
||||
<style>td{padding:4px 24px}</style>
|
||||
<table>${("<tr>" + "<td>payload</td>".repeat(40) + "</tr>").repeat(600)}</table>
|
||||
<script>let n=0; setInterval(() => { document.title = "tick " + (++n); }, 500);</script>`;
|
||||
|
||||
const PAGE2 = `<!doctype html><meta charset="utf-8"><title>second</title><p>the second page</p>`;
|
||||
|
||||
const mb = (k) => Math.round(k / 1024);
|
||||
const metrics = () =>
|
||||
app
|
||||
.getAppMetrics()
|
||||
.filter((m) => m.type === "Tab" || m.type === "Renderer")
|
||||
.reduce((acc, m) => ({ count: acc.count + 1, ws: acc.ws + m.memory.workingSetSize }), { count: 0, ws: 0 });
|
||||
const wait = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
async function until(what, fn, ms = 30000) {
|
||||
const end = Date.now() + ms;
|
||||
for (;;) {
|
||||
const got = await fn();
|
||||
if (got) return got;
|
||||
if (Date.now() > end) throw new Error(`timed out waiting for ${what}`);
|
||||
await wait(250);
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rx-sleep-probe-"));
|
||||
const file = path.join(dir, "page.html");
|
||||
fs.writeFileSync(file, PAGE);
|
||||
const file2 = path.join(dir, "page2.html");
|
||||
fs.writeFileSync(file2, PAGE2);
|
||||
|
||||
const win = new BrowserWindow({ show: false, width: 1280, height: 900 });
|
||||
await win.loadURL("about:blank");
|
||||
const views = new BrowserViews({ win, kernelOrigin: "http://127.0.0.1:1", sleepAfterMs: 8000, sweepMs: 2000, maxAwake: 6 });
|
||||
|
||||
// Every domain event each page's debugger session produces is kept, so the
|
||||
// probe can assert later that the events a wake's rebuilt session must
|
||||
// carry really flowed.
|
||||
const handles = [];
|
||||
const eventFrames = [];
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const events = [];
|
||||
eventFrames.push(events);
|
||||
handles.push(
|
||||
views.create({
|
||||
partition: `probe-${i}`,
|
||||
url: "file://" + file.replace(/\\/g, "/"),
|
||||
onEvent: (method, params) => events.push({ method, params }),
|
||||
onClosed: () => {},
|
||||
onPopup: () => {},
|
||||
onDownload: () => {},
|
||||
}),
|
||||
);
|
||||
}
|
||||
await until("pages to load and their renderers to settle", async () => {
|
||||
await wait(1000);
|
||||
return metrics().count >= 5;
|
||||
});
|
||||
await wait(1500);
|
||||
// What the kernel says once at attach, said to page 3 before it dozes: the
|
||||
// wake must say them again to the session it builds, or no domain event
|
||||
// reaches the page after.
|
||||
for (const [method, params] of [
|
||||
["Page.enable", {}],
|
||||
["Runtime.enable", {}],
|
||||
["Network.enable", {}],
|
||||
["Page.setLifecycleEventsEnabled", { enabled: true }],
|
||||
]) await handles[2].send(method, params);
|
||||
const before = metrics();
|
||||
console.log(`[probe] 5 pages live: ${before.count} renderer processes, ${mb(before.ws)} MB working set`);
|
||||
|
||||
await until("the sweep to sleep every page", async () => {
|
||||
await wait(1000);
|
||||
return handles.every((h) => views.asleep(h.targetId));
|
||||
});
|
||||
await wait(2500); // let the OS reap the dead processes
|
||||
const after = metrics();
|
||||
console.log(`[probe] after sleep: ${after.count} renderer processes, ${mb(after.ws)} MB working set`);
|
||||
console.log(`[probe] reclaimed: ${before.count - after.count} processes, ${mb(before.ws - after.ws)} MB`);
|
||||
|
||||
// A wake must answer a real command on a real page.
|
||||
const answer = await handles[2].send("Runtime.evaluate", { expression: "document.querySelectorAll('td').length", returnByValue: true });
|
||||
const cells = answer && answer.result && answer.result.value;
|
||||
const woken = metrics();
|
||||
console.log(`[probe] wake #3: Runtime.evaluate answered cells=${cells}; renderers now ${woken.count}, ${mb(woken.ws)} MB`);
|
||||
if (after.count >= before.count) throw new Error("sleep reclaimed no renderer processes");
|
||||
if (cells !== 40 * 600) throw new Error(`wake answered wrong value: ${cells}`);
|
||||
if (woken.count !== after.count + 1) throw new Error(`wake rebuilt ${woken.count - after.count} renderers, wanted 1`);
|
||||
|
||||
// Domain events must flow after a wake on a real rebuilt session: the
|
||||
// kernel's waitLoaded depends exactly on Page.frameNavigated arriving. The
|
||||
// enables said before the sleep were replayed to the session the wake built.
|
||||
await handles[2].send("Page.navigate", { url: "file://" + file2.replace(/\\/g, "/") });
|
||||
await until(
|
||||
"post-wake Page.frameNavigated for page2",
|
||||
() =>
|
||||
eventFrames[2].some(
|
||||
(f) => f.method === "Page.frameNavigated" && f.params && f.params.frame && typeof f.params.frame.url === "string" && f.params.frame.url.includes("page2"),
|
||||
),
|
||||
15000,
|
||||
);
|
||||
const titled = await until("page2's title to read back", async () => {
|
||||
const said = await handles[2].send("Runtime.evaluate", { expression: "document.title", returnByValue: true });
|
||||
const value = said && said.result && said.result.value;
|
||||
return value === "second" ? value : null;
|
||||
}, 15000);
|
||||
console.log(`[probe] post-wake: navigate answered frameNavigated for page2, title="${titled}"`);
|
||||
|
||||
// A command still in flight when the sleep lands must fail fast, not hang:
|
||||
// the kernel reads an error it can retry, never a timeout it cannot tell
|
||||
// from a dead page. The hanging ask wakes page 5, which then idles out and
|
||||
// sleeps again underneath it — the exact window being pinned.
|
||||
const pending = handles[4].send("Runtime.evaluate", { expression: "new Promise(() => {})", awaitPromise: true, returnByValue: true });
|
||||
const held = pending.then(() => "resolved", (err) => `rejected: ${err.message}`);
|
||||
await until("the sleeping page's in-flight command to settle", async () => {
|
||||
await wait(500);
|
||||
return !!(await Promise.race([held, Promise.resolve(false)]));
|
||||
}, 25000);
|
||||
const outcome = await held;
|
||||
if (!outcome.startsWith("rejected")) throw new Error(`a command on a slept page ${outcome} instead of failing`);
|
||||
console.log("[probe] in-flight: " + outcome);
|
||||
|
||||
console.log("[probe] PASS");
|
||||
views.closeAll();
|
||||
win.destroy();
|
||||
app.exit(0);
|
||||
}
|
||||
|
||||
app.whenReady().then(main).catch((err) => {
|
||||
console.error("[probe] FAIL:", err.message);
|
||||
app.exit(1);
|
||||
});
|
||||
@@ -84,7 +84,7 @@ async function run() {
|
||||
placed: typeof window.reasonixHost?.pathForFile(new File(['x'], 'x.txt')),
|
||||
}))()`);
|
||||
|
||||
const verbs = ["answerBrowserLogin", "closeWindow", "controlBrowserView", "freezeBrowserView", "hideBrowserView", "isWindowMaximised", "minimiseWindow", "navigateBrowserView", "onBrowserLoadState", "onBrowserLogin", "openExternal", "openWorkspace", "pathForFile", "pickFolder", "platform", "revealPath", "saveBytes", "saveText", "shell", "showBrowserView", "titleBar", "toggleMaximiseWindow", "trustBrowserCertificate"];
|
||||
const verbs = ["answerBrowserLogin", "captureWindow", "closeWindow", "controlBrowserView", "freezeBrowserView", "globalShortcut", "graphics", "hideBrowserView", "isWindowMaximised", "minimiseWindow", "navigateBrowserView", "onBrowserLoadState", "onBrowserLogin", "openExternal", "openFile", "openWorkspace", "pathForFile", "pickFolder", "platform", "processMemory", "revealLogs", "revealPath", "revealWorkspace", "saveBytes", "saveText", "setGlobalShortcut", "setUnreadBadge", "shell", "showBrowserView", "showTextContextMenu", "syncTheme", "titleBar", "toggleMaximiseWindow", "trustBrowserCertificate"];
|
||||
check("the bridge exposes verbs and nothing else", JSON.stringify(seen.bridge) === JSON.stringify(verbs), seen.bridge);
|
||||
check("the credential never reaches the page", !seen.cookie.includes("reasonix_token"), seen.cookie);
|
||||
check("the renderer has no node of its own", seen.globals.every((t) => t === "undefined"), seen.globals);
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
"use strict";
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const { app, BrowserWindow, nativeTheme } = require("electron");
|
||||
const { GROUND } = require("../src/ground");
|
||||
const { loadPrefs, prefsFile } = require("../src/prefs");
|
||||
const { until, wait } = require("./livekit");
|
||||
|
||||
const home = process.env.REASONIX_HOME;
|
||||
if (!home) throw new Error("Use an isolated REASONIX_HOME for the native theme smoke");
|
||||
fs.mkdirSync(home, { recursive: true });
|
||||
if (!fs.existsSync(path.join(home, "config.toml"))) {
|
||||
fs.writeFileSync(path.join(home, "config.toml"), 'default_model = "smoke/smoke-model"\n[desktop]\nwelcomed = true\n[[providers]]\nname = "smoke"\nkind = "openai"\nbase_url = "http://127.0.0.1:9/v1"\nmodels = ["smoke-model"]\ndefault = "smoke-model"\napi_key_env = "SMOKE_API_KEY"\n');
|
||||
fs.writeFileSync(path.join(home, ".env"), "SMOKE_API_KEY=smoke\n");
|
||||
}
|
||||
process.chdir(home);
|
||||
fs.mkdirSync(path.join(home, "electron-profile"), { recursive: true });
|
||||
app.setPath("userData", path.join(home, "electron-profile"));
|
||||
const startups = new Map();
|
||||
app.on("browser-window-created", (_event, win) => {
|
||||
// Native options settle after creation; load-start still precedes page paint.
|
||||
win.webContents.once("did-start-loading", () => {
|
||||
startups.set(win, { source: nativeTheme.themeSource, background: win.getBackgroundColor(), saved: loadPrefs(prefsFile(app.getPath("userData"))) });
|
||||
});
|
||||
});
|
||||
const { current } = require("../src/main");
|
||||
let untrusted;
|
||||
|
||||
async function run() {
|
||||
const win = await until("Studio window", () => current().win && !current().win.webContents.isLoading() && current().win);
|
||||
const js = source => win.webContents.executeJavaScript(source);
|
||||
await until("real Studio workbench", () => js('!!document.querySelector(".chrome") && !document.getElementById("boot")'));
|
||||
const saved = () => loadPrefs(prefsFile(app.getPath("userData")));
|
||||
async function matches(scheme, source = scheme) {
|
||||
await until(`native ${scheme}/${source} repaint`, async () => nativeTheme.themeSource === source &&
|
||||
win.getBackgroundColor().toLowerCase().endsWith(GROUND[scheme].slice(1)) &&
|
||||
await js(`document.documentElement.dataset.theme === ${JSON.stringify(scheme)}`));
|
||||
console.log(JSON.stringify({ page: scheme, source: nativeTheme.themeSource, background: win.getBackgroundColor() }));
|
||||
}
|
||||
if (process.env.REASONIX_THEME_RESTART === "1") {
|
||||
const startup = startups.get(win);
|
||||
assert.equal(startup.saved["rx-theme"], "scheduled");
|
||||
assert.equal(startup.source, startup.saved["rx-theme-resolved"]);
|
||||
assert.ok(startup.background.toLowerCase().endsWith(GROUND[startup.source].slice(1)), JSON.stringify(startup));
|
||||
const clock = new Date();
|
||||
const actual = clock.getHours() >= 20 || clock.getHours() < 7 ? "dark" : "light";
|
||||
assert.notEqual(actual, startup.source);
|
||||
await matches(actual);
|
||||
assert.equal(await js('localStorage.getItem("rx-theme")'), "scheduled");
|
||||
console.log("ok restart: cached native startup tint, then current local-clock page/native repaint on a new kernel origin");
|
||||
return;
|
||||
}
|
||||
|
||||
nativeTheme.themeSource = "system";
|
||||
const systemDark = nativeTheme.shouldUseDarkColors;
|
||||
console.log(`host OS dark=${systemDark}; platform=${process.platform}`);
|
||||
await js(`(() => {
|
||||
const RealDate = Date;
|
||||
window.__themeClock = { at: RealDate.now(), began: performance.now() };
|
||||
window.Date = class extends RealDate {
|
||||
constructor(...args) { super(...(args.length ? args : [window.__themeClock.at + performance.now() - window.__themeClock.began])); }
|
||||
static now() { return window.__themeClock.at + performance.now() - window.__themeClock.began; }
|
||||
};
|
||||
})()`);
|
||||
async function clock(hour, minute = 0, second = 0) {
|
||||
const at = new Date(2026, 9, 29, hour, minute, second).getTime();
|
||||
await js(`window.__themeClock = { at: ${at}, began: performance.now() }; window.dispatchEvent(new Event("focus"))`);
|
||||
}
|
||||
await js('document.querySelector(\'[data-action="chrome.settings"]\').click()');
|
||||
await until("settings navigation", () => js('!!document.querySelector(\'[data-action="settings.section"][data-value="appearance"]\')'));
|
||||
await js('document.querySelector(\'[data-action="settings.section"][data-value="appearance"]\').click()');
|
||||
await until("appearance modes", () => js('!!document.querySelector(\'[data-action="appearance.scheme"][data-value="scheduled"]\')'));
|
||||
const mode = value => js(`document.querySelector('[data-action="appearance.scheme"][data-value="${value}"]').click()`);
|
||||
async function change(field, value) {
|
||||
await js(`(() => {
|
||||
const input = document.querySelector('[data-action="appearance.schedule"][data-target="${field}"]');
|
||||
Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value").set.call(input, ${JSON.stringify(value)});
|
||||
input.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
input.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
})()`);
|
||||
}
|
||||
await clock(19, 59, 57);
|
||||
await mode("scheduled");
|
||||
await matches("light");
|
||||
await matches("dark");
|
||||
console.log("ok real boundary timeout changes both nativeTheme.themeSource and BrowserWindow background");
|
||||
await clock(6, 59, 58);
|
||||
await matches("dark");
|
||||
await matches("light");
|
||||
await clock(21);
|
||||
await matches("dark");
|
||||
await change("start", "22:00");
|
||||
await matches("light");
|
||||
await change("start", "20:00");
|
||||
await matches("dark");
|
||||
await change("end", "21:00");
|
||||
await matches("light");
|
||||
await change("end", "07:00");
|
||||
await matches("dark");
|
||||
await js('document.querySelector(\'[data-action="theme.activate"][data-value="official-clear-blue"]\').click()');
|
||||
await until("active theme pack", () => js('document.documentElement.dataset.pack === "official-clear-blue"'));
|
||||
await matches("dark");
|
||||
await js('document.querySelector(".appearance-advanced").open = true; document.querySelector(\'[data-action="appearance.contrast"][data-value="strong"]\').click()');
|
||||
await matches("dark");
|
||||
await clock(8);
|
||||
await matches("light");
|
||||
assert.equal(await js('document.documentElement.dataset.pack'), "official-clear-blue");
|
||||
assert.equal(await js('document.documentElement.dataset.contrast'), "strong");
|
||||
console.log("ok start/end edits, focus resume, active pack and contrast keep the native scheme aligned");
|
||||
|
||||
await clock(systemDark ? 8 : 21);
|
||||
await matches(systemDark ? "light" : "dark");
|
||||
await mode("auto");
|
||||
await until("Auto native system following", () => nativeTheme.themeSource === "system");
|
||||
const systemScheme = systemDark ? "dark" : "light";
|
||||
await matches(systemScheme, "system");
|
||||
await js('localStorage.setItem("rx-theme", "scheduled"); localStorage.setItem("rx-weight", "heavy"); window.dispatchEvent(new Event("pagehide"))');
|
||||
assert.equal(saved()["rx-theme"], "scheduled");
|
||||
await matches(systemScheme, "system");
|
||||
await js('localStorage.setItem("rx-theme", "auto")');
|
||||
console.log("ok simulated stale prefs:save cannot override the latest Auto paint or pin system following");
|
||||
await clock(8);
|
||||
await mode("scheduled");
|
||||
await matches("light");
|
||||
await mode("dark");
|
||||
await matches("dark");
|
||||
await js('window.reasonixHost.syncTheme("scheduled", "auto"); window.reasonixHost.syncTheme("light", "dark"); window.reasonixHost.syncTheme("invalid", "light")');
|
||||
await wait(200);
|
||||
await matches("dark");
|
||||
untrusted = new BrowserWindow({ show: false, webPreferences: { preload: path.join(__dirname, "../src/preload.js"), sandbox: true, contextIsolation: true, nodeIntegration: false, partition: "theme-smoke-untrusted" } });
|
||||
await untrusted.loadURL("data:text/html,<title>Untrusted theme reporter</title>");
|
||||
await untrusted.webContents.executeJavaScript('window.reasonixHost.syncTheme("scheduled", "light")');
|
||||
await wait(200);
|
||||
await matches("dark");
|
||||
untrusted.destroy(); untrusted = null;
|
||||
await js('const iframe = document.createElement("iframe"); iframe.id = "theme-subframe"; iframe.srcdoc = "<p>Untrusted subframe</p>"; document.body.append(iframe)');
|
||||
await until("isolated subframe", () => js('!!document.getElementById("theme-subframe").contentDocument?.querySelector("p")'));
|
||||
assert.equal(await js('typeof document.getElementById("theme-subframe").contentWindow.reasonixHost'), "undefined");
|
||||
await js('document.getElementById("theme-subframe").remove()');
|
||||
console.log("ok real untrusted renderer and invalid payloads cannot repaint native chrome; subframes have no bridge");
|
||||
|
||||
await mode("scheduled");
|
||||
const real = new Date();
|
||||
const restartScheme = real.getHours() >= 20 || real.getHours() < 7 ? "light" : "dark";
|
||||
await clock(restartScheme === "dark" ? 21 : 8);
|
||||
await matches(restartScheme);
|
||||
await js('window.dispatchEvent(new Event("pagehide"))');
|
||||
assert.equal(saved()["rx-theme"], "scheduled");
|
||||
assert.equal(saved()["rx-theme-resolved"], restartScheme);
|
||||
const out = process.env.REASONIX_SCREENSHOTS;
|
||||
if (out) {
|
||||
fs.mkdirSync(out, { recursive: true });
|
||||
await js('document.getElementById("set-mode").scrollIntoView({block:"start"})');
|
||||
await wait(350);
|
||||
fs.writeFileSync(path.join(out, "native-scheduled.png"), (await win.webContents.capturePage()).toPNG());
|
||||
}
|
||||
console.log("ok saved opposite-clock native tint; run again with REASONIX_THEME_RESTART=1 to check a complete restart");
|
||||
console.log("note Windows border rendering and changing the host OS preference are not exercised on this host");
|
||||
}
|
||||
|
||||
let exitCode = 0;
|
||||
app.once("will-quit", () => app.exit(exitCode));
|
||||
run().then(() => app.quit()).catch(error => {
|
||||
untrusted?.destroy();
|
||||
console.error(error.stack || error);
|
||||
exitCode = 1;
|
||||
app.quit();
|
||||
});
|
||||
@@ -4,18 +4,20 @@ import { createRequire } from "node:module";
|
||||
import path from "node:path";
|
||||
import os from "node:os";
|
||||
import fs from "node:fs";
|
||||
import { EventEmitter } from "node:events";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const cuaContract = require("../../../internal/platform/computer/cua-driver.lock.json");
|
||||
const { parse, readActs, start } = require("../src/host.js");
|
||||
const { contextTemplate, editMenuTemplate, applicationMenuTemplate, menuInstaller } = require("../src/editmenu.js");
|
||||
const { textContextTemplate, textContextMenuHandler } = require("../src/textcontextmenu.js");
|
||||
const { uiLanguage } = require("../src/uilang.js");
|
||||
const { externalTarget } = require("../src/links.js");
|
||||
const { offerCleanup, ownBundle } = require("../src/legacy.js");
|
||||
const { stripPackageGrants, readReport, unpaintedWindowCause } = require("../src/packagegrants.js");
|
||||
const { groundFor, themeSourceFor, GROUND } = require("../src/ground.js");
|
||||
const { folderAnswer } = require("../src/folderpanel.js");
|
||||
const { pick, loadPrefs, savePrefs, registerPrefs } = require("../src/prefs.js");
|
||||
const { pick, loadPrefs, savePrefs, registerPrefs, PAGE_SLEEP_MS, pageSleepAfterMs } = require("../src/prefs.js");
|
||||
|
||||
const TOKEN = "a".repeat(64);
|
||||
const line = (over) => JSON.stringify({ version: 1, origin: "http://127.0.0.1:8080", token: TOKEN, ...over });
|
||||
@@ -84,6 +86,136 @@ test("the context menu mirrors what the page says is possible", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("a reader's generic text menu never offers mutation roles", () => {
|
||||
const items = contextTemplate({ isEditable: false, selectionText: "picked",
|
||||
editFlags: { canCopy: true, canSelectAll: true, canCut: true, canPaste: true, canUndo: true } }, "en");
|
||||
assert.deepEqual(items.map((item) => item.role), ["copy", "selectAll"]);
|
||||
assert.ok(items.every((item) => item.enabled));
|
||||
});
|
||||
|
||||
const textMenuRequest = (over = {}) => ({
|
||||
editable: true, selected: true, canUndo: true, canRedo: false, quote: true, x: 12.5, y: 24, ...over,
|
||||
});
|
||||
const menuRoles = (items) => Object.fromEntries(items.filter((item) => item.role || item.id)
|
||||
.map((item) => [item.role ?? item.id, item.enabled]));
|
||||
|
||||
test("workbench native menus distinguish editable and reader surfaces and translate quoting", () => {
|
||||
const editable = textContextTemplate(textMenuRequest(), "en", () => {});
|
||||
assert.deepEqual(menuRoles(editable), { undo: true, redo: false, cut: true, copy: true, paste: true, selectAll: true });
|
||||
assert.equal(editable.at(-1).label, "Add to conversation");
|
||||
assert.ok(editable.filter((item) => item.role).every((item) => !item.click));
|
||||
const empty = textContextTemplate(textMenuRequest({ selected: false }), "en", () => {});
|
||||
assert.deepEqual(menuRoles(empty), { undo: true, redo: false, cut: false, copy: false, paste: true, selectAll: true });
|
||||
assert.equal(empty.filter((item) => item.click).length, 3);
|
||||
const reader = textContextTemplate(textMenuRequest({ editable: false }), "zh", () => {});
|
||||
assert.deepEqual(menuRoles(reader), { copy: true, selectAll: true });
|
||||
assert.equal(reader.at(-1).label, "加入对话");
|
||||
assert.deepEqual(textContextTemplate(textMenuRequest({ editable: false, selected: false }), "en", () => {}), []);
|
||||
assert.equal(textContextTemplate(textMenuRequest({ quote: false }), "en", () => {}).filter((item) => item.click).length, 3);
|
||||
});
|
||||
|
||||
function textMenuHarness() {
|
||||
const contents = Object.assign(new EventEmitter(), {
|
||||
mainFrame: { url: "http://127.0.0.1:8080/" }, getZoomFactor: () => 1.5,
|
||||
});
|
||||
const window = Object.assign(new EventEmitter(), { webContents: contents, isDestroyed: () => false });
|
||||
const menus = [];
|
||||
const Menu = { buildFromTemplate: (template) => {
|
||||
const menu = { template, popup: (options) => { menu.options = options; },
|
||||
closePopup: (target) => { assert.equal(target, window); menu.closed = true; } };
|
||||
menus.push(menu);
|
||||
return menu;
|
||||
} };
|
||||
const event = { sender: contents, senderFrame: contents.mainFrame };
|
||||
const handle = textContextMenuHandler(Menu, (e) => e.sender === contents ? window : null,
|
||||
() => "http://127.0.0.1:8080", () => "en");
|
||||
return { contents, window, menus, event, handle };
|
||||
}
|
||||
|
||||
test("native workbench popup returns quote or dismissal and uses zoomed client coordinates", async () => {
|
||||
const h = textMenuHarness();
|
||||
const picked = h.handle(h.event, textMenuRequest());
|
||||
const menu = h.menus[0];
|
||||
assert.equal(menu.options.window, h.window);
|
||||
assert.equal(menu.options.x, 19);
|
||||
assert.equal(menu.options.y, 36);
|
||||
menu.template.at(-1).click();
|
||||
menu.options.callback();
|
||||
assert.equal(await picked, "quote");
|
||||
const dismissed = h.handle(h.event, textMenuRequest());
|
||||
h.menus[1].options.callback();
|
||||
assert.equal(await dismissed, "");
|
||||
assert.equal(h.contents.listenerCount("destroyed"), 0);
|
||||
assert.equal(h.window.listenerCount("closed"), 0);
|
||||
});
|
||||
|
||||
test("workbench history and select-all commands return editor actions, not native roles", async () => {
|
||||
const h = textMenuHarness();
|
||||
for (const [action, accelerator] of [
|
||||
["undo", "CommandOrControl+Z"],
|
||||
["redo", process.platform === "darwin" ? "CommandOrControl+Shift+Z" : "CommandOrControl+Y"],
|
||||
["selectAll", "CommandOrControl+A"],
|
||||
]) {
|
||||
const result = h.handle(h.event, textMenuRequest({ canRedo: true }));
|
||||
const menu = h.menus.at(-1);
|
||||
const item = menu.template.find((entry) => entry.id === action);
|
||||
assert.equal(item.role, undefined);
|
||||
assert.equal(item.accelerator, accelerator);
|
||||
assert.equal(item.enabled, true);
|
||||
item.click();
|
||||
menu.options.callback();
|
||||
assert.equal(await result, action);
|
||||
}
|
||||
const disabled = textContextTemplate(textMenuRequest({ canUndo: false }), "en", () => {});
|
||||
assert.equal(disabled.find((item) => item.id === "undo").enabled, false);
|
||||
assert.equal(disabled.find((item) => item.id === "redo").enabled, false);
|
||||
});
|
||||
|
||||
test("native workbench popup rejects foreign senders, subframes, origins and malformed capabilities", async () => {
|
||||
const h = textMenuHarness();
|
||||
for (const event of [
|
||||
{ ...h.event, sender: {} },
|
||||
{ ...h.event, senderFrame: { url: h.contents.mainFrame.url } },
|
||||
{ ...h.event, senderFrame: null },
|
||||
]) assert.equal(await h.handle(event, textMenuRequest()), "");
|
||||
for (const url of ["http://127.0.0.1:8081/", "http://127.0.0.1:8080.evil/", "about:blank"]) {
|
||||
h.contents.mainFrame.url = url;
|
||||
assert.equal(await h.handle(h.event, textMenuRequest()), "");
|
||||
}
|
||||
h.contents.mainFrame.url = "http://127.0.0.1:8080/";
|
||||
for (const request of [null, [], {}, textMenuRequest({ selected: "yes" }),
|
||||
textMenuRequest({ canUndo: 1 }), textMenuRequest({ quote: undefined }),
|
||||
textMenuRequest({ x: NaN }), textMenuRequest({ y: Infinity }), textMenuRequest({ x: "1" }),
|
||||
textMenuRequest({ items: [{ role: "quit" }] })]) {
|
||||
assert.equal(await h.handle(h.event, request), "");
|
||||
}
|
||||
assert.equal(await h.handle(h.event, textMenuRequest({ editable: false, selected: false })), "");
|
||||
assert.equal(h.menus.length, 0);
|
||||
});
|
||||
|
||||
test("native workbench popups settle and clean up on navigation, destruction and replacement", async () => {
|
||||
for (const lifecycle of ["navigation", "contents", "window", "replacement"]) {
|
||||
const h = textMenuHarness();
|
||||
const pending = h.handle(h.event, textMenuRequest());
|
||||
if (lifecycle === "navigation") {
|
||||
h.contents.emit("did-start-navigation", { isMainFrame: false });
|
||||
assert.equal(h.menus[0].closed, undefined);
|
||||
h.contents.emit("did-start-navigation", { isMainFrame: true });
|
||||
} else if (lifecycle === "contents") h.contents.emit("destroyed");
|
||||
else if (lifecycle === "window") h.window.emit("closed");
|
||||
else {
|
||||
const next = h.handle(h.event, textMenuRequest());
|
||||
h.menus[1].options.callback();
|
||||
assert.equal(await next, "");
|
||||
}
|
||||
assert.equal(await pending, "");
|
||||
assert.equal(h.menus[0].closed, true);
|
||||
assert.equal(h.contents.listenerCount("did-start-navigation"), 0);
|
||||
assert.equal(h.contents.listenerCount("destroyed"), 0);
|
||||
assert.equal(h.window.listenerCount("closed"), 0);
|
||||
}
|
||||
});
|
||||
|
||||
test("the edit menu's labels follow the interface language, not the system's", () => {
|
||||
const params = { isEditable: true, selectionText: "", editFlags: {} };
|
||||
const labels = (lang) => contextTemplate(params, lang).filter((i) => i.role).map((i) => i.label);
|
||||
@@ -1020,6 +1152,30 @@ test("only the Studio window reads or writes the preferences", () => {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("only the Studio main frame reports a validated resolved theme", () => {
|
||||
const handlers = {};
|
||||
const studio = { mainFrame: {} };
|
||||
const browser = { mainFrame: {} };
|
||||
const paints = [];
|
||||
registerPrefs({ on: (name, fn) => (handlers[name] = fn) }, () => "", event => event.sender === studio,
|
||||
() => {}, paint => paints.push(paint));
|
||||
const send = (sender, senderFrame, paint) => handlers["prefs:theme"]({ sender, senderFrame }, paint);
|
||||
send(browser, browser.mainFrame, { mode: "scheduled", scheme: "dark" });
|
||||
send(studio, {}, { mode: "scheduled", scheme: "dark" });
|
||||
send(studio, null, { mode: "scheduled", scheme: "dark" });
|
||||
for (const paint of [null, [], "dark", { mode: "scheduled" }, { mode: "scheduled", scheme: "auto" },
|
||||
{ mode: "other", scheme: "dark" }, { mode: "light", scheme: "dark" }, { mode: "dark", scheme: "light" }]) {
|
||||
send(studio, studio.mainFrame, paint);
|
||||
}
|
||||
assert.deepEqual(paints, []);
|
||||
for (const mode of ["scheduled", "auto", "dark"]) send(studio, studio.mainFrame, { mode, scheme: "dark", extra: "discarded" });
|
||||
send(studio, studio.mainFrame, { mode: "light", scheme: "light" });
|
||||
assert.deepEqual(paints, [
|
||||
{ mode: "scheduled", scheme: "dark" }, { mode: "auto", scheme: "dark" },
|
||||
{ mode: "dark", scheme: "dark" }, { mode: "light", scheme: "light" },
|
||||
]);
|
||||
});
|
||||
|
||||
function elf64(interpreter, machine = 62) {
|
||||
const phoff = 64, phentsize = 56, phnum = interpreter ? 2 : 1;
|
||||
const dataOffset = phoff + phnum * phentsize;
|
||||
@@ -1722,6 +1878,25 @@ test("the launch credential cookie is put back when something removes it, and on
|
||||
assert.match(lines[0], /evicted/);
|
||||
});
|
||||
|
||||
test("a refused request makes the shell check the credential cookie, and put it back only when it is missing or differs", async () => {
|
||||
const { restoreIfStale } = require("../src/credential.js");
|
||||
const cookie = { url: "http://127.0.0.1:5000/", name: "reasonix_token", value: "t" };
|
||||
const run = async (held) => {
|
||||
const sets = [];
|
||||
const lines = [];
|
||||
const ses = { cookies: { get: async () => held, set: async (c) => void sets.push(c) } };
|
||||
await restoreIfStale(ses, cookie, (l) => lines.push(l));
|
||||
return { sets, lines };
|
||||
};
|
||||
assert.deepEqual((await run([{ name: "reasonix_token", value: "t" }])).sets, [], "a matching cookie is left alone");
|
||||
const missing = await run([]);
|
||||
assert.deepEqual(missing.sets, [cookie]);
|
||||
assert.match(missing.lines[0], /missing/);
|
||||
const stale = await run([{ name: "reasonix_token", value: "old" }]);
|
||||
assert.deepEqual(stale.sets, [cookie]);
|
||||
assert.match(stale.lines[0], /differs/);
|
||||
});
|
||||
|
||||
test("the tray has a file for every Windows scale, at exactly 16 * scale pixels", () => {
|
||||
const { trayAsset, SCALES } = require("../src/trayimage.js");
|
||||
const dim = (f) => {
|
||||
@@ -2032,6 +2207,31 @@ test("the window frame takes the stored theme, and auto follows the system", ()
|
||||
assert.equal(themeSourceFor(undefined), "system");
|
||||
});
|
||||
|
||||
test("scheduled grounds and frames follow the painted scheme rather than the opposite OS", () => {
|
||||
const prefs = { "rx-theme": "scheduled" };
|
||||
for (const [scheme, systemDark] of [["dark", false], ["light", true]]) {
|
||||
const painted = { mode: "scheduled", scheme };
|
||||
assert.equal(themeSourceFor(prefs, painted), scheme);
|
||||
assert.equal(groundFor(prefs, systemDark, painted), GROUND[scheme]);
|
||||
const saved = { ...prefs, "rx-theme-resolved": scheme };
|
||||
assert.equal(themeSourceFor(saved), scheme);
|
||||
assert.equal(groundFor(saved, systemDark), GROUND[scheme]);
|
||||
}
|
||||
assert.equal(themeSourceFor({ ...prefs, "rx-theme-resolved": "invalid" }), "system");
|
||||
});
|
||||
|
||||
test("the latest painted mode wins over delayed preference saves without pinning Auto", () => {
|
||||
const stale = { "rx-theme": "scheduled", "rx-theme-resolved": "dark" };
|
||||
const auto = { mode: "auto", scheme: "dark" };
|
||||
assert.equal(themeSourceFor(stale, auto), "system");
|
||||
assert.equal(groundFor(stale, false, auto), GROUND.light);
|
||||
assert.equal(groundFor(stale, true, auto), GROUND.dark);
|
||||
const light = { mode: "light", scheme: "light" };
|
||||
assert.equal(themeSourceFor(stale, light), "light");
|
||||
assert.equal(groundFor(stale, true, light), GROUND.light);
|
||||
assert.equal(themeSourceFor({ "rx-theme": "light" }, { mode: "scheduled", scheme: "dark" }), "dark");
|
||||
});
|
||||
|
||||
const { accelerationOff, shouldDisableGpu, graphicsReport, graphicsHandler, PREF_KEY: GPU_PREF } = require("../src/graphics.js");
|
||||
|
||||
test("hardware acceleration is off only when the saved preference says exactly that", () => {
|
||||
@@ -2178,7 +2378,6 @@ test("packaging: a Windows installer named for x64 is renamed to amd64", async (
|
||||
});
|
||||
|
||||
const { stateFile, loadState, saveState, placement, track } = require("../src/windowstate.js");
|
||||
const { EventEmitter } = require("node:events");
|
||||
|
||||
const AREA = { x: 0, y: 0, width: 1920, height: 1040 };
|
||||
const SECOND = { x: 1920, y: 0, width: 1280, height: 1000 };
|
||||
@@ -2558,6 +2757,421 @@ test("input that carries a point is scaled with the page or refused, never sent
|
||||
assert.equal(scaleInput("Input.insertText", { text: "x" }, 0.5).text, "x");
|
||||
});
|
||||
|
||||
// The pages' idle sleep. Electron is faked the way the suites above fake it,
|
||||
// but richly enough that create() itself runs: a page that never went through
|
||||
// create() cannot be watched going to sleep. The module is dropped from the
|
||||
// require cache first, because the suites above have already loaded it against
|
||||
// their own bare mock, and a cached module never loads again.
|
||||
function sleepingViews({ sleepAfterMs = 5, sweepMs = 5, maxAwake = 6, capturePage, failSpawns = 0, hang = null } = {}) {
|
||||
const { EventEmitter } = require("node:events");
|
||||
const created = [];
|
||||
const loads = [];
|
||||
class FakeContents extends EventEmitter {
|
||||
constructor() {
|
||||
super();
|
||||
this.destroyed = false;
|
||||
this.url = "about:blank";
|
||||
this.commands = [];
|
||||
// What maySleep reads off a live page; a test flips these on the fake
|
||||
// the helper's `created` array already hands back.
|
||||
this.audible = false;
|
||||
this.devTools = false;
|
||||
const contents = this;
|
||||
this.debugger = {
|
||||
attach() {},
|
||||
on(event, fn) {
|
||||
contents.on("debugger:" + event, fn);
|
||||
},
|
||||
sendCommand: async (method, params) => {
|
||||
contents.commands.push([method, params]);
|
||||
if (hang === method) return new Promise(() => {});
|
||||
return { data: "cG5n" };
|
||||
},
|
||||
};
|
||||
}
|
||||
isCurrentlyAudible() {
|
||||
return this.audible;
|
||||
}
|
||||
isDevToolsOpened() {
|
||||
return this.devTools;
|
||||
}
|
||||
setWindowOpenHandler(handler) {
|
||||
this.openHandler = handler;
|
||||
}
|
||||
loadURL(url) {
|
||||
this.url = url;
|
||||
loads.push(url);
|
||||
return Promise.resolve();
|
||||
}
|
||||
getURL() {
|
||||
return this.url;
|
||||
}
|
||||
isDestroyed() {
|
||||
return this.destroyed;
|
||||
}
|
||||
close() {
|
||||
if (this.destroyed) return;
|
||||
this.destroyed = true;
|
||||
this.emit("destroyed");
|
||||
}
|
||||
capturePage() {
|
||||
return capturePage ? capturePage() : Promise.resolve({ isEmpty: () => false, toJPEG: () => Buffer.from("jpeg") });
|
||||
}
|
||||
}
|
||||
class FakeView {
|
||||
constructor({ webPreferences }) {
|
||||
if (failSpawns > 0) {
|
||||
failSpawns--;
|
||||
throw new Error("the view would not build");
|
||||
}
|
||||
this.webPreferences = webPreferences;
|
||||
this.webContents = new FakeContents();
|
||||
created.push(this);
|
||||
}
|
||||
setBorderRadius() {}
|
||||
setBounds() {}
|
||||
}
|
||||
const ses = Object.assign(new EventEmitter(), {
|
||||
setPermissionRequestHandler() {},
|
||||
setPermissionCheckHandler() {},
|
||||
webRequest: { onBeforeRequest() {} },
|
||||
});
|
||||
const Module = require("node:module");
|
||||
const load = Module._load;
|
||||
Module._load = function (request, ...rest) {
|
||||
if (request === "electron") return { WebContentsView: FakeView, session: { fromPartition: () => ses } };
|
||||
return load.call(this, request, ...rest);
|
||||
};
|
||||
let BrowserViews;
|
||||
try {
|
||||
delete require.cache[require.resolve("../src/browserviews.js")];
|
||||
({ BrowserViews } = require("../src/browserviews.js"));
|
||||
} finally {
|
||||
Module._load = load;
|
||||
}
|
||||
const win = {
|
||||
on() {},
|
||||
isDestroyed: () => false,
|
||||
isFocused: () => false,
|
||||
webContents: Object.assign(new EventEmitter(), { isFocused: () => false }),
|
||||
getContentBounds: () => ({ width: 1200, height: 800 }),
|
||||
contentView: { addChildView() {}, removeChildView() {} },
|
||||
};
|
||||
const views = new BrowserViews({ win, kernelOrigin: "http://127.0.0.1:1", sleepAfterMs, sweepMs, maxAwake });
|
||||
views.keepKeyboard = () => {};
|
||||
const page = () => {
|
||||
const events = { closed: 0, popups: [], downloads: [] };
|
||||
const handle = views.create({
|
||||
partition: "p" + created.length,
|
||||
url: "https://page.test/",
|
||||
onEvent: () => {},
|
||||
onClosed: () => events.closed++,
|
||||
onPopup: (to) => events.popups.push(to),
|
||||
onDownload: (download) => events.downloads.push(download),
|
||||
});
|
||||
return { handle, events };
|
||||
};
|
||||
return { views, created, loads, page };
|
||||
}
|
||||
|
||||
test("an idle page nobody is looking at is slept by the sweep and lives on as a tombstone", async (t) => {
|
||||
const { views, created, page } = sleepingViews();
|
||||
t.after(() => views.closeAll());
|
||||
const { handle, events } = page();
|
||||
await later(40);
|
||||
assert.deepEqual(views.asleep(handle.targetId), { url: "https://page.test/" }, "the idle page is asleep");
|
||||
assert.ok(views.entries.has(handle.targetId), "the entry survives as a tombstone");
|
||||
assert.equal(views.entries.get(handle.targetId).view, null);
|
||||
assert.match(views.entries.get(handle.targetId).slept.picture, /^data:image\/jpeg;base64,/);
|
||||
assert.equal(created.length, 1, "no view was built for a sleep");
|
||||
assert.equal(events.closed, 0, "the kernel is not told of a sleep");
|
||||
});
|
||||
|
||||
test("a send to a slept page wakes it, and the call lands on the page it comes back as", async (t) => {
|
||||
const { views, created, loads, page } = sleepingViews();
|
||||
t.after(() => views.closeAll());
|
||||
const { handle, events } = page();
|
||||
await later(40);
|
||||
assert.ok(views.asleep(handle.targetId));
|
||||
const built = created.length;
|
||||
await handle.send("Page.captureScreenshot", { format: "png" });
|
||||
assert.equal(created.length, built + 1, "waking built the view again");
|
||||
assert.equal(views.asleep(handle.targetId), null, "the page is awake");
|
||||
assert.ok(views.entries.has(handle.targetId), "the targetId is the one the kernel holds");
|
||||
assert.equal(loads.length, 2, "the load was kicked off again");
|
||||
assert.equal(events.closed, 0, "a wake is not a death");
|
||||
const contents = views.entries.get(handle.targetId).view.webContents;
|
||||
assert.deepEqual(contents.commands.at(-1), ["Page.captureScreenshot", { format: "png" }]);
|
||||
});
|
||||
|
||||
test("closing a slept page buries it, and the kernel hears of it exactly once", async (t) => {
|
||||
const { views, page } = sleepingViews();
|
||||
t.after(() => views.closeAll());
|
||||
const { handle, events } = page();
|
||||
await later(40);
|
||||
assert.ok(views.asleep(handle.targetId));
|
||||
handle.close();
|
||||
assert.equal(events.closed, 1, "the kernel is told once");
|
||||
handle.close();
|
||||
assert.equal(events.closed, 1, "and only once");
|
||||
assert.equal(views.entries.has(handle.targetId), false);
|
||||
assert.equal(views.asleep(handle.targetId), null);
|
||||
});
|
||||
|
||||
test("a third page costs the least recently touched one its renderer, never the one on screen", async (t) => {
|
||||
const { views, page } = sleepingViews({ sleepAfterMs: 60_000, sweepMs: 60_000, maxAwake: 2 });
|
||||
t.after(() => views.closeAll());
|
||||
const first = page();
|
||||
const second = page();
|
||||
views.show(first.handle.targetId, { x: 0, y: 0, width: 600, height: 400 });
|
||||
// The page on screen is made the oldest on purpose: the cap must go past it.
|
||||
views.entries.get(first.handle.targetId).lastTouch = 1;
|
||||
const third = page();
|
||||
await later(20);
|
||||
assert.ok(views.asleep(second.handle.targetId), "the second page was slept to make room");
|
||||
assert.equal(views.asleep(first.handle.targetId), null, "the page on screen keeps its renderer");
|
||||
assert.equal(views.asleep(third.handle.targetId), null, "the page just created is awake");
|
||||
});
|
||||
|
||||
test("with the setting off, the cap sleeps no page either", async (t) => {
|
||||
const off = sleepingViews({ sleepAfterMs: Infinity, sweepMs: 5, maxAwake: 2 });
|
||||
t.after(() => off.views.closeAll());
|
||||
const one = off.page();
|
||||
const two = off.page();
|
||||
const three = off.page();
|
||||
await later(20);
|
||||
assert.equal(off.views.asleep(one.handle.targetId), null, "off means nothing sleeps: page one");
|
||||
assert.equal(off.views.asleep(two.handle.targetId), null, "page two");
|
||||
assert.equal(off.views.asleep(three.handle.targetId), null, "page three, though the cap is passed");
|
||||
// The same cap with the setting on still sleeps the least recently touched.
|
||||
const on = sleepingViews({ sleepAfterMs: 60_000, sweepMs: 60_000, maxAwake: 2 });
|
||||
t.after(() => on.views.closeAll());
|
||||
const oldest = on.page();
|
||||
on.page();
|
||||
on.page();
|
||||
await later(20);
|
||||
assert.ok(on.views.asleep(oldest.handle.targetId), "on, the least recently touched page sleeps");
|
||||
});
|
||||
|
||||
test("the sweep never puts the page the person is looking at to sleep", async (t) => {
|
||||
const { views, page } = sleepingViews();
|
||||
t.after(() => views.closeAll());
|
||||
const shown = page();
|
||||
views.show(shown.handle.targetId, { x: 0, y: 0, width: 600, height: 400 });
|
||||
views.entries.get(shown.handle.targetId).lastTouch = 1;
|
||||
await later(40);
|
||||
assert.equal(views.asleep(shown.handle.targetId), null, "the shown page is still awake");
|
||||
assert.ok(views.entries.get(shown.handle.targetId).view, "and its view is still there");
|
||||
});
|
||||
|
||||
test("closeAll takes the sweep's timer with it, and a suite full of these does not hang", async () => {
|
||||
mock.timers.enable({ apis: ["setTimeout", "setInterval", "Date"] });
|
||||
try {
|
||||
const { views, page } = sleepingViews({ sleepAfterMs: 10, sweepMs: 10 });
|
||||
page();
|
||||
views.closeAll();
|
||||
assert.equal(views.sweeper, null, "the timer is gone");
|
||||
views.closeAll();
|
||||
assert.equal(views.sweeper, null, "and a second closeAll does not mind");
|
||||
// A page made after the closeAll has nothing left to sleep it: had the
|
||||
// timer survived, this tick would put it to sleep.
|
||||
const { handle } = page();
|
||||
mock.timers.tick(60_000);
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
assert.equal(views.asleep(handle.targetId), null, "no sweep fired after closeAll");
|
||||
} finally {
|
||||
mock.timers.reset();
|
||||
}
|
||||
});
|
||||
|
||||
test("a wake replays the page's domain enables before the next command lands", async (t) => {
|
||||
const { views, created, page } = sleepingViews();
|
||||
t.after(() => views.closeAll());
|
||||
const { handle } = page();
|
||||
// What the kernel says once at attach: the domains the page's events come from.
|
||||
await handle.send("Page.enable", {});
|
||||
await handle.send("Page.setLifecycleEventsEnabled", { enabled: true });
|
||||
// A domain switched on mid-session joins the set; one switched off leaves it.
|
||||
await handle.send("Network.enable", {});
|
||||
await handle.send("Network.disable", {});
|
||||
await later(40);
|
||||
assert.ok(views.asleep(handle.targetId));
|
||||
await handle.send("Runtime.evaluate", { expression: "1" });
|
||||
assert.equal(created.length, 2, "the wake built a new view");
|
||||
const said = created[1].webContents.commands.map(([method]) => method);
|
||||
assert.deepEqual(said, ["Page.enable", "Page.setLifecycleEventsEnabled", "Runtime.evaluate"], "the enables are said again, before the next command — and a domain switched off is not switched back on");
|
||||
assert.deepEqual(created[1].webContents.commands[1][1], { enabled: true }, "the parameters the kernel chose are the ones replayed");
|
||||
assert.equal(created[0].webContents.commands.length, 4, "nothing was replayed on the session that died");
|
||||
assert.equal(views.asleep(handle.targetId), null);
|
||||
});
|
||||
|
||||
test("a wake yields no room: the page asked for outranks the pages left alone", async (t) => {
|
||||
const { views, page } = sleepingViews({ maxAwake: 2, sleepAfterMs: 60, sweepMs: 5 });
|
||||
t.after(() => views.closeAll());
|
||||
const first = page();
|
||||
const second = page();
|
||||
await later(90);
|
||||
assert.ok(views.asleep(first.handle.targetId), "the pages slept once they idled");
|
||||
// The kernel is using the second page when it asks for the first: the cap is
|
||||
// a brake on creation, not a quota held against a page the kernel wants.
|
||||
await second.handle.send("Runtime.evaluate", { expression: "1" });
|
||||
await first.handle.send("Runtime.evaluate", { expression: "1" });
|
||||
assert.equal(views.asleep(first.handle.targetId), null, "the asked-for page woke");
|
||||
assert.equal(views.asleep(second.handle.targetId), null, "the page in use was not slept to make room");
|
||||
assert.ok(views.entries.get(second.handle.targetId).view, "the page in use kept its renderer");
|
||||
});
|
||||
|
||||
test("a command the sleep outlives is failed, not left hanging", async (t) => {
|
||||
const { views, page } = sleepingViews({ hang: "Runtime.evaluate" });
|
||||
t.after(() => views.closeAll());
|
||||
const { handle } = page();
|
||||
// A session that dies never settles its open calls on its own; the kernel
|
||||
// hears nothing of a sleep, so the sleep itself must answer them. The
|
||||
// failure is held from the moment it is asked, so an early rejection is
|
||||
// never an unhandled one.
|
||||
const asked = handle.send("Runtime.evaluate", { expression: "1" });
|
||||
const held = asked.then(
|
||||
() => "resolved",
|
||||
(err) => err,
|
||||
);
|
||||
await later(40);
|
||||
assert.ok(views.asleep(handle.targetId));
|
||||
const outcome = await held;
|
||||
assert.match(String(outcome && outcome.message), /went to sleep/);
|
||||
});
|
||||
|
||||
test("a navigation that begins while the picture is taken keeps the page awake", async (t) => {
|
||||
let release;
|
||||
const held = new Promise((resolve) => { release = resolve; });
|
||||
let taken = 0;
|
||||
const { views, page } = sleepingViews({ capturePage: () => { taken++; return held; } });
|
||||
t.after(() => views.closeAll());
|
||||
const { handle } = page();
|
||||
const entry = views.entries.get(handle.targetId);
|
||||
for (let waited = 0; waited < 40 && taken === 0; waited++) await later(5);
|
||||
assert.ok(taken > 0, "the sweep is mid-capture, the picture never coming");
|
||||
// A navigation starts the only way leave() is reached for one: the page is
|
||||
// attached and the app page holds the keyboard, so it is held out of the
|
||||
// window with a settle pending.
|
||||
views.keyboard.owner = "app";
|
||||
entry.attached = true;
|
||||
views.leave(entry);
|
||||
assert.ok(entry.returning, "the settle is pending");
|
||||
release({ isEmpty: () => false, toJPEG: () => Buffer.from("jpeg") });
|
||||
await later(20);
|
||||
assert.equal(views.asleep(handle.targetId), null, "the page was not slept mid-flight");
|
||||
assert.ok(views.entries.get(handle.targetId).view, "and its view is still there");
|
||||
});
|
||||
|
||||
test("a spawn that fails leaves no entry behind", (t) => {
|
||||
const { views } = sleepingViews({ failSpawns: 1 });
|
||||
t.after(() => views.closeAll());
|
||||
let closed = 0;
|
||||
assert.throws(
|
||||
() => views.create({ partition: "p0", url: "https://page.test/", onEvent: () => {}, onClosed: () => closed++, onPopup: () => {}, onDownload: () => {} }),
|
||||
/would not build/,
|
||||
);
|
||||
assert.equal(closed, 0, "the kernel is told nothing of a target it never had");
|
||||
assert.equal(views.entries.size, 0, "and no entry is left behind");
|
||||
});
|
||||
|
||||
test("the nineteenth tombstone costs the oldest its place, and the kernel hears it", async (t) => {
|
||||
const { views, page } = sleepingViews({ maxAwake: 32 });
|
||||
t.after(() => views.closeAll());
|
||||
const pages = [];
|
||||
for (let i = 0; i < 19; i++) pages.push(page());
|
||||
await later(60);
|
||||
assert.ok(views.asleep(pages[18].handle.targetId), "the pages slept");
|
||||
assert.ok(views.asleep(pages[1].handle.targetId), "the younger tombstones keep their place");
|
||||
assert.equal(pages[0].events.closed, 1, "the oldest tombstone was buried and the kernel told");
|
||||
assert.equal(views.asleep(pages[0].handle.targetId), null);
|
||||
assert.equal(views.entries.size, 18, "eighteen tombstones remain");
|
||||
});
|
||||
|
||||
test("a woken page comes back at the viewport the agent set", async (t) => {
|
||||
const { views, created, page } = sleepingViews();
|
||||
t.after(() => views.closeAll());
|
||||
const { handle } = page();
|
||||
const viewport = { width: 390, height: 844, deviceScaleFactor: 0, mobile: false };
|
||||
await handle.send("Emulation.setDeviceMetricsOverride", viewport);
|
||||
await later(40);
|
||||
assert.ok(views.asleep(handle.targetId));
|
||||
await handle.send("Runtime.evaluate", { expression: "1" });
|
||||
assert.equal(created.length, 2, "the wake built a new view");
|
||||
const said = created[1].webContents.commands.map(([method]) => method);
|
||||
assert.deepEqual(said, ["Emulation.setDeviceMetricsOverride", "Runtime.evaluate"], "the viewport is said again to the new session, before the command that woke the page");
|
||||
assert.deepEqual(created[1].webContents.commands[0][1], viewport, "the parameters the agent chose are the ones replayed");
|
||||
});
|
||||
|
||||
test("a woken page's viewport is fitted into its panel again, at the scale it had before the sleep", async (t) => {
|
||||
const { views, created, page } = sleepingViews({ sleepAfterMs: 60_000, sweepMs: 60_000 });
|
||||
t.after(() => views.closeAll());
|
||||
const { handle } = page();
|
||||
await handle.send("Emulation.setDeviceMetricsOverride", { width: 390, height: 844, mobile: true });
|
||||
const rect = { x: 0, y: 0, width: 195, height: 422 };
|
||||
views.show(handle.targetId, rect);
|
||||
const overrides = (which) => created[which].webContents.commands.filter(([m]) => m === "Emulation.setDeviceMetricsOverride");
|
||||
const fitted = overrides(0).at(-1);
|
||||
assert.equal(fitted[1].scale, 0.5, "the show fitted the phone viewport into the panel with a scale");
|
||||
views.hide();
|
||||
await views.sleep(views.entries.get(handle.targetId));
|
||||
assert.ok(views.asleep(handle.targetId), "the page slept");
|
||||
await handle.send("Runtime.evaluate", { expression: "1" });
|
||||
assert.equal(created.length, 2, "the wake built a new view");
|
||||
views.show(handle.targetId, rect);
|
||||
assert.deepEqual(overrides(1).at(-1), fitted, "the new session gets the same viewport and scale the show sent before the sleep");
|
||||
});
|
||||
|
||||
test("a page with a dialog open does not sleep", async (t) => {
|
||||
const { views, created, page } = sleepingViews();
|
||||
t.after(() => views.closeAll());
|
||||
const { handle } = page();
|
||||
const contents = created[0].webContents;
|
||||
// The kernel enables Page at attach, so the dialog events reach the shell
|
||||
// through the debugger's message stream.
|
||||
contents.emit("debugger:message", {}, "Page.javascriptDialogOpening", { message: "really?" }, undefined);
|
||||
await later(40);
|
||||
assert.equal(views.asleep(handle.targetId), null, "a page waiting on its answer is not slept under the question");
|
||||
contents.emit("debugger:message", {}, "Page.javascriptDialogClosed", { result: false }, undefined);
|
||||
await later(40);
|
||||
assert.ok(views.asleep(handle.targetId), "the answered dialog sleeps again");
|
||||
});
|
||||
|
||||
test("a page awaiting a certificate decision does not sleep", async (t) => {
|
||||
const { views, created, page } = sleepingViews();
|
||||
t.after(() => views.closeAll());
|
||||
const { handle } = page();
|
||||
const contents = created[0].webContents;
|
||||
contents.emit(
|
||||
"certificate-error",
|
||||
{ preventDefault() {} },
|
||||
"https://page.test/",
|
||||
"ERR_CERT_AUTHORITY_INVALID",
|
||||
{ fingerprint: "ff:ee", subjectName: "page.test", issuerName: "nobody", validExpiry: 0 },
|
||||
() => {},
|
||||
true,
|
||||
);
|
||||
await later(40);
|
||||
assert.ok(views.entries.get(handle.targetId).certificate, "the certificate waits on the person");
|
||||
assert.equal(views.asleep(handle.targetId), null, "the undecided certificate holds the page awake");
|
||||
views.entries.get(handle.targetId).certificate = null;
|
||||
await later(40);
|
||||
assert.ok(views.asleep(handle.targetId), "once decided, the page sleeps again");
|
||||
});
|
||||
|
||||
test("a page playing audio does not sleep", async (t) => {
|
||||
const { views, created, page } = sleepingViews();
|
||||
t.after(() => views.closeAll());
|
||||
const { handle } = page();
|
||||
const contents = created[0].webContents;
|
||||
contents.audible = true;
|
||||
await later(40);
|
||||
assert.equal(views.asleep(handle.targetId), null, "the sounding page keeps its renderer");
|
||||
contents.audible = false;
|
||||
await later(40);
|
||||
assert.ok(views.asleep(handle.targetId), "the quieted page sleeps again");
|
||||
});
|
||||
|
||||
test("rewind notice dismissal survives the desktop preference disk round trip", (t) => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rewind-notice-prefs-"));
|
||||
const file = path.join(dir, "prefs.json");
|
||||
@@ -2567,6 +3181,22 @@ test("rewind notice dismissal survives the desktop preference disk round trip",
|
||||
assert.deepEqual(loadPrefs(file), record);
|
||||
});
|
||||
|
||||
test("the page-sleep preference round-trips through the desktop preference file", (t) => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "page-sleep-prefs-"));
|
||||
const file = path.join(dir, "prefs.json");
|
||||
t.after(() => { fs.rmSync(file, { force: true }); fs.rmdirSync(dir); });
|
||||
savePrefs(file, { "rx-page-sleep": "on" });
|
||||
assert.deepEqual(loadPrefs(file), { "rx-page-sleep": "on" });
|
||||
savePrefs(file, { "rx-lang": "en" });
|
||||
assert.deepEqual(loadPrefs(file), { "rx-lang": "en" }, "a key the page no longer holds is dropped");
|
||||
});
|
||||
|
||||
test("the shell reads the sleep threshold from the preference", () => {
|
||||
assert.equal(pageSleepAfterMs({}), Infinity, "off until the person turns it on");
|
||||
assert.equal(pageSleepAfterMs({ "rx-page-sleep": "on" }), PAGE_SLEEP_MS);
|
||||
assert.equal(pageSleepAfterMs({ "rx-page-sleep": "garbage" }), Infinity);
|
||||
});
|
||||
|
||||
const { setUnreadBadge } = require("../src/badge.js");
|
||||
|
||||
test("macOS marks and clears the application Dock badge", () => {
|
||||
@@ -3175,3 +3805,119 @@ test("only the app's own window is answered, and nothing is read from the asker"
|
||||
assert.deepEqual(processMemoryHandler(app, () => null)({}), []);
|
||||
assert.deepEqual(processMemoryHandler({ getAppMetrics: () => { throw new Error("x"); } }, () => true)({}), []);
|
||||
});
|
||||
|
||||
test("a pick arms the page, answers what it reports, and a page that cannot answer is cancelled", async () => {
|
||||
const Module = require("node:module");
|
||||
const load = Module._load;
|
||||
Module._load = function (request, ...rest) {
|
||||
if (request === "electron") return { WebContentsView: class {}, session: { fromPartition: () => ({}) } };
|
||||
return load.call(this, request, ...rest);
|
||||
};
|
||||
let BrowserViews;
|
||||
try {
|
||||
({ BrowserViews } = require("../src/browserviews.js"));
|
||||
} finally {
|
||||
Module._load = load;
|
||||
}
|
||||
const win = {
|
||||
on() {},
|
||||
isDestroyed: () => false,
|
||||
isFocused: () => false,
|
||||
webContents: Object.assign(new EventEmitter(), { isFocused: () => false }),
|
||||
getContentBounds: () => ({ width: 1200, height: 800 }),
|
||||
contentView: { addChildView() {}, removeChildView() {} },
|
||||
};
|
||||
const views = new BrowserViews({ win, kernelOrigin: "http://127.0.0.1:1" });
|
||||
const element = { tagName: "img", selector: "main > img", rect: { x: 1, y: 2, width: 3, height: 4 } };
|
||||
const armed = [];
|
||||
const recheck = [];
|
||||
const contents = {
|
||||
isDestroyed: () => false,
|
||||
executeJavaScript: async (code) => {
|
||||
armed.push(code);
|
||||
return { cancelled: false, element };
|
||||
},
|
||||
executeJavaScriptInIsolatedWorld: async (_worldId, scripts) => {
|
||||
recheck.push(scripts[0].code);
|
||||
return "img";
|
||||
},
|
||||
};
|
||||
const shots = [];
|
||||
contents.capturePage = async (at) => {
|
||||
shots.push(at);
|
||||
return { isEmpty: () => true, toDataURL: () => "data:image/png;base64,x" };
|
||||
};
|
||||
views.entries.set("t1", { targetId: "t1", view: { webContents: contents, getBounds: () => ({ width: 200, height: 200 }) } });
|
||||
// Picking only reaches the page that is on screen.
|
||||
views.shown = "t1";
|
||||
|
||||
assert.deepEqual(await views.pick("t1"), { cancelled: false, element });
|
||||
assert.match(armed[0], /__reasonixElementPicker/, "the page is armed with the picker's own script");
|
||||
assert.match(recheck[0], /main > img/, "the shell re-finds the selector in its own world");
|
||||
assert.equal(await views.cancelPick("t1"), true);
|
||||
assert.match(armed[1], /picker\.stop\(\)/, "cancelling disarms the page");
|
||||
assert.deepEqual(await views.pick("gone"), { cancelled: true, reason: "gone" }, "a page that is not there picks nothing");
|
||||
assert.equal(await views.cancelPick("gone"), false);
|
||||
|
||||
// A page that navigated out from under the pick, and one that answered
|
||||
// something that is not an element, both say why rather than "nothing".
|
||||
contents.executeJavaScript = async () => {
|
||||
throw new Error("the page went away");
|
||||
};
|
||||
assert.deepEqual(await views.pick("t1"), { cancelled: true, reason: "navigation" });
|
||||
contents.executeJavaScript = async () => "not an answer";
|
||||
assert.deepEqual(await views.pick("t1"), { cancelled: true, reason: "gone" });
|
||||
|
||||
// The page wrote the answer: the shell keeps known fields, clips them, and
|
||||
// treats a shape it cannot read as gone.
|
||||
contents.executeJavaScript = async () => ({
|
||||
cancelled: false,
|
||||
element: {
|
||||
tagName: "img",
|
||||
selector: "main > img",
|
||||
text: "x".repeat(10_000),
|
||||
extra: "drop me",
|
||||
rect: { x: "nope", y: 2, width: 3, height: 4 },
|
||||
attributes: { id: "a", bad: 5 },
|
||||
},
|
||||
});
|
||||
contents.executeJavaScriptInIsolatedWorld = async () => "img";
|
||||
const bounded = await views.pick("t1");
|
||||
assert.equal(bounded.element.text.length, 4000, "an oversized string is clipped");
|
||||
assert.equal("extra" in bounded.element, false, "an unknown field is dropped");
|
||||
assert.equal("rect" in bounded.element, false, "a rect that is not numbers is dropped");
|
||||
assert.deepEqual(bounded.element.attributes, { id: "a" }, "a non-string attribute is dropped");
|
||||
contents.executeJavaScript = async () => ({
|
||||
cancelled: false,
|
||||
element: { tagName: "img", selector: "main > img", attributes: { ["k".repeat(600)]: "v" } },
|
||||
});
|
||||
const longKey = await views.pick("t1");
|
||||
assert.equal(Object.keys(longKey.element.attributes)[0].length, 500, "an oversized attribute key is clipped like its value");
|
||||
contents.executeJavaScript = async () => ({ cancelled: false, element: { tagName: "img" } });
|
||||
assert.deepEqual(await views.pick("t1"), { cancelled: true, reason: "gone" }, "an element without a selector is gone");
|
||||
|
||||
// A label with a quote and a newline still produces a script that parses.
|
||||
contents.executeJavaScript = async (code) => {
|
||||
armed.push(code);
|
||||
return { cancelled: true, reason: "escape" };
|
||||
};
|
||||
await views.pick("t1", { color: 'a"b\nc' });
|
||||
assert.doesNotThrow(() => new Function(armed[armed.length - 1]), "the label is embedded as JSON");
|
||||
|
||||
// A rect the page wrote is clamped to the view or dropped entirely.
|
||||
for (const [bad, why] of [
|
||||
[{ x: -1, y: 0, width: 10, height: 10 }, "a negative origin is kept and clamped"],
|
||||
[{ x: NaN, y: 0, width: 10, height: 10 }, "a NaN rect is dropped"],
|
||||
[{ x: Infinity, y: 0, width: 10, height: 10 }, "an infinite rect is dropped"],
|
||||
]) {
|
||||
contents.executeJavaScript = async () => ({ cancelled: false, element: { tagName: "img", selector: "main > img", rect: bad } });
|
||||
const picked = await views.pick("t1");
|
||||
if (Number.isFinite(bad.x) && bad.x < 0) {
|
||||
assert.equal(picked.element.rect.x, -1, why);
|
||||
assert.deepEqual(shots[shots.length - 1], { x: 0, y: 0, width: 10, height: 10 }, "the origin is clamped to the view");
|
||||
} else {
|
||||
assert.equal("rect" in picked.element, false, why);
|
||||
assert.equal("shot" in picked, false, "no picture is taken for a rect that is not numbers");
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -7,6 +7,26 @@ reviewed: 2026-09-22
|
||||
|
||||
# Reasonix Studio frontend
|
||||
|
||||
## Feedback contact
|
||||
|
||||
- Studio prefills new reports with the contact value from the last successful
|
||||
submission, stored in this device's local browser storage.
|
||||
- The field stays editable; submitting it empty clears the remembered value.
|
||||
Failed submissions do not replace it.
|
||||
- An unsent draft takes precedence while the page is open.
|
||||
- No feedback body or screenshots are persisted by this preference.
|
||||
|
||||
## Pane workspace metadata
|
||||
|
||||
- Each pane owns one workspace-metadata read after its initial status settles.
|
||||
Chrome and the composer's workspace chip consume that same result.
|
||||
- A failed initial status still allows the read using the runtime's known root;
|
||||
history loads independently of both status and workspace metadata.
|
||||
- Changing the pane's port or directory reads again and discards superseded
|
||||
responses. Focusing an already-open pane reuses its own result.
|
||||
- Opening the workspace chooser still refreshes its branches and worktree
|
||||
sources; it does not issue another workspace-metadata read.
|
||||
|
||||
## Tests
|
||||
|
||||
`pnpm test` runs one worker per test file on the threads pool. Threads rather
|
||||
|
||||
@@ -32,15 +32,15 @@
|
||||
"@codemirror/state": "^6.7.6",
|
||||
"@codemirror/view": "^6.43.14",
|
||||
"@lezer/highlight": "^1.2.5",
|
||||
"@mantine/charts": "9.7.2",
|
||||
"@mantine/core": "9.7.2",
|
||||
"@mantine/hooks": "9.7.2",
|
||||
"@mantine/charts": "9.7.3",
|
||||
"@mantine/core": "9.7.3",
|
||||
"@mantine/hooks": "9.7.3",
|
||||
"@noble/curves": "2.4.0",
|
||||
"codemirror": "^6.0.2",
|
||||
"dompurify": "^3.4.17",
|
||||
"katex": "^0.19.0",
|
||||
"lucide-react": "^1.60.0",
|
||||
"maplibre-gl": "6.14.0",
|
||||
"lucide-react": "^1.61.0",
|
||||
"maplibre-gl": "6.15.0",
|
||||
"react": "^19.3.1",
|
||||
"react-dom": "^19.3.1",
|
||||
"papaparse": "^5.7.0",
|
||||
@@ -68,7 +68,7 @@
|
||||
"jsdom": "^30.1.3",
|
||||
"playwright": "^1.64.1",
|
||||
"typescript": "^7.0.2",
|
||||
"vite": "^8.3.4",
|
||||
"vitest": "^5.0.4"
|
||||
"vite": "^8.3.5",
|
||||
"vitest": "^5.0.5"
|
||||
}
|
||||
}
|
||||
@@ -39,14 +39,20 @@ node perf/rail.mjs # 左栏会话树:2×8 / 10×50 / 20×200 / 40×500
|
||||
node perf/profile.mjs # CPU 采样,按自耗时列热点(流式那一段)
|
||||
node perf/switchprof.mjs # CPU 采样,量点开一份长会话的那一下
|
||||
node perf/panes.mjs # 并行窗格的代价:两个会话同时流式
|
||||
node perf/rail-toggle-panes.mjs # Rail-fold frame cost with 1/3/6 panes; existing-pane scroll retention and background-output follow
|
||||
node perf/verify.mjs # 行为验证:跟随、块的装卸、切页往返、大会话树
|
||||
node perf/panels.mjs # 两侧栏:收起补间、拖动改宽、上下限、窄屏退场
|
||||
node perf/look.mjs # 外观验证:字号、界面缩放、自定义字体、壁纸上传与调节(含焦点)
|
||||
node perf/measure.mjs # Custom conversation percentage, preset retention, narrow composer controls and populated phone layout
|
||||
node perf/theme-schedule.mjs # Scheduled appearance: daily boundaries, midnight, reload/resume, modes, active packs and DST; THEME_SCHEDULE_SHOTS writes wide/phone light/dark screenshots
|
||||
node perf/pick.mjs # 补全菜单:/ 和 @ 的选中态,深浅两色下各走一遍
|
||||
node perf/models.mjs # 连接面板:网关报出一百多个模型时,这一屏还能不能用
|
||||
node perf/side.mjs # 右栏:端点自己写的一句话有多长,都不该改变栏宽
|
||||
node perf/queue.mjs # 待送达:正文读不回来时,「改」不许开出一个空编辑器
|
||||
node perf/reask.mjs # 改写框:没有换行的长段落,框要长到让人读得到全文,宽屏和手机宽度各一遍
|
||||
node perf/composer-reflow.mjs # Draft and skill-chip wrapping during column transitions, interface zoom and native scrollbar gutters
|
||||
node perf/composer-toolbar-row.mjs # The buttons under the composer stay on one row from 900 to 400 px of conversation width, with the widest realistic labels
|
||||
node perf/workbench-save.mjs # File toolbar sizing, Save contrast and edit/save/read states: light/dark × Chinese/English × desktop/browser × wide/phone
|
||||
node perf/onboarding.mjs # First-run setup: zoom-aware card bounds, resize, scrolling, focus and completion
|
||||
node perf/running.mjs # 执行过程:在跑的步骤自己在动、收起后由标题接着动,步骤行没有空栏
|
||||
node perf/lang.mjs # 双语验证:英文启动、界面译文到位、切回中文
|
||||
@@ -80,9 +86,18 @@ node perf/crumb.mjs # 标题栏里会话标题紧跟项目名,长名字也不
|
||||
node perf/hoverradius.mjs # 输入框底行的按钮悬停时圆角和底色一致(浅/深)
|
||||
node perf/jump-ends.mjs # 回到顶部/跳到最新按钮一次点击直达两端(中间有展开块也一样)
|
||||
node perf/sessionrows.mjs # 会话行样式:34px、1px 紧凑间距、单行省略;本机/远程、悬浮/焦点、浅/深、窄/宽
|
||||
node perf/recent-sessions.mjs # Recent: global history paging, finished-session discovery and project location (light/dark × wide/390px)
|
||||
node perf/start.mjs # 经工作台标签/边缘把手打开工作台先出入口页(文件/改动/浏览器),不替人开浏览器;标题栏 Browser 按钮仍直达浏览器(浅/深 × 宽/窄)
|
||||
```
|
||||
|
||||
`theme-schedule.mjs` is a manual smoke, not a default guard. For real native
|
||||
effects, build the normal Studio page and host, then run `pnpm theme-live` from
|
||||
`desktop/electron` with an isolated `REASONIX_HOME`.
|
||||
|
||||
Run it again with the same home and `REASONIX_THEME_RESTART=1` to check restart
|
||||
tint and current-clock reconciliation. It reads actual `nativeTheme.themeSource`
|
||||
and `BrowserWindow` background effects, including rejected IPC reporters.
|
||||
|
||||
`lang.mjs` 从仓库外跑时用 `PERF_SRC` 指向 `src/`(它要读固件源码来决定
|
||||
哪些中文不必翻)。`PERF_URL` 可以指向别的地址;`bench.mjs` 和 `scale.mjs` 另收 `PERF_SCALES`、
|
||||
`PERF_DELTAS`,`rail.mjs` 收 `PERF_TREE`(`区数x每区会话数`,逗号分隔),
|
||||
@@ -90,6 +105,20 @@ node perf/start.mjs # 经工作台标签/边缘把手打开工作台先出
|
||||
往返摆进来,用来分清「等的是这份记录」还是「等的是一次状态读」。
|
||||
性能台自己也读 `?ws=&sess=` 来决定左栏那棵树有多大。
|
||||
|
||||
- `composer-reflow.mjs` arms frame sampling before each viewport change;
|
||||
sampling only after `setViewportSize` returns can miss the stale-mirror frame.
|
||||
It selects `/init`, types the draft, then visits
|
||||
1440 → 960 → 640 → 420 → 1440px at each zoom.
|
||||
- The native-scrollbar case requires a space-consuming gutter, not an exact
|
||||
pixel width; overlay-scrollbar browsers explicitly skip that case.
|
||||
- `?panes=N` seeds the mock hub's shared runtime/session book. Tree rows and open
|
||||
panes must agree on both transcript path and runtime ID.
|
||||
- `rail-toggle-panes.mjs` selects two existing sessions by path, verifies that the
|
||||
original transcript DOM node is hidden and restored, and checks scroll retention
|
||||
while new output arrives in the background.
|
||||
- The same guard checks that a neighbouring pane following the latest output
|
||||
continues to do so. `RAIL_TOGGLE_SHOTS` saves its before/hidden/returned views.
|
||||
|
||||
## 判据
|
||||
|
||||
这几条是这套 UI 的性能契约,回归时先看它们:
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import { chromium } from "playwright";
|
||||
|
||||
const browser = await chromium.launch();
|
||||
const base = process.env.PERF_URL ?? "http://localhost:4399/perf.html";
|
||||
const failures = [];
|
||||
const check = (name, ok, detail) => {
|
||||
console.log(`${ok ? "ok" : "FAIL"} ${name}: ${detail}`);
|
||||
if (!ok) failures.push(name);
|
||||
};
|
||||
|
||||
async function inkBounds(page, png, color) {
|
||||
return page.evaluate(async ({ image, color }) => {
|
||||
const img = new Image();
|
||||
img.src = image;
|
||||
await img.decode();
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = img.width;
|
||||
canvas.height = img.height;
|
||||
const context = canvas.getContext("2d");
|
||||
context.fillStyle = color;
|
||||
context.fillRect(0, 0, 1, 1);
|
||||
const foreground = context.getImageData(0, 0, 1, 1).data;
|
||||
context.drawImage(img, 0, 0);
|
||||
const pixels = context.getImageData(0, 0, img.width, img.height).data;
|
||||
let top = img.height, bottom = -1;
|
||||
for (let y = 0; y < img.height; y++) {
|
||||
for (let x = 0; x < img.width; x++) {
|
||||
const i = (y * img.width + x) * 4;
|
||||
if ([0, 1, 2].every((c) => Math.abs(pixels[i + c] - foreground[c]) < 45)) {
|
||||
top = Math.min(top, y);
|
||||
bottom = Math.max(bottom, y);
|
||||
}
|
||||
}
|
||||
}
|
||||
return { found: bottom >= top, top, bottom, height: img.height };
|
||||
}, { image: `data:image/png;base64,${png.toString("base64")}`, color });
|
||||
}
|
||||
|
||||
try {
|
||||
for (const scheme of ["light", "dark"]) {
|
||||
for (const label of ["Demo", "季谢尔"]) for (const scale of [1, 1.25]) {
|
||||
const page = await browser.newPage({ viewport: { width: 1280, height: 800 }, colorScheme: scheme, deviceScaleFactor: scale });
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem("rx-nav-rail", "off");
|
||||
localStorage.setItem("reasonix.mock.account", "1");
|
||||
});
|
||||
await page.goto(`${base}?pref=zh&turns=3&accountLabel=${encodeURIComponent(label)}`, { waitUntil: "networkidle" });
|
||||
await page.waitForSelector(".studio-user-foot .ini");
|
||||
await page.evaluate(() => document.fonts.ready);
|
||||
const layout = await page.evaluate(() => {
|
||||
const avatar = document.querySelector(".studio-user-foot .ini");
|
||||
const box = avatar.getBoundingClientRect();
|
||||
const button = avatar.closest("button").getBoundingClientRect();
|
||||
return { initial: avatar.textContent, color: getComputedStyle(avatar).color,
|
||||
contained: box.top >= button.top && box.bottom <= button.bottom && box.left >= button.left && box.right <= button.right };
|
||||
});
|
||||
const tag = `${scheme}/${label}/${scale * 100}%`;
|
||||
check(`${tag} avatar inside click target`, layout.contained, JSON.stringify(layout));
|
||||
const png = await page.locator(".studio-user-foot .ini").screenshot();
|
||||
const ink = await inkBounds(page, png, layout.color);
|
||||
check(`${tag} visible glyph is not clipped`, ink.found && ink.top > 0 && ink.bottom < ink.height - 1, JSON.stringify(ink));
|
||||
const centers = [];
|
||||
for (const selector of [".studio-user-foot .ini > span", ".studio-workspace-kind"]) {
|
||||
const bounds = await page.locator(selector).evaluate((el) => {
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(el);
|
||||
const text = range.getBoundingClientRect();
|
||||
const row = el.closest(".studio-user-foot").getBoundingClientRect();
|
||||
return { x: text.x, y: row.y + 4, width: text.width, height: 30, color: getComputedStyle(el).color };
|
||||
});
|
||||
const { color, ...clip } = bounds;
|
||||
const pixels = await inkBounds(page, await page.screenshot({ clip }), color);
|
||||
centers.push(pixels.found ? (pixels.top + pixels.bottom) / (2 * scale) : NaN);
|
||||
}
|
||||
check(`${tag} avatar/workspace ink alignment`,
|
||||
centers.every(Number.isFinite) && Math.abs(centers[0] - centers[1]) <= 1, centers.join(" / "));
|
||||
await page.locator('[data-action="chrome.account"]').click();
|
||||
await page.waitForSelector(".acct");
|
||||
check(`${tag} account identity`, (await page.locator(".acct-who .nm").textContent()) === label, label);
|
||||
await page.close();
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
if (failures.length) process.exitCode = 1;
|
||||
@@ -0,0 +1,127 @@
|
||||
import { chromium } from "playwright";
|
||||
|
||||
const BASE = process.env.PERF_URL ?? "http://localhost:4399/perf.html";
|
||||
const fails = [];
|
||||
const check = (name, ok, detail) => {
|
||||
console.log(`${ok ? " ok" : "FAIL"} ${name}: ${JSON.stringify(detail)}`);
|
||||
if (!ok) fails.push(name);
|
||||
};
|
||||
const textKeys = ["title", "count", "name", "arg", "normalization", "originalInput"];
|
||||
const weightKeys = textKeys.filter(key => key !== "title" && key !== "count");
|
||||
const browser = await chromium.launch();
|
||||
for (const lang of ["zh", "en"]) {
|
||||
for (const fold of [false, true]) {
|
||||
const page = await browser.newPage({ viewport: { width: 1280, height: 800 }, reducedMotion: "reduce" });
|
||||
page.on("pageerror", (e) => fails.push(e.message));
|
||||
await page.goto(`${BASE}?pref=${lang}${fold ? "&fold" : ""}`, { waitUntil: "networkidle" });
|
||||
await page.waitForSelector(".app");
|
||||
await page.evaluate(async () => {
|
||||
const frame = () => new Promise((r) => requestAnimationFrame(r));
|
||||
window.__feed({ kind: "turn_started", authoredTurn: 1, msgIndex: 0, text: "Inspect the file" });
|
||||
window.__feed({ kind: "text", text: "I will inspect it first." });
|
||||
window.__feed({ kind: "message", text: "I will inspect it first." });
|
||||
const tool = {
|
||||
id: "typography-read", name: "read_file", args: '{"path":"src/main.go"}',
|
||||
argumentNormalization: { reason: "json_string_unwrapped", original: '"{\\"path\\":\\"src/main.go\\"}"', effective: '{"path":"src/main.go"}' },
|
||||
};
|
||||
window.__feed({ kind: "tool_dispatch", tool });
|
||||
window.__feed({ kind: "tool_result", tool: { ...tool, output: "package main", durationMs: 120 } });
|
||||
await frame();
|
||||
window.__feed({ kind: "text", text: "The file declares package main." });
|
||||
window.__feed({ kind: "message", text: "The file declares package main." });
|
||||
window.__feed({ kind: "turn_done" });
|
||||
});
|
||||
await page.waitForSelector(".activity-title");
|
||||
while (await page.locator(".activity-group:not([open]) > summary").count()) {
|
||||
await page.locator(".activity-group:not([open]) > summary").first().click();
|
||||
}
|
||||
await page.waitForSelector(".activity-body .hl .nm", { state: "visible" });
|
||||
await page.locator(".activity-body .tool-disclosure > summary .nm").first().click();
|
||||
await page.waitForSelector(".activity-call-input > details > summary", { state: "visible" });
|
||||
check(`${lang}/${fold}: original input starts folded`, await page.locator(".activity-call-input > details").evaluate((el) => !el.open));
|
||||
await page.locator(".activity-call-input > details > summary").click();
|
||||
check(`${lang}/${fold}: original input opens with its source`, await page.locator(".activity-call-input > details pre").textContent() === '"{\\"path\\":\\"src/main.go\\"}"');
|
||||
const measure = async () => {
|
||||
await page.evaluate(() => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))));
|
||||
return page.evaluate(() => {
|
||||
const style = (selector) => {
|
||||
const s = getComputedStyle(document.querySelector(selector));
|
||||
return { size: parseFloat(s.fontSize), weight: Number(s.fontWeight), family: s.fontFamily };
|
||||
};
|
||||
return {
|
||||
title: style(".activity-title"), count: style(".activity-count"),
|
||||
name: style(".activity-body .hl .nm"), arg: style(".activity-body .hl .arg"),
|
||||
normalization: style(".activity-call-input > p"), originalInput: style(".activity-call-input > details > summary"),
|
||||
sidebar: style(".sesstitle"), answer: style('.call[data-k="say"] .out .txt'),
|
||||
};
|
||||
});
|
||||
};
|
||||
const base = await measure();
|
||||
check(`${lang}/${fold}: activity labels preserve the 11px/500 default`, base.title.size === 11 && base.count.size === 11 && base.title.weight === 500 && base.count.weight === 500, { title: base.title, count: base.count });
|
||||
const pick = async (action, index) => {
|
||||
await page.evaluate(() => document.querySelector('[data-action="chrome.settings"]').click());
|
||||
await page.waitForSelector(".prefs-nav button");
|
||||
await page.evaluate(() => document.getElementById("prefs-appearance").click());
|
||||
const variable = action === "appearance.reading-size" ? "--read" : "--fs-scale";
|
||||
const expected = action === "appearance.reading-size"
|
||||
? `${(lang === "zh" ? [12, 13, 15, 17] : [11.5, 12.5, 14.5, 16.5])[index]}px` : "1.3";
|
||||
await page.locator(`[data-action="${action}"]`).nth(index).click();
|
||||
await page.waitForFunction(([name, value]) => document.documentElement.style.getPropertyValue(name) === value, [variable, expected]);
|
||||
await page.keyboard.press("Escape");
|
||||
};
|
||||
await pick("appearance.reading-size", 3);
|
||||
const large = await measure();
|
||||
const ratio = large.answer.size / base.answer.size;
|
||||
const tag = `${lang}/${fold ? "folded turn" : "activity"}`;
|
||||
for (const key of textKeys) {
|
||||
check(`${tag} ${key} follows body size`, Math.abs(large[key].size / base[key].size - ratio) < .02, { before: base[key].size, after: large[key].size, ratio });
|
||||
}
|
||||
check(`${tag} body size leaves sidebar alone`, large.sidebar.size === base.sidebar.size, large.sidebar);
|
||||
await pick("appearance.text-size", 3);
|
||||
const uiLarge = await measure();
|
||||
for (const key of textKeys) {
|
||||
const expectedRatio = key === "title" || key === "count" ? 1.3 : 1;
|
||||
check(`${tag} ${key} ${expectedRatio === 1 ? "independent of" : "scales with"} interface size`,
|
||||
Math.abs(uiLarge[key].size / large[key].size - expectedRatio) < .02, uiLarge[key]);
|
||||
}
|
||||
check(`${tag} interface size still enlarges sidebar`, uiLarge.sidebar.size > large.sidebar.size, uiLarge.sidebar);
|
||||
await page.evaluate(() => { document.documentElement.dataset.weight = "light"; });
|
||||
const light = await measure();
|
||||
await page.evaluate(() => { document.documentElement.dataset.weight = "heavy"; });
|
||||
const heavy = await measure();
|
||||
for (const key of weightKeys) {
|
||||
check(`${tag} ${key} follows weight preference`, heavy[key].weight > light[key].weight, { light: light[key].weight, heavy: heavy[key].weight });
|
||||
}
|
||||
check(`${tag} activity labels retain one 500 weight`, heavy.title.weight === 500 && heavy.count.weight === 500, { title: heavy.title, count: heavy.count });
|
||||
await page.evaluate(() => {
|
||||
document.documentElement.style.setProperty("--ui", "Georgia, serif");
|
||||
document.documentElement.style.setProperty("--mono", "Menlo, monospace");
|
||||
});
|
||||
const fonts = await measure();
|
||||
check(`${tag} labels use prose family; commands use mono`, fonts.title.family.startsWith("Georgia") && fonts.name.family.startsWith("Menlo") && fonts.arg.family.startsWith("Menlo"), fonts);
|
||||
await page.evaluate(() => {
|
||||
window.__feed({ kind: "turn_started", authoredTurn: 2, msgIndex: 1, text: "Inspect failure and running labels" });
|
||||
const failed = { id: "scale-failure", name: "bash", args: '{"command":"false"}' };
|
||||
window.__feed({ kind: "tool_dispatch", tool: failed });
|
||||
window.__feed({ kind: "tool_result", tool: { ...failed, err: "exit status 1", output: "failed", durationMs: 120 } });
|
||||
window.__feed({ kind: "tool_dispatch", tool: { id: "scale-running", name: "bash", args: '{"command":"sleep 10"}' } });
|
||||
});
|
||||
await page.waitForSelector(".activity-running");
|
||||
for (const step of [0, 1, 2, 3]) {
|
||||
await pick("appearance.reading-size", step);
|
||||
await page.evaluate(() => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))));
|
||||
const labels = await page.locator(".activity-group[data-running] > summary").evaluate(el =>
|
||||
[...el.querySelectorAll(".activity-title, .activity-running, .activity-errors, .activity-count")].map(span => {
|
||||
const css = getComputedStyle(span);
|
||||
return { label: span.className, size: parseFloat(css.fontSize), weight: Number(css.fontWeight), lineHeight: css.lineHeight };
|
||||
}));
|
||||
const read = (lang === "zh" ? [12, 13, 15, 17] : [11.5, 12.5, 14.5, 16.5])[step];
|
||||
const expected = 11 * 1.3 * read / (lang === "zh" ? 13 : 12.5);
|
||||
check(`${tag} body step ${step}: title/running/failed/count compose text and reading scales at weight 500`,
|
||||
labels.length === 4 && labels.every(label => Math.abs(label.size - expected) < .01 && label.weight === 500 && label.lineHeight === labels[0].lineHeight), labels);
|
||||
}
|
||||
await page.close();
|
||||
}
|
||||
}
|
||||
await browser.close();
|
||||
if (fails.length) throw new Error(`${fails.length} checks failed: ${fails.join(", ")}`);
|
||||
@@ -0,0 +1,81 @@
|
||||
// A sub-agent opened from the deck lands beside the conversation as a workbench tab that reads the kernel's record;
|
||||
// a call with no run keeps the full-record dialog.
|
||||
import { chromium } from "playwright";
|
||||
|
||||
const BASE = process.env.PERF_URL ?? "http://localhost:4399/perf.html";
|
||||
const SHOTS = process.env.SHOTS;
|
||||
const fails = [];
|
||||
const check = (name, ok, detail = "") => {
|
||||
console.log(`${ok ? " ok" : "FAIL"} ${name}${detail ? " — " + detail : ""}`);
|
||||
if (!ok) fails.push(name);
|
||||
};
|
||||
|
||||
const calls = [
|
||||
{ id: "c1", tool: "list_dir", args: '{"path":"internal/auth"}', out: "session.go\nstore.go\ntoken.go" },
|
||||
{ id: "c2", tool: "read_file", args: '{"path":"internal/auth/session.go"}', out: "package auth\n\nfunc New() *Session { return &Session{} }" },
|
||||
];
|
||||
const messages = [
|
||||
{ role: "user", content: "Audit the session code for token reuse.", msgIndex: 1 },
|
||||
{ role: "assistant", content: "I will list the package, then read the session file.", msgIndex: 2, toolCalls: calls.map((c) => ({ id: c.id, name: c.tool, arguments: c.args })) },
|
||||
...calls.map((c, i) => ({ role: "tool", content: c.out, toolCallId: c.id, toolName: c.tool, msgIndex: 3 + i })),
|
||||
{ role: "assistant", content: "## Finding\n\nTokens are rotated on every refresh; I found no reuse path.", msgIndex: 5 },
|
||||
];
|
||||
const runs = [{ run: { ref: "sa_1", name: "security-review", kind: "task", model: "deepseek/deepseek-v4-pro", state: "completed", parentCall: "a1", startedAt: 1, updatedAt: 2 }, messages }];
|
||||
|
||||
const browser = await chromium.launch();
|
||||
for (const scheme of ["light", "dark"]) {
|
||||
const page = await browser.newPage({ viewport: { width: 1440, height: 900 }, colorScheme: scheme, reducedMotion: "reduce" });
|
||||
page.setDefaultTimeout(8000);
|
||||
page.on("pageerror", (e) => fails.push("page error: " + e.message));
|
||||
await page.goto(`${BASE}?pref=en`, { waitUntil: "networkidle" });
|
||||
await page.waitForSelector(".app");
|
||||
await page.evaluate((runs) => {
|
||||
window.__subagents(runs);
|
||||
const args = '{"description":"review","subagent_type":"security-review"}';
|
||||
for (const [id, name] of [["a1", "security-review"], ["a2", "other"]]) {
|
||||
window.__feed({ kind: "tool_dispatch", tool: { id, name: "task", profile: { name }, args } });
|
||||
window.__feed({ kind: "tool_result", tool: { id, name: "task", profile: { name }, args, output: "done" } });
|
||||
}
|
||||
}, runs);
|
||||
await page.evaluate(() => {
|
||||
window.__feed({ kind: "turn_started", authoredTurn: 1, msgIndex: 0, text: "go" });
|
||||
window.__feed({ kind: "text", text: "Both reviews are done." });
|
||||
});
|
||||
await page.waitForSelector(".studio-deckchip");
|
||||
await page.locator('[data-action="deck.agents"]').hover();
|
||||
await page.locator('.studio-deck-pop .ag-open[data-target]').first().waitFor();
|
||||
const rows = await page.locator(".studio-deck-pop .ag").count();
|
||||
check(`${scheme}: the deck lists both delegations`, rows === 2, `rows=${rows}`);
|
||||
|
||||
await page.locator('.studio-deck-pop .ag', { hasText: "security-review" }).locator('[data-action="agent.open"]').click();
|
||||
await page.locator('.workbench-tab-pick', { hasText: "security-review" }).waitFor();
|
||||
await page.locator(".agentrun .txt", { hasText: "rotated on every refresh" }).waitFor();
|
||||
await page.waitForFunction(() => !document.querySelector(".agentrun")?.textContent.includes("## Finding"));
|
||||
const text = await page.locator(".agentrun").evaluate((el) => el.textContent);
|
||||
check(`${scheme}: the run opens as a workbench tab, not a dialog`, (await page.locator(".agent-tx").count()) === 0);
|
||||
check(`${scheme}: it shows the model, the state and the final answer`, /deepseek-v4-pro/.test(text) && /Completed|Done/.test(text) && /rotated on every refresh/.test(text), text.slice(0, 160));
|
||||
check(`${scheme}: the user's task is in it and the tool steps are folded under one line`, /Audit the session code/.test(text) && (await page.locator(".agentrun .activity-group").count()) === 1 && (await page.locator(".agentrun .activity-group[open]").count()) === 0);
|
||||
await page.locator(".agentrun .activity-group > summary").click();
|
||||
await page.locator(".agentrun .activity-body").waitFor();
|
||||
check(`${scheme}: opening the fold shows the steps`, /list_dir|List|session\.go/.test(await page.locator(".agentrun .activity-body").evaluate((el) => el.textContent)));
|
||||
const box = await page.evaluate(() => {
|
||||
const hd = document.querySelector(".agentrun-hd").getBoundingClientRect();
|
||||
const wb = document.querySelector(".workbench").getBoundingClientRect();
|
||||
const flow = document.querySelector(".agentrun .flow").getBoundingClientRect();
|
||||
return { hdRight: hd.right, wbRight: wb.right, flowRight: flow.right, flowLeft: flow.left, wbLeft: wb.left };
|
||||
});
|
||||
if (SHOTS) await page.screenshot({ path: `${SHOTS}-tab-${scheme}.png` });
|
||||
check(`${scheme}: the record stays inside the workbench column`, box.flowRight <= box.wbRight + 1 && box.flowLeft >= box.wbLeft - 1, JSON.stringify(box));
|
||||
|
||||
await page.locator('[data-action="deck.agents"]').hover();
|
||||
await page.locator('.studio-deck-pop .ag', { hasText: "other" }).locator('[data-action="agent.open"]').click();
|
||||
await page.locator(".agent-tx").waitFor();
|
||||
check(`${scheme}: a call with no run keeps the full-record dialog`, true);
|
||||
await page.close();
|
||||
}
|
||||
await browser.close();
|
||||
if (fails.length) {
|
||||
console.log(`\n${fails.length} failed`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log("\nall ok");
|
||||
@@ -0,0 +1,99 @@
|
||||
import { chromium } from "playwright";
|
||||
import { mkdirSync } from "node:fs";
|
||||
|
||||
const url = process.env.PERF_URL ?? "http://localhost:4399/perf.html";
|
||||
const shots = process.env.SHOTS ?? "/tmp/reasonix-14759-visibility";
|
||||
mkdirSync(shots, { recursive: true });
|
||||
const browser = await chromium.launch();
|
||||
const page = await browser.newPage({ locale: "zh-CN" });
|
||||
const errors = [];
|
||||
page.on("pageerror", (error) => errors.push(error.message));
|
||||
const box = page.locator(".compose textarea");
|
||||
function check(ok, message) {
|
||||
if (!ok) throw new Error(message);
|
||||
}
|
||||
async function stable() {
|
||||
await box.waitFor();
|
||||
await page.addStyleTag({ content: "*, *::before, *::after { transition: none !important; animation: none !important; }" });
|
||||
await page.evaluate(() => document.fonts.ready);
|
||||
}
|
||||
async function hint(on, shot) {
|
||||
const settings = page.locator('[data-action="chrome.settings"]');
|
||||
const menu = page.locator("button.studio-menu");
|
||||
if (await menu.isVisible() && await menu.getAttribute("aria-pressed") !== "true") await menu.click();
|
||||
await settings.click();
|
||||
await page.locator('.prefs-sheet input[data-action-keydown="settings.search"]').fill("placeholder");
|
||||
await page.locator('[role="option"][data-target="composer-hint"]').click();
|
||||
const section = page.locator('[data-setting="composer-hint"]');
|
||||
await section.locator(`[data-value="${on ? "on" : "off"}"]`).click();
|
||||
check(await section.locator(`[data-value="${on ? "on" : "off"}"]`).getAttribute("aria-pressed") === "true", "Setting must reflect the selected visibility");
|
||||
check(await section.locator('[data-apply="immediate"]').count() === 1, "Visibility must be immediate");
|
||||
if (shot) {
|
||||
await section.evaluate((el) => el.scrollIntoView({ block: "center" }));
|
||||
await page.screenshot({ path: `${shots}/${shot}.png` });
|
||||
}
|
||||
await page.locator('button[data-action="settings.close"]').click();
|
||||
}
|
||||
async function visible(on) {
|
||||
await page.waitForFunction((on) => Boolean(document.querySelector(".compose textarea")?.placeholder) === on, on);
|
||||
check(Boolean(await box.getAttribute("aria-label")), "Hidden hint must retain the accessible input label");
|
||||
check(Boolean(await box.getAttribute("aria-describedby")), "Input instructions must remain associated");
|
||||
}
|
||||
try {
|
||||
for (const width of [1440, 390]) {
|
||||
await page.setViewportSize({ width, height: 900 });
|
||||
for (const theme of ["light", "dark"]) {
|
||||
await page.goto(url, { waitUntil: "networkidle" });
|
||||
await stable();
|
||||
if (await page.locator("html").getAttribute("data-theme") !== theme) await page.locator('[data-action="appearance.theme"]').click();
|
||||
await visible(true);
|
||||
check(await box.evaluate((el) => getComputedStyle(el, "::placeholder").opacity) === "1", "Visible guidance must retain full theme opacity");
|
||||
await page.screenshot({ path: `${shots}/${theme}-${width}-on.png` });
|
||||
await hint(false, `${theme}-${width}-settings-off`);
|
||||
await visible(false);
|
||||
await page.screenshot({ path: `${shots}/${theme}-${width}-off.png` });
|
||||
await page.reload({ waitUntil: "networkidle" });
|
||||
await stable();
|
||||
await visible(false);
|
||||
await page.screenshot({ path: `${shots}/${theme}-${width}-off-reloaded.png` });
|
||||
await box.fill("preserved draft");
|
||||
const ink = await box.evaluate((el) => ({ color: getComputedStyle(el).color, opacity: getComputedStyle(el).opacity }));
|
||||
check(ink.opacity === "1", "Entered text must retain full opacity");
|
||||
await hint(true);
|
||||
await visible(true);
|
||||
check(await box.inputValue() === "preserved draft", "Changing hint must not alter the draft");
|
||||
check(await box.evaluate((el) => getComputedStyle(el).color) === ink.color, "Changing hint must not alter entered ink");
|
||||
await box.fill("");
|
||||
await page.reload({ waitUntil: "networkidle" });
|
||||
await stable();
|
||||
await visible(true);
|
||||
console.log(`PASS: ${theme}/${width} default on, Appearance off/on, draft and reload persistence`);
|
||||
}
|
||||
}
|
||||
await page.setViewportSize({ width: 1440, height: 900 });
|
||||
await page.reload({ waitUntil: "networkidle" });
|
||||
await stable();
|
||||
await hint(false);
|
||||
await box.fill("visibility smoke input");
|
||||
check(await box.evaluate((el) => document.activeElement === el), "Typing must focus the composer");
|
||||
await box.evaluate((el) => el.dispatchEvent(new CompositionEvent("compositionstart", { bubbles: true })));
|
||||
await box.press("Enter");
|
||||
check(await page.evaluate(() => !window.__sent?.length), "IME Enter must not submit");
|
||||
await box.evaluate((el) => el.dispatchEvent(new CompositionEvent("compositionend", { bubbles: true })));
|
||||
await page.waitForTimeout(120);
|
||||
await box.press("Enter");
|
||||
await page.waitForFunction(() => window.__sent?.includes("visibility smoke input"));
|
||||
await page.goto(`${url}?queue=3&running=1`, { waitUntil: "networkidle" });
|
||||
await stable();
|
||||
await page.locator(".queue .qi").first().waitFor();
|
||||
await visible(false);
|
||||
check(await page.locator(".queue .qi").count() === 3, "Hiding hint must retain queued content");
|
||||
await hint(true);
|
||||
await visible(true);
|
||||
check(await page.locator(".queue .qi").count() === 3, "Showing hint must retain queued content");
|
||||
await page.screenshot({ path: `${shots}/queue-hint-on.png` });
|
||||
check(errors.length === 0, errors.join("\n"));
|
||||
console.log(`PASS: accessible label, entered ink, focus, IME, submit and queue; screenshots: ${shots}`);
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { chromium } from "playwright";
|
||||
|
||||
const base = process.env.PERF_URL ?? "http://localhost:4399/perf.html?pref=zh&turns=4";
|
||||
const browser = await chromium.launch({ ignoreDefaultArgs: ["--hide-scrollbars"] });
|
||||
const box = 'textarea[role="combobox"]';
|
||||
const text = "planning这个skill刚刚更新完,并且跑了一次情报规划任务,现在需要对collection技能做同步优化。";
|
||||
try {
|
||||
for (const [zoom, scrollbar] of [[1, false], [0.85, false], [1.5, false], [1, true]]) {
|
||||
const page = await browser.newPage({ viewport: { width: 1440, height: 900 }, colorScheme: scrollbar ? "light" : "dark" });
|
||||
page.setDefaultTimeout(8000);
|
||||
const errors = [];
|
||||
page.on("pageerror", (error) => errors.push(error.message));
|
||||
const url = new URL(base);
|
||||
url.searchParams.set("zoom", String(zoom));
|
||||
await page.goto(url.href, { waitUntil: "networkidle" });
|
||||
await page.evaluate(() => document.fonts.ready);
|
||||
if (scrollbar) {
|
||||
await page.addStyleTag({ content: ".compose textarea { overflow-y: scroll; scrollbar-gutter: stable; scrollbar-width: auto; } .compose textarea::-webkit-scrollbar { width: 14px; }" });
|
||||
if (await page.locator(box).evaluate((el) => el.offsetWidth === el.clientWidth)) {
|
||||
console.log("SKIP native scrollbar gutter: this browser uses overlay scrollbars");
|
||||
await page.close();
|
||||
continue;
|
||||
}
|
||||
}
|
||||
await page.locator(box).fill("/init");
|
||||
await page.locator(".slashmenu").waitFor();
|
||||
await page.locator(box).press("Enter");
|
||||
await page.locator(".skillchip").waitFor();
|
||||
await page.locator(box).pressSequentially(text.repeat(4));
|
||||
for (const width of [1440, 960, 640, 420, 1440]) {
|
||||
await page.evaluate(() => {
|
||||
window.__reflowFrames = [];
|
||||
const sample = () => {
|
||||
if (!window.__reflowFrames) return;
|
||||
const input = document.querySelector('textarea[role="combobox"]');
|
||||
const mirror = document.querySelector(".chipmirror");
|
||||
const compose = document.querySelector(".compose").getBoundingClientRect();
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(mirror);
|
||||
window.__reflowFrames.push({
|
||||
text: input.value,
|
||||
caret: input.selectionStart,
|
||||
scrollbar: input.offsetWidth - input.clientWidth,
|
||||
inputWidth: input.getBoundingClientRect().width,
|
||||
mirrorWidth: mirror.getBoundingClientRect().width,
|
||||
inputScroll: input.scrollHeight,
|
||||
mirrorScroll: mirror.scrollHeight,
|
||||
outside: [...range.getClientRects()].filter((r) => r.right > compose.right + 1 || r.left < compose.left - 1).length,
|
||||
});
|
||||
requestAnimationFrame(sample);
|
||||
};
|
||||
requestAnimationFrame(sample);
|
||||
});
|
||||
await page.setViewportSize({ width, height: 900 });
|
||||
const states = await page.evaluate(async () => {
|
||||
for (let frame = 0; frame < 15; frame++) await new Promise(requestAnimationFrame);
|
||||
const states = window.__reflowFrames;
|
||||
window.__reflowFrames = null;
|
||||
return states;
|
||||
});
|
||||
for (const state of states) {
|
||||
assert.equal(state.text, "/init " + text.repeat(4));
|
||||
assert.equal(state.caret, state.text.length);
|
||||
assert.ok(Math.abs(state.inputWidth - state.mirrorWidth) <= 1, `mirror follows the resized input: ${JSON.stringify(state)}`);
|
||||
assert.ok(Math.abs(state.inputScroll - state.mirrorScroll) <= 1, `mirror and caret wrap on the same lines: ${JSON.stringify(state)}`);
|
||||
assert.equal(state.outside, 0, "draft glyphs stay inside the composer");
|
||||
if (scrollbar) assert.ok(state.scrollbar >= 14, "exercise a space-consuming native scrollbar");
|
||||
}
|
||||
console.log(`PASS zoom=${zoom} width=${width} scrollbar=${scrollbar}: draft, caret, wrapping and containment through the transition`);
|
||||
}
|
||||
assert.deepEqual(errors, []);
|
||||
await page.close();
|
||||
}
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { chromium } from "playwright";
|
||||
|
||||
const base = process.env.PERF_URL ?? "http://localhost:4399/perf.html";
|
||||
const browser = await chromium.launch();
|
||||
const failures = [];
|
||||
try {
|
||||
for (const scheme of ["light", "dark"]) {
|
||||
for (const [pref, zoom] of [["zh", 1], ["en", 1], ["zh", 1.25], ["en", 1.25]]) {
|
||||
const page = await browser.newPage({ viewport: { width: 1500, height: 900 }, colorScheme: scheme, reducedMotion: "reduce" });
|
||||
page.setDefaultTimeout(8000);
|
||||
await page.goto(`${base}?pref=${pref}&ws=1&sess=1&turns=1&fullaccess&effort=max&modelref=workbuddy/deepseek-v4-pro-long-name&zoom=${zoom}`, { waitUntil: "networkidle" });
|
||||
await page.waitForSelector(".turntools");
|
||||
await page.evaluate(() => document.fonts.ready);
|
||||
const wrapped = [];
|
||||
for (let width = 900; width >= 400; width -= 10) {
|
||||
const rows = await page.evaluate((w) => {
|
||||
document.querySelector(".pane").style.width = `${w}px`;
|
||||
const tools = document.querySelector(".turntools");
|
||||
const centers = [...tools.children]
|
||||
.map((el) => el.getBoundingClientRect())
|
||||
.filter((box) => box.width > 0 && box.height > 0)
|
||||
.map((box) => box.top + box.height / 2)
|
||||
.sort((a, b) => a - b);
|
||||
return centers.reduce((rows, y, i) => (i && y - centers[i - 1] > 8 ? rows + 1 : rows), 1);
|
||||
}, width);
|
||||
if (rows > 1) wrapped.push(width);
|
||||
}
|
||||
console.log(`${wrapped.length === 0 ? "PASS" : "FAIL"} ${scheme} ${pref} zoom ${zoom}: toolbar stays on one row from 900 to 400 px${wrapped.length ? `; wraps at ${wrapped.join(", ")}` : ""}`);
|
||||
if (wrapped.length) failures.push(`${scheme}/${pref}/${zoom}`);
|
||||
await page.close();
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
assert.deepEqual(failures, [], "the composer toolbar wrapped to a second row");
|
||||
@@ -164,7 +164,7 @@ const settled = async (page) => {
|
||||
};
|
||||
|
||||
const found = new Map();
|
||||
for (const scheme of ["light", "dark"]) {
|
||||
for (const scheme of ["light", "dark"]) for (const tier of ["", "soft"]) {
|
||||
const page = await browser.newPage({ viewport: { width: 1512, height: 950 }, locale: "zh-CN", colorScheme: scheme });
|
||||
page.on("pageerror", (e) => fails.push("页面异常: " + e.message));
|
||||
// The receipt card is off unless a machine asked for it, and an unrendered
|
||||
@@ -177,6 +177,11 @@ for (const scheme of ["light", "dark"]) {
|
||||
});
|
||||
await page.goto(PAGE, { waitUntil: "networkidle" });
|
||||
await page.waitForSelector(".compose");
|
||||
await page.evaluate(({ scheme, tier }) => {
|
||||
document.documentElement.dataset.theme = scheme;
|
||||
if (tier) document.documentElement.dataset.contrast = tier;
|
||||
else delete document.documentElement.dataset.contrast;
|
||||
}, { scheme, tier });
|
||||
await page.waitForTimeout(400);
|
||||
|
||||
// 一屏里画不出所有状态:审批、提问、失败的调用、设置页各自带着自己的配色。
|
||||
@@ -223,11 +228,11 @@ for (const scheme of ["light", "dark"]) {
|
||||
f({ kind: "turn_done", receipt: { verdict: "partial", changes: [{ path: "a.go", reviewed: true }], verifications: [{ command: "go test ./...", passed: false, stale: true }], gaps: [{ kind: "unverified_mutation", detail: "改了没跑" }], risks: ["只在 darwin 验过"], unverified: ["Windows"], saysSomething: true } });
|
||||
});
|
||||
await settled(page);
|
||||
for (const row of await sweep(page, scheme, "工作台")) found.set(row.key, row);
|
||||
for (const row of await sweep(page, `${scheme}/${tier || "unset"}`, "工作台")) found.set(row.key, row);
|
||||
|
||||
await page.locator('[data-action="chrome.settings"]').first().click();
|
||||
await settled(page);
|
||||
for (const row of await sweep(page, scheme, "设置")) if (!found.has(row.key)) found.set(row.key, row);
|
||||
for (const row of await sweep(page, `${scheme}/${tier || "unset"}`, "设置")) if (!found.has(row.key)) found.set(row.key, row);
|
||||
await page.close();
|
||||
}
|
||||
|
||||
|
||||
@@ -41,7 +41,6 @@ try {
|
||||
['.chrome [data-action="settings.section"][data-value="tools"]', "沙盒与运行边界"],
|
||||
['[data-action="pane.view"][data-value="analysis"]', "打开运行分析"],
|
||||
['[data-action="pane.view"][data-value="browser"]', "关闭工作台"],
|
||||
['[data-action="browser.wide"]', "加宽工作台"],
|
||||
['[data-action="browser.full"]', "全屏工作台"],
|
||||
['[data-action="workbench.files"]', "展开文件树"],
|
||||
['[data-action="workbench.new"]', "新建标签"],
|
||||
@@ -56,13 +55,13 @@ try {
|
||||
assert.equal(await button.getAttribute("title"), null);
|
||||
const box = await tip.boundingBox();
|
||||
assert.ok(box.x >= 0 && box.y >= 0 && box.x + box.width <= 1440 && box.y + box.height <= 960);
|
||||
if (selector.includes("browser.wide")) await page.screenshot({ path: "/tmp/reasonix-tooltip-workbench.png" });
|
||||
if (selector.includes("browser.full")) await page.screenshot({ path: "/tmp/reasonix-tooltip-workbench.png" });
|
||||
if (selector.includes("reply.quote")) await page.screenshot({ path: "/tmp/reasonix-tooltip-reply.png" });
|
||||
await page.mouse.move(1, 1);
|
||||
await tip.waitFor({ state: "detached" });
|
||||
}
|
||||
await page.locator('[data-action="browser.wide"]').focus();
|
||||
await page.getByRole("tooltip").filter({ hasText: "加宽工作台" }).waitFor();
|
||||
await page.locator('[data-action="browser.full"]').focus();
|
||||
await page.getByRole("tooltip").filter({ hasText: "全屏工作台" }).waitFor();
|
||||
await page.keyboard.press("Escape");
|
||||
assert.equal(await page.locator(".icon-tooltip").count(), 0);
|
||||
await page.getByRole("button", { name: "下一页", exact: true }).click();
|
||||
|
||||
@@ -2,6 +2,8 @@ import { createRoot } from "react-dom/client";
|
||||
import { boot as bootLang } from "../src/i18n";
|
||||
import { track as trackWidth } from "../src/ui/viewport";
|
||||
import { pushToast } from "../src/state/toasts";
|
||||
import { apply as applyPack } from "../src/ui/theme";
|
||||
import { mount as mountEditor } from "./syntax-mount";
|
||||
import "../src/styles/tokens.css";
|
||||
import "../src/styles/app.css";
|
||||
import "../src/styles/dock.css";
|
||||
@@ -26,6 +28,7 @@ import "../src/styles/stophint.css";
|
||||
import "../src/styles/dialogcenter.css";
|
||||
import "../src/styles/treerows.css";
|
||||
import "../src/styles/workbench-changes.css";
|
||||
import "../src/styles/agentrun.css";
|
||||
import "../src/styles/gitgraph.css";
|
||||
import "../src/styles/tips.css";
|
||||
import "../src/styles/waitnote.css";
|
||||
@@ -52,11 +55,13 @@ import { MockPort } from "../src/port/mock";
|
||||
import { fromHistory } from "../src/state/session";
|
||||
import { HttpError } from "../src/port/port";
|
||||
import type { AgentPort, Appearance, ChipCall, HistoryMessage, McpEntry } from "../src/port/port";
|
||||
import type { HistoryPage } from "../src/port/session";
|
||||
import type { UpdateProgress } from "../src/port/version";
|
||||
import type { ThemePack } from "../src/port/look";
|
||||
import type { RuntimeView, TreeWindow, TreeWorkspace } from "../src/port/hub";
|
||||
import type { WireEvent } from "../src/port/wire";
|
||||
import type { PreviewInfo } from "../src/port/workspace";
|
||||
import type { SubagentRun } from "../src/port/subagent";
|
||||
import { deliveryContents, deliveryHistory } from "./delivery-data";
|
||||
|
||||
// Saves are coalesced (src/ui/trailing.ts), and a fixture answers too fast for
|
||||
@@ -98,8 +103,14 @@ function benchPack(): ThemePack | null {
|
||||
}
|
||||
|
||||
class BenchPort extends MockPort {
|
||||
private driverRunning: boolean | undefined;
|
||||
opensFiles() { return query.has("deliveries") || super.opensFiles(); }
|
||||
revealsFiles() { return query.has("deliveries") || super.revealsFiles(); }
|
||||
async account() {
|
||||
const state = await super.account();
|
||||
const label = query.get("accountLabel");
|
||||
return label && state.signedIn && state.user ? { ...state, user: { ...state.user, label } } : state;
|
||||
}
|
||||
// ?mcpactions=1 adds a server whose tools are withheld and one that is followed.
|
||||
constructor() {
|
||||
super();
|
||||
@@ -296,9 +307,35 @@ class BenchPort extends MockPort {
|
||||
}
|
||||
|
||||
private settingCardSent = false;
|
||||
private receiptSent = false;
|
||||
|
||||
async receiptSettings() {
|
||||
const read = await super.receiptSettings();
|
||||
return query.has("receipt") ? { ...read, show: true } : read;
|
||||
}
|
||||
private meterSent = false;
|
||||
|
||||
subscribe(on: (e: WireEvent) => void) {
|
||||
this.subs.add(on);
|
||||
if (query.has("meter") && !this.meterSent) {
|
||||
this.meterSent = true;
|
||||
const usage = (amount: number) => ({ cacheHitTokens: 90, cacheMissTokens: 10, completionTokens: 40, totalTokens: 140, cost: amount, currency: "CNY", currencyCode: "CNY", source: "executor" });
|
||||
setTimeout(() => {
|
||||
for (const [msgIndex, amount] of [[1, 1.2], [2, 0.35]] as const) {
|
||||
this.feed({ kind: "turn_started", msgIndex } as unknown as WireEvent);
|
||||
this.feed({ kind: "usage", usage: usage(amount) } as unknown as WireEvent);
|
||||
this.feed({ kind: "turn_done" } as unknown as WireEvent);
|
||||
}
|
||||
}, 500);
|
||||
}
|
||||
if (query.has("receipt") && !this.receiptSent) {
|
||||
this.receiptSent = true;
|
||||
const script = "cd \"C:/Users/me/work\" && cat > \"$TMPDIR/check/verify_006.py\" <<'PYEOF'\n" + "import os, re, sys, unittest\nclass T(unittest.TestCase):\n def test_a(self):\n self.assertEqual(1, 1)\n".repeat(18) + "PYEOF\npython -m unittest verify_006 2>&1 | tail -12";
|
||||
setTimeout(() => {
|
||||
this.feed({ kind: "turn_started", msgIndex: 9 } as unknown as WireEvent);
|
||||
this.feed({ kind: "turn_done", receipt: { saysSomething: true, severity: "warn", gaps: [{ kind: "failed_verification", detail: script }, { kind: "inconclusive_verification", detail: "go test ./internal/... 2>&1 | tail -5" }] } } as unknown as WireEvent);
|
||||
}, 500);
|
||||
}
|
||||
const card = query.get("settingcard");
|
||||
if (card && !this.settingCardSent) {
|
||||
this.settingCardSent = true;
|
||||
@@ -308,6 +345,8 @@ class BenchPort extends MockPort {
|
||||
}
|
||||
|
||||
feed(ev: WireEvent) {
|
||||
if (ev.kind === "turn_started") this.driverRunning = true;
|
||||
if (ev.kind === "turn_done") this.driverRunning = false;
|
||||
// The driver's frames are the fixture's own frames: a fed approval has to
|
||||
// move /status the way a scripted one does, or a guard reads a card sitting
|
||||
// over a status that still says the turn is running.
|
||||
@@ -322,6 +361,20 @@ class BenchPort extends MockPort {
|
||||
return query.has("deliveries") ? deliveryHistory() : storedHistory();
|
||||
}
|
||||
|
||||
// ?paged=N serves that transcript the way a paging kernel does: N messages a
|
||||
// page, counted back from the newest. Without the parameter the answer is 404
|
||||
// and the read falls back to full history, so scripts written before paging
|
||||
// keep measuring what they measured.
|
||||
async historyPage(cursor?: string): Promise<HistoryPage> {
|
||||
const size = Number(query.get("paged") ?? 0);
|
||||
if (!(size > 0)) throw new HttpError(404, "paged history is off");
|
||||
const all = query.has("deliveries") ? deliveryHistory() : storedHistory();
|
||||
const end = cursor ? Number(cursor) : all.length;
|
||||
const start = Math.max(0, end - size);
|
||||
return { messages: all.slice(start, end), toolContext: [], snapshot: "harness", start, end, total: all.length,
|
||||
hasMoreBefore: start > 0, nextCursor: start > 0 ? String(start) : undefined };
|
||||
}
|
||||
|
||||
// ?queue= fills the pending queue to the count under measurement. Seeded
|
||||
// here rather than by calling steer sixty-four times: what the guard asks is
|
||||
// what a full queue does to the layout, not how it got full.
|
||||
@@ -333,7 +386,8 @@ class BenchPort extends MockPort {
|
||||
// Lines the person queued: the panel leaves out the host's own queued
|
||||
// continuations, so a seed of those would draw nothing.
|
||||
intent: (QUEUE_IMAGES && i === 0 ? "steer" : "followup") as "steer" | "followup",
|
||||
state: "queued" as const,
|
||||
state: QUEUE_STATES && i < 2 ? (i === 0 ? "blocked" as const : "uncertain" as const) : "queued" as const,
|
||||
...(QUEUE_STATES && i < 2 ? { blockReason: "This queued instruction needs review before delivery. ".repeat(5) } : {}),
|
||||
...(QUEUE_IMAGES && i === 0 ? { images: true } : {}),
|
||||
// 三种长度,各有各的对照:第一条没被截断(不该弹),第二条长到撞天花板
|
||||
// (浮层要自己滚),其余是寻常的被截断行。内核把 preview 截到 120 runes,
|
||||
@@ -369,7 +423,8 @@ class BenchPort extends MockPort {
|
||||
const goalRuntime = usage
|
||||
? { turnsUsed: 4, tokensUsed: 128_400, requestsUsed: 9, workDurationMs: 12 * 60_000, noProgressTurns: usage === "blocked" ? 2 : 0, ...(usage === "blocked" ? { stopCause: "goal_stuck", lastReason: "测试一直没有通过,改动没有让失败的数量减少" } : {}) }
|
||||
: undefined;
|
||||
const st = { ...(await super.status()), ...(query.has("running") ? { running: true } : {}), ...(query.has("goal") ? { goal: query.get("goal") ?? "" } : {}), ...(usage ? { goalStatus: usage, goalRuntime } : {}), ...(query.has("trustpending") ? { toolApprovalMode: "ask" as const, approvalDefault: { defaulted: true, writesConfined: true, trust: "" as const, trustable: true } } : {}) };
|
||||
const st = { ...(await super.status()), ...(query.has("running") ? { running: true } : {}), ...(query.has("goal") ? { goal: query.get("goal") ?? "" } : {}), ...(usage ? { goalStatus: usage, goalRuntime } : {}), ...(query.has("fullaccess") ? { toolApprovalMode: "yolo" as const } : {}), ...(query.has("effort") ? { effort: query.get("effort") ?? "" } : {}), ...(query.has("modelref") ? { modelRef: query.get("modelref") ?? "" } : {}), ...(query.has("trustpending") ? { toolApprovalMode: "ask" as const, approvalDefault: { defaulted: true, writesConfined: true, trust: "" as const, trustable: true } } : {}) };
|
||||
st.running = this.driverRunning ?? st.running;
|
||||
if (STATUS_MS > 0) await new Promise((r) => setTimeout(r, STATUS_MS));
|
||||
if (!JOBS) return st;
|
||||
const jobs = Array.from({ length: JOBS }, (_, i) => ({
|
||||
@@ -448,6 +503,7 @@ const SWITCH_MS = Number(query.get("switchms") ?? 0);
|
||||
// ?queue= is how many lines are already waiting when the window opens.
|
||||
const QUEUE = Number(query.get("queue") ?? 0);
|
||||
const QUEUE_BODY = query.has("queuebody");
|
||||
const QUEUE_STATES = query.has("queuestates");
|
||||
// ?queueimages makes the first seeded line a steer that carries a picture.
|
||||
const QUEUE_IMAGES = query.has("queueimages");
|
||||
const JOBS = Number(query.get("jobs") ?? 0);
|
||||
@@ -455,12 +511,16 @@ const JOBS = Number(query.get("jobs") ?? 0);
|
||||
// window can be seen before the kernel has named a single folder.
|
||||
const TREE_AT = performance.now() + Number(query.get("treems") ?? 0);
|
||||
// ?panes=0 opens the window with no conversation open; ?panes=N opens N.
|
||||
// ?tree=none answers every tree read with no folders.
|
||||
const PANES = query.has("panes") ? Number(query.get("panes")) : 1;
|
||||
const NO_PANES = PANES === 0;
|
||||
|
||||
class BenchHub extends MockHub {
|
||||
readonly feeds = new Map<string, BenchPort>();
|
||||
|
||||
constructor() {
|
||||
super(PANES);
|
||||
}
|
||||
|
||||
// ?search=1 answers every query with two conversations that say it.
|
||||
async searchHistory(q: string) {
|
||||
if (!query.has("search")) return super.searchHistory(q);
|
||||
@@ -482,20 +542,6 @@ class BenchHub extends MockHub {
|
||||
return port;
|
||||
}
|
||||
|
||||
runtimes(): Promise<RuntimeView[]> {
|
||||
if (NO_PANES) return Promise.resolve([]);
|
||||
if (PANES <= 1) return super.runtimes();
|
||||
return Promise.resolve(
|
||||
Array.from({ length: PANES }, (_, i) => ({
|
||||
id: `r${i + 1}`,
|
||||
base: i === 0 ? "" : `/rt/r${i + 1}`,
|
||||
root: "~/projects/DeepSeek-Reasonix",
|
||||
name: "DeepSeek-Reasonix",
|
||||
sessionPath: `/sessions/pane-${i + 1}.jsonl`,
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
async moveSessionTo(path: string, target: string) {
|
||||
(window.__moves ??= []).push({ path, target });
|
||||
return super.moveSessionTo(path, target);
|
||||
@@ -515,15 +561,11 @@ class BenchHub extends MockHub {
|
||||
return new Promise((resolve) => setTimeout(() => resolve(answer), archived ? slow : 0));
|
||||
}
|
||||
const wait = TREE_AT - performance.now();
|
||||
if (wait > 0) return new Promise((resolve) => setTimeout(() => resolve(this.tree()), wait));
|
||||
if (!WORKSPACES && query.has("trustpending") && PANES > 1) {
|
||||
return super.tree().then((all) => all.map((ws, i) => i > 0 ? ws : { ...ws, sessions: Array.from({ length: PANES }, (_, k) => ({
|
||||
path: `/sessions/pane-${k + 1}.jsonl`, name: `pane-${k + 1}`, title: `并行会话 ${k + 1}`, turns: 3, runtimeId: `r${k + 1}`,
|
||||
})) }));
|
||||
}
|
||||
if (!WORKSPACES) return super.tree();
|
||||
return Promise.resolve(
|
||||
Array.from({ length: WORKSPACES }, (_, w) => ({
|
||||
if (wait > 0) return new Promise((resolve) => setTimeout(() => resolve(this.tree(window)), wait));
|
||||
if (query.get("tree") === "none") return Promise.resolve([]);
|
||||
if (!WORKSPACES) return super.tree(window);
|
||||
return super.runtimes().then((runtimes) =>
|
||||
this.recentTree(Array.from({ length: WORKSPACES }, (_, w) => ({
|
||||
root: `~/projects/workspace-${w}`,
|
||||
name: `workspace-${w}`,
|
||||
remembered: true,
|
||||
@@ -533,12 +575,13 @@ class BenchHub extends MockHub {
|
||||
name: `w${w}-s${s}`,
|
||||
title: query.has("rows") ? ROW_TITLES[s % ROW_TITLES.length] : `第 ${s} 次会话,标题是内核起的一句话`,
|
||||
turns: (s % 40) + 1,
|
||||
runtimeId: runtimes.find((runtime) => runtime.sessionPath === `/sessions/w${w}-s${s}.jsonl`)?.id,
|
||||
...(query.has("rows") ? {
|
||||
lastActiveAt: Date.now() - (9 + s) * 86_400_000,
|
||||
versions: s % 2 === 1 ? [{ path: `/sessions/w${w}-s${s}-v1.jsonl`, name: `w${w}-s${s}-v1`, title: ROW_TITLES[s % ROW_TITLES.length], turns: 1 }] : undefined,
|
||||
} : {}),
|
||||
}))),
|
||||
})),
|
||||
})), window),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -562,7 +605,11 @@ declare global {
|
||||
// conversations really cost.
|
||||
__feed: (ev: WireEvent) => void;
|
||||
__panes: () => number;
|
||||
// Answer every completion request with this one, as the kernel would for a command typed in full.
|
||||
__complete: (answer: import("../src/port/port").Completion) => void;
|
||||
__previewFiles: (files: Record<string, { kind: PreviewInfo["kind"]; text: string }>) => void;
|
||||
// Sub-agent runs the kernel would list, each with the conversation it would return.
|
||||
__subagents: (runs: { run: SubagentRun; messages: HistoryMessage[] }[]) => void;
|
||||
// What folding a stored transcript into cards costs on its own, with no
|
||||
// frame in it: the step between the read answering and the first paint.
|
||||
__foldCost: () => number;
|
||||
@@ -574,9 +621,11 @@ declare global {
|
||||
__refuse: (method: string, message: string) => void;
|
||||
// Put a notice in the window's corner the way a failed setting does.
|
||||
__toast: (text: string) => void;
|
||||
__syntax: { apply: typeof applyPack; mount: typeof mountEditor };
|
||||
}
|
||||
}
|
||||
window.__toast = pushToast;
|
||||
window.__syntax = { apply: applyPack, mount: mountEditor };
|
||||
window.__feed = (ev) => hub.feeds.forEach((p) => p.feed(ev));
|
||||
window.__panes = () => hub.feeds.size;
|
||||
window.__previewFiles = (files) => hub.feeds.forEach((port) => {
|
||||
@@ -588,6 +637,14 @@ window.__previewFiles = (files) => hub.feeds.forEach((port) => {
|
||||
data: new TextEncoder().encode(files[ref.path].text).buffer, version: "fixture",
|
||||
});
|
||||
});
|
||||
window.__subagents = (runs) => hub.feeds.forEach((port) => {
|
||||
port.subagents = async () => runs.map((r) => r.run);
|
||||
port.subagentTranscript = async (ref) => {
|
||||
const found = runs.find((r) => r.run.ref === ref)!;
|
||||
return { run: found.run, messages: found.messages, after: 0, next: found.messages.length, total: found.messages.length, live: false };
|
||||
};
|
||||
});
|
||||
window.__complete = (answer) => hub.feeds.forEach((port) => { port.complete = async () => answer; });
|
||||
window.__refuse = (method, message) => {
|
||||
(hub as unknown as Record<string, unknown>)[method] = () => Promise.reject(new Error(message));
|
||||
};
|
||||
|
||||
@@ -61,7 +61,7 @@ try {
|
||||
assert.equal(await page.locator(".user-image button").evaluate(el => el === document.activeElement), true);
|
||||
await page.locator(".activity-group > summary").click();
|
||||
const tool = page.locator('[data-call="image-shot"] .tool-disclosure');
|
||||
await tool.locator("summary").click();
|
||||
await tool.locator("summary .nm").click();
|
||||
await page.getByRole("button", { name: "查看图片 工具截图 1", exact: true }).click();
|
||||
assert.equal(await page.getByRole("dialog").locator("img").getAttribute("src"), data);
|
||||
await page.getByRole("button", { name: "关闭图片预览" }).click();
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { chromium } from "playwright";
|
||||
|
||||
const browser = await chromium.launch();
|
||||
try {
|
||||
const page = await browser.newPage({ viewport: { width: 2400, height: 1200 }, locale: "en-US" });
|
||||
const errors = [];
|
||||
page.on("pageerror", (error) => errors.push(error.message));
|
||||
const url = new URL(process.env.PERF_URL ?? "http://localhost:4399/perf.html");
|
||||
url.searchParams.set("pref", "en");
|
||||
await page.goto(url.href, { waitUntil: "networkidle" });
|
||||
await page.locator(".compose textarea").waitFor();
|
||||
await page.evaluate(() => {
|
||||
window.__feed({ kind: "turn_started" });
|
||||
window.__feed({ kind: "text", text: "A populated conversation must remain readable when a custom width meets a narrow window." });
|
||||
window.__feed({ kind: "message" });
|
||||
window.__feed({ kind: "turn_done" });
|
||||
});
|
||||
await page.locator(".flow .out .txt").waitFor();
|
||||
await page.getByRole("button", { name: "Settings", exact: true }).click();
|
||||
await page.locator("#prefs-appearance").click();
|
||||
await page.getByRole("button", { name: "Custom percentage", exact: true }).click();
|
||||
const slider = page.getByRole("slider", { name: "Conversation width", exact: true });
|
||||
await slider.fill("67");
|
||||
await page.getByRole("button", { name: "Full width", exact: true }).click();
|
||||
await page.getByRole("button", { name: "Custom percentage", exact: true }).click();
|
||||
assert.equal(await slider.inputValue(), "67", "preset switching retains the custom percentage");
|
||||
await page.getByRole("button", { name: "Close Esc", exact: true }).click();
|
||||
await page.waitForFunction(() => {
|
||||
const pane = document.querySelector(".pane").clientWidth;
|
||||
return Math.abs(document.querySelector(".compose").getBoundingClientRect().width / pane - 0.67) < 0.01;
|
||||
});
|
||||
for (const selector of [".flow", ".compose"]) {
|
||||
const ratio = await page.locator(selector).evaluate((el) => el.getBoundingClientRect().width / document.querySelector(".pane").clientWidth);
|
||||
assert.ok(Math.abs(ratio - 0.67) < 0.01, `${selector} uses 67% of the conversation pane: ${ratio}`);
|
||||
}
|
||||
for (const [name, wideFlow, wideComposer] of [
|
||||
["Full width", true, false], ["Full width, composer too", true, true], ["Reading", false, false],
|
||||
]) {
|
||||
await page.getByRole("button", { name: "Settings", exact: true }).click();
|
||||
await page.locator("#prefs-appearance").click();
|
||||
await page.getByRole("button", { name, exact: true }).click();
|
||||
await page.getByRole("button", { name: "Close Esc", exact: true }).click();
|
||||
await page.waitForFunction(([flow, composer]) => {
|
||||
const fits = (selector, wide) => {
|
||||
const width = document.querySelector(selector).getBoundingClientRect().width;
|
||||
return wide ? width > 1800 : Math.abs(width - 760) < 1;
|
||||
};
|
||||
return fits(".flow", flow) && fits(".compose", composer);
|
||||
}, [wideFlow, wideComposer]);
|
||||
}
|
||||
console.log("PASS existing presets: reading, wide transcript and wide composer keep their geometry");
|
||||
console.log("PASS 67%: transcript and composer resize together; presets retain the value");
|
||||
|
||||
await page.getByRole("button", { name: "Settings", exact: true }).click();
|
||||
await page.locator("#prefs-appearance").click();
|
||||
await page.getByRole("button", { name: "Custom percentage", exact: true }).click();
|
||||
await slider.fill("30");
|
||||
await page.getByRole("button", { name: "Close Esc", exact: true }).click();
|
||||
await page.setViewportSize({ width: 1366, height: 900 });
|
||||
await page.waitForFunction(() => document.querySelector(".compose").getBoundingClientRect().width < 350);
|
||||
const outside = await page.locator(".compose").evaluate((el) => {
|
||||
const box = el.getBoundingClientRect();
|
||||
return [...el.querySelectorAll("button")].filter((button) => {
|
||||
const rect = button.getBoundingClientRect();
|
||||
return button.checkVisibility({ checkOpacity: true, checkVisibilityCSS: true })
|
||||
&& rect.width > 0 && (rect.right > box.right + 1 || rect.left < box.left - 1);
|
||||
}).map((el) => el.textContent);
|
||||
});
|
||||
assert.deepEqual(outside, [], "toolbar actions stay within a 30% composer on a laptop");
|
||||
const workspace = page.locator(".compose .worktree-trigger").first();
|
||||
await workspace.click();
|
||||
assert.equal(await workspace.getAttribute("aria-expanded"), "true", "workspace configuration remains accessible in a 30% composer");
|
||||
await page.keyboard.press("Escape");
|
||||
console.log("PASS 30%: composer actions remain contained on a laptop");
|
||||
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await page.waitForFunction(() => document.querySelector(".flow").getBoundingClientRect().width > 300);
|
||||
for (const selector of [".flow", ".compose"]) {
|
||||
const width = await page.locator(selector).evaluate((el) => el.getBoundingClientRect().width);
|
||||
assert.ok(width > 300 && width <= 390, `${selector} uses the phone layout rather than 30%: ${width}`);
|
||||
}
|
||||
assert.equal(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), true, "phone has no horizontal page overflow");
|
||||
await workspace.click();
|
||||
assert.equal(await workspace.getAttribute("aria-expanded"), "true", "workspace configuration remains accessible on a phone");
|
||||
await page.keyboard.press("Escape");
|
||||
assert.deepEqual(errors, []);
|
||||
console.log("PASS phone: populated conversation and composer use the available width");
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
@@ -65,7 +65,7 @@ const probe = (page) =>
|
||||
};
|
||||
});
|
||||
|
||||
const CONTROLS = [".phone-action", ".find-action", ".theme-toggle", ".top-action", ".view-action"];
|
||||
const CONTROLS = [".phone-action", ".find-action", ".theme-toggle", ".view-action"];
|
||||
|
||||
// Opens the fold when there is one, then presses nothing: asks whether each control is on screen with nothing over it.
|
||||
async function reachable(page) {
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
// A label's ink must centre on its icon's: the line box centres on the row, but a CJK label's ink sits above the
|
||||
// line box's middle, so a row can look top-heavy while every box measurement says it is level.
|
||||
import { chromium } from "playwright";
|
||||
|
||||
const BASE = process.env.PERF_URL ?? "http://localhost:4399/perf.html?pref=zh";
|
||||
const fails = [];
|
||||
const check = (name, ok, detail = "") => {
|
||||
console.log(`${ok ? " ok" : "FAIL"} ${name}${detail ? " — " + detail : ""}`);
|
||||
if (!ok) fails.push(name);
|
||||
};
|
||||
|
||||
const browser = await chromium.launch();
|
||||
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 }, reducedMotion: "reduce" });
|
||||
await ctx.addInitScript(() => localStorage.setItem("rx-nav-rail", "off"));
|
||||
const page = await ctx.newPage();
|
||||
page.on("pageerror", (e) => fails.push("page error: " + e.message));
|
||||
await page.goto(BASE, { waitUntil: "networkidle" });
|
||||
await page.waitForSelector(".studio-quicknav button");
|
||||
await page.evaluate(() => document.fonts.ready);
|
||||
const rows = await page.evaluate(() => {
|
||||
return [...document.querySelectorAll(".studio-new-task, .studio-search, .studio-quicknav button")].map((row) => {
|
||||
const box = row.getBoundingClientRect();
|
||||
const icon = row.querySelector("span").getBoundingClientRect();
|
||||
return { name: row.textContent.trim(), x: box.x, y: box.y, w: box.width, h: box.height, iconRight: icon.right - box.x };
|
||||
});
|
||||
});
|
||||
for (const r of rows) {
|
||||
const shot = await page.screenshot({ clip: { x: r.x, y: r.y, width: r.w, height: r.h } });
|
||||
const mids = await page.evaluate(async ({ data, split }) => {
|
||||
const img = await createImageBitmap(await (await fetch("data:image/png;base64," + data)).blob());
|
||||
const c = new OffscreenCanvas(img.width, img.height);
|
||||
const g = c.getContext("2d");
|
||||
g.drawImage(img, 0, 0);
|
||||
const px = g.getImageData(0, 0, img.width, img.height).data;
|
||||
const mid = (x0, x1) => {
|
||||
let top = Infinity, bot = -1;
|
||||
for (let y = 0; y < img.height; y++) for (let x = x0; x < Math.min(x1, img.width); x++) {
|
||||
const o = (y * img.width + x) * 4;
|
||||
if ((px[o] + px[o + 1] + px[o + 2]) / 3 < 170) { top = Math.min(top, y); bot = Math.max(bot, y); }
|
||||
}
|
||||
return (top + bot) / 2;
|
||||
};
|
||||
return { icon: mid(0, split), text: mid(split, img.width) };
|
||||
}, { data: shot.toString("base64"), split: Math.round(r.iconRight + 4) });
|
||||
const off = Math.abs(mids.text - mids.icon);
|
||||
check(`${r.name}: label and icon centre within 1px`, off <= 1, `icon ${mids.icon} text ${mids.text}`);
|
||||
}
|
||||
await browser.close();
|
||||
if (fails.length) {
|
||||
console.log(`\n${fails.length} failed`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log("\nall ok");
|
||||
@@ -0,0 +1,31 @@
|
||||
// An enlarged settings window centres its content column between the nav and
|
||||
// the window edge: the room left over is shared, not piled up on the right.
|
||||
import assert from "node:assert/strict";
|
||||
import { chromium } from "playwright";
|
||||
|
||||
const PAGE = process.env.PERF_URL ?? "http://localhost:4399/perf.html?pref=zh";
|
||||
const browser = await chromium.launch();
|
||||
try {
|
||||
for (const width of [1920, 2560]) {
|
||||
const page = await browser.newPage({ viewport: { width, height: 900 } });
|
||||
const errors = [];
|
||||
page.on("pageerror", (error) => errors.push(error.message));
|
||||
await page.goto(PAGE, { waitUntil: "networkidle" });
|
||||
await page.evaluate(() => document.querySelector('[data-action="chrome.settings"]')?.click());
|
||||
await page.waitForSelector(".prefs-nav button");
|
||||
await page.getByRole("button", { name: "放大设置窗口" }).click();
|
||||
await page.waitForTimeout(400);
|
||||
const room = await page.evaluate(() => {
|
||||
const main = document.querySelector(".prefs-main").getBoundingClientRect();
|
||||
const col = document.querySelector(".prefs-main .prefs-col").getBoundingClientRect();
|
||||
return { left: Math.round(col.left - main.left), right: Math.round(main.right - col.right), width: Math.round(col.width) };
|
||||
});
|
||||
assert.ok(room.width <= 1200, `the column keeps its cap (${room.width})`);
|
||||
assert.ok(Math.abs(room.left - room.right) <= 2, `${width}px: the room is shared evenly (left ${room.left}, right ${room.right})`);
|
||||
assert.deepEqual(errors, []);
|
||||
console.log(`PASS ${width}px: column ${room.width}px with ${room.left}px each side`);
|
||||
await page.close();
|
||||
}
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
@@ -15,7 +15,8 @@ try {
|
||||
page.setDefaultTimeout(8000);
|
||||
page.on("pageerror", (error) => failures.push(error.message));
|
||||
await page.goto(`${BASE}?pref=zh&turns=3`, { waitUntil: "networkidle" });
|
||||
const add = page.locator(".projects-heading .projects-add");
|
||||
const add = page.locator('.projects-heading [data-action="workspace.add"]');
|
||||
const recent = page.locator('.projects-heading [data-action="session.recent"]');
|
||||
const more = page.locator(".projects-heading .wsmore");
|
||||
await add.waitFor();
|
||||
await page.addStyleTag({ content: "*{transition:none!important;animation:none!important}" });
|
||||
@@ -29,11 +30,14 @@ try {
|
||||
const addHover = await paint(add);
|
||||
await more.hover();
|
||||
const moreHover = await paint(more);
|
||||
check(`${scheme}: both header actions visibly highlight on hover`,
|
||||
addHover.fill !== rest.fill && moreHover.fill !== rest.fill);
|
||||
await recent.hover();
|
||||
const recentHover = await paint(recent);
|
||||
check(`${scheme}: all header actions visibly highlight on hover`,
|
||||
addHover.fill !== rest.fill && moreHover.fill !== rest.fill && recentHover.fill !== rest.fill);
|
||||
check(`${scheme}: project header actions have the same hover fill and ink`,
|
||||
addHover.fill === moreHover.fill && addHover.ink === moreHover.ink,
|
||||
JSON.stringify({ add: addHover, more: moreHover }));
|
||||
addHover.fill === moreHover.fill && addHover.ink === moreHover.ink
|
||||
&& recentHover.fill === addHover.fill && recentHover.ink === addHover.ink,
|
||||
JSON.stringify({ add: addHover, more: moreHover, recent: recentHover }));
|
||||
await more.click();
|
||||
const menu = page.getByRole("menu", { name: "项目列表的更多操作", exact: true });
|
||||
await menu.waitFor();
|
||||
@@ -50,15 +54,22 @@ try {
|
||||
focused: button === document.activeElement,
|
||||
outline: getComputedStyle(button).outline,
|
||||
}));
|
||||
await page.keyboard.press("Shift+Tab");
|
||||
const recentFocus = await recent.evaluate((button) => ({
|
||||
focused: button === document.activeElement,
|
||||
outline: getComputedStyle(button).outline,
|
||||
}));
|
||||
await page.keyboard.press("Tab");
|
||||
await page.keyboard.press("Tab");
|
||||
const moreFocus = await more.evaluate((button) => ({
|
||||
focused: button === document.activeElement,
|
||||
outline: getComputedStyle(button).outline,
|
||||
}));
|
||||
check(`${scheme}: both header actions retain visible keyboard focus`,
|
||||
addFocus.focused && moreFocus.focused
|
||||
&& !addFocus.outline.includes("none") && !moreFocus.outline.includes("none"),
|
||||
JSON.stringify({ add: addFocus, more: moreFocus }));
|
||||
check(`${scheme}: all header actions retain visible keyboard focus`,
|
||||
addFocus.focused && moreFocus.focused && recentFocus.focused
|
||||
&& !addFocus.outline.includes("none") && !moreFocus.outline.includes("none")
|
||||
&& !recentFocus.outline.includes("none"),
|
||||
JSON.stringify({ add: addFocus, more: moreFocus, recent: recentFocus }));
|
||||
await more.click();
|
||||
await menu.waitFor();
|
||||
await page.locator(".projects-heading-title").click();
|
||||
@@ -73,6 +84,54 @@ try {
|
||||
await page.mouse.move(900, 500);
|
||||
const after = await paint(more);
|
||||
check(`${scheme}: dismissing the menu restores the resting background`, after.fill === rest.fill);
|
||||
await recent.click();
|
||||
const recentDialog = page.getByRole("dialog", { name: "最近的会话", exact: true });
|
||||
await recentDialog.locator(".recent-session").first().waitFor();
|
||||
check(`${scheme}: Recent opens saved session discovery`, await recentDialog.isVisible());
|
||||
await page.keyboard.press("Escape");
|
||||
await recentDialog.waitFor({ state: "hidden" });
|
||||
check(`${scheme}: closing Recent returns focus to its own header entry`,
|
||||
await recent.evaluate((button) => button === document.activeElement));
|
||||
await page.goto(`${BASE}?pref=zh&ws=20&sess=8&turns=3`, { waitUntil: "networkidle" });
|
||||
await add.waitFor();
|
||||
const headerTop = await add.evaluate((button) => button.getBoundingClientRect().top);
|
||||
const tree = page.getByRole("tree", { name: "项目与会话", exact: true });
|
||||
const rows = tree.getByRole("treeitem");
|
||||
await rows.first().focus();
|
||||
await page.keyboard.press("End");
|
||||
const last = rows.last();
|
||||
check(`${scheme}: End reaches the final local or remote row`,
|
||||
await last.evaluate((row) => row === document.activeElement));
|
||||
const bottom = await last.evaluate((row) => {
|
||||
const scroller = row.closest(".scroll");
|
||||
const r = row.getBoundingClientRect(), s = scroller.getBoundingClientRect();
|
||||
return { top: scroller.scrollTop, height: scroller.clientHeight, visible: r.top >= s.top && r.bottom <= s.bottom };
|
||||
});
|
||||
check(`${scheme}: a long project list scrolls to its final rows`, bottom.top > bottom.height && bottom.visible);
|
||||
const addAtEnd = await add.evaluate((button) => {
|
||||
const r = button.getBoundingClientRect();
|
||||
return { top: r.top, reached: button.contains(document.elementFromPoint(r.left + r.width / 2, r.top + r.height / 2)) };
|
||||
});
|
||||
check(`${scheme}: project controls stay fixed and clickable while the tree scrolls`,
|
||||
addAtEnd.top === headerTop && addAtEnd.reached, JSON.stringify(addAtEnd));
|
||||
await page.keyboard.press("Home");
|
||||
check(`${scheme}: Home returns to the first project`,
|
||||
await rows.first().evaluate((row) => row === document.activeElement));
|
||||
await page.keyboard.press("Shift+F10");
|
||||
await page.getByRole("menu", { name: "项目操作", exact: true }).waitFor();
|
||||
await page.keyboard.press("Escape");
|
||||
await more.click();
|
||||
await page.getByRole("menuitem", { name: "选择本机会话", exact: true }).click();
|
||||
await page.getByRole("button", { name: "完成选择", exact: true }).click();
|
||||
check(`${scheme}: leaving batch selection returns focus to the fixed header`,
|
||||
await more.evaluate((button) => button === document.activeElement));
|
||||
await add.click();
|
||||
await dialog.waitFor();
|
||||
check(`${scheme}: the fixed add action retains local and remote project locations`,
|
||||
await dialog.getByRole("button", { name: "选择本机文件夹", exact: true }).isEnabled()
|
||||
&& await dialog.getByRole("button", { name: "gpu-box · SSH", exact: true }).isEnabled());
|
||||
await page.keyboard.press("Escape");
|
||||
await dialog.waitFor({ state: "hidden" });
|
||||
await page.close();
|
||||
}
|
||||
} finally {
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { chromium } from "playwright";
|
||||
import { mkdirSync } from "node:fs";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
const url = process.env.PERF_URL ?? "http://127.0.0.1:4407/perf.html";
|
||||
const shots = process.env.QUEUE_SHOTS ?? "/tmp/queue-14497-shots";
|
||||
const baseline = process.env.QUEUE_BASELINE === "1";
|
||||
mkdirSync(shots, { recursive: true });
|
||||
const browser = await chromium.launch();
|
||||
const failures = [];
|
||||
for (const theme of ["light", "dark"]) {
|
||||
for (const width of [1440, 390]) {
|
||||
const context = await browser.newContext({ viewport: { width, height: 1000 }, locale: "zh-CN" });
|
||||
const page = await context.newPage();
|
||||
page.on("pageerror", (e) => failures.push(e.message));
|
||||
await page.goto(`${url}?queue=12&queuebody&queuestates&turns=8`, { waitUntil: "networkidle" });
|
||||
await page.locator(".qi").first().waitFor();
|
||||
await page.evaluate((theme) => { document.documentElement.dataset.theme = theme; }, theme);
|
||||
await page.waitForTimeout(500);
|
||||
const geometry = await page.evaluate(() => {
|
||||
const list = document.querySelector(".qitems");
|
||||
const row = document.querySelector(".qi");
|
||||
const style = getComputedStyle(row);
|
||||
return { border: style.borderTopWidth, height: row.getBoundingClientRect().height,
|
||||
scrolls: list.scrollHeight > list.clientHeight, visibleHeight: list.clientHeight, width: document.documentElement.scrollWidth,
|
||||
background: style.backgroundColor, gap: getComputedStyle(list).gap };
|
||||
});
|
||||
console.log(`${baseline ? "before" : "after"}/${theme}/${width}`, geometry);
|
||||
await page.screenshot({ path: `${shots}/${baseline ? "before" : "after"}-${theme}-${width}.png` });
|
||||
if (baseline) {
|
||||
assert.notEqual(geometry.border, "0px", "baseline must expose the standalone row border");
|
||||
} else {
|
||||
assert.equal(geometry.border, "0px");
|
||||
assert.ok(geometry.height >= 36 && geometry.height <= 40, JSON.stringify(geometry));
|
||||
assert.equal(geometry.gap, "2px");
|
||||
assert.ok(geometry.scrolls);
|
||||
assert.ok(geometry.width <= width);
|
||||
assert.equal(await page.getByRole("combobox", { name: "任务输入" }).getAttribute("placeholder"), "继续输入以排队后续修改");
|
||||
const row = page.locator(".qi").first();
|
||||
assert.equal(await row.locator(".qwhy").getAttribute("title"), "This queued instruction needs review before delivery. ".repeat(5));
|
||||
assert.ok(geometry.visibleHeight >= 180 && geometry.visibleHeight <= 208, JSON.stringify(geometry));
|
||||
await row.getByRole("button", { name: "改", exact: true }).focus();
|
||||
assert.equal(await row.getByRole("button", { name: "下移", exact: true }).evaluate((el) => getComputedStyle(el).opacity), "1");
|
||||
await page.screenshot({ path: `${shots}/focus-${theme}-${width}.png` });
|
||||
await row.getByRole("button", { name: "改", exact: true }).click();
|
||||
await page.locator(".qedit").waitFor();
|
||||
assert.ok(await page.locator(".qedit").inputValue());
|
||||
await page.screenshot({ path: `${shots}/edit-${theme}-${width}.png` });
|
||||
await page.locator(".qedit").press("Escape");
|
||||
assert.equal(await page.locator(".qedit").count(), 0);
|
||||
assert.equal(await row.getByRole("button", { name: "取回", exact: true }).getAttribute("title"), "取回");
|
||||
await page.locator(".qitems").evaluate((el) => { el.scrollTop = el.scrollHeight; });
|
||||
await page.locator(".qi").last().getByRole("button", { name: "取回", exact: true }).focus();
|
||||
assert.ok(await page.locator(".qi").last().getByRole("button", { name: "取回", exact: true }).isVisible());
|
||||
}
|
||||
await context.close();
|
||||
}
|
||||
}
|
||||
await browser.close();
|
||||
assert.deepEqual(failures, []);
|
||||
console.log("Queue layout browser matrix passed");
|
||||
@@ -38,7 +38,7 @@ check("播出来的行在", (await rows.count()) === 3, `${await rows.count()}
|
||||
|
||||
const first = rows.nth(0);
|
||||
const wasFirst = await first.locator(".pv").innerText();
|
||||
await first.locator('button:has-text("改")').click();
|
||||
await first.getByRole("button", { name: "改", exact: true }).click();
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
const box = await page.locator(".queue .qedit").count();
|
||||
@@ -50,7 +50,7 @@ check("这一行说了为什么", await why.isVisible().catch(() => false), (awa
|
||||
// 说明是这一行的,不是面板的:换一行再点,上一行的说明得跟着走。一条粘住不动的
|
||||
// 提示会让下一次失败看起来像上一次的余音。
|
||||
const second = rows.nth(1);
|
||||
await second.locator('button:has-text("改")').click();
|
||||
await second.getByRole("button", { name: "改", exact: true }).click();
|
||||
await page.waitForTimeout(500);
|
||||
check("说明跟着被点的那一行走", (await first.locator(".qwhy[data-err]").count()) === 0);
|
||||
check("新的那一行才带着说明", await second.locator(".qwhy[data-err]").isVisible().catch(() => false));
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
// With the icon rail hidden, the sidebar's rows line up and read alike: every row's icon sits on one vertical line and every
|
||||
// label starts on another, from the new-session button down to the wallet and feedback rows at the foot.
|
||||
import { chromium } from "playwright";
|
||||
|
||||
const BASE = process.env.PERF_URL ?? "http://localhost:4399/perf.html?pref=zh&ws=1&sess=1&turns=1";
|
||||
const ROWS = [
|
||||
["新建会话", ".studio-new-task"],
|
||||
["搜索与快捷操作", ".studio-search"],
|
||||
["文件", ".studio-quicknav > button:nth-child(1)"],
|
||||
["工具与权限", ".studio-quicknav > button:nth-child(2)"],
|
||||
["扩展", ".studio-quicknav > button:nth-child(3)"],
|
||||
["记忆", ".studio-quicknav > button:nth-child(4)"],
|
||||
["远程", ".studio-quicknav > button:nth-child(5)"],
|
||||
["钱包与用量", ".studio-wallet:not(.studio-feedback)"],
|
||||
["发送反馈", ".studio-feedback"],
|
||||
];
|
||||
const TOLERANCE = 1;
|
||||
const spread0 = (rows, at) => rows.reduce((worst, r) => (Math.abs(r.icon - at) > Math.abs(worst) ? r.icon - at : worst), 0);
|
||||
const fails = [];
|
||||
const check = (name, ok, detail = "") => {
|
||||
console.log(`${ok ? " ok" : "FAIL"} ${name}${detail ? " — " + detail : ""}`);
|
||||
if (!ok) fails.push(name);
|
||||
};
|
||||
|
||||
const browser = await chromium.launch();
|
||||
try {
|
||||
for (const scheme of ["light", "dark"]) {
|
||||
const ctx = await browser.newContext({ locale: "zh-CN", colorScheme: scheme, viewport: { width: 1280, height: 900 }, deviceScaleFactor: 4, reducedMotion: "reduce" });
|
||||
await ctx.addInitScript(() => { try { localStorage.setItem("rx-nav-rail", "off"); } catch {} });
|
||||
const page = await ctx.newPage();
|
||||
await page.goto(BASE, { waitUntil: "networkidle" });
|
||||
await page.waitForSelector(".app");
|
||||
const seen = [];
|
||||
for (const [name, sel] of ROWS) {
|
||||
const at = await page.evaluate((s) => {
|
||||
const row = document.querySelector(s);
|
||||
if (!row) return null;
|
||||
const svg = row.querySelector("svg").getBoundingClientRect();
|
||||
const walk = document.createTreeWalker(row, NodeFilter.SHOW_TEXT);
|
||||
let text = null;
|
||||
for (let n = walk.nextNode(); n && !text; n = walk.nextNode()) {
|
||||
if (!n.textContent.trim()) continue;
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(n);
|
||||
text = range.getBoundingClientRect().x;
|
||||
}
|
||||
const label = [...row.querySelectorAll("*")].find((e) => e.children.length === 0 && e.textContent.trim() && !e.closest("kbd,small,.fbk-badge")) ?? row;
|
||||
const cs = getComputedStyle(label);
|
||||
return { icon: svg.x + svg.width / 2, text, look: `${cs.fontSize}/${cs.fontWeight}/${cs.color}`, fill: getComputedStyle(row).backgroundColor };
|
||||
}, sel);
|
||||
if (at) seen.push({ name, ...at });
|
||||
}
|
||||
const spread = (key) => Math.max(...seen.map((r) => r[key])) - Math.min(...seen.map((r) => r[key]));
|
||||
const show = (key) => seen.map((r) => `${r.name} ${r[key].toFixed(1)}`).join("; ");
|
||||
check(`${scheme}: all ${ROWS.length} rows are drawn`, seen.length === ROWS.length, `${seen.length}`);
|
||||
check(`${scheme}: the icons share one vertical line`, spread("icon") <= TOLERANCE, `spread ${spread("icon").toFixed(1)}px — ${show("icon")}`);
|
||||
check(`${scheme}: the labels start on one vertical line`, spread("text") <= TOLERANCE, `spread ${spread("text").toFixed(1)}px — ${show("text")}`);
|
||||
const folder = await page.evaluate(() => {
|
||||
const r = document.querySelector(".rail .wsfolder")?.getBoundingClientRect();
|
||||
return r ? r.x + r.width / 2 : null;
|
||||
});
|
||||
check(`${scheme}: the icons sit on the project folders' line`, folder !== null && Math.abs(spread0(seen, folder)) <= TOLERANCE, `folders ${folder}; rows ${show("icon")}`);
|
||||
const lockup = await page.evaluate(() => {
|
||||
const logo = document.querySelector(".studio-brand-logo").getBoundingClientRect();
|
||||
const first = document.querySelector(".studio-new-task").getBoundingClientRect();
|
||||
return { above: logo.top, below: first.top - logo.bottom };
|
||||
});
|
||||
check(`${scheme}: the logo has the same space above and below it`, Math.abs(lockup.above - lockup.below) <= TOLERANCE, `above ${lockup.above}, below ${lockup.below}`);
|
||||
const logo = await page.evaluate(() => {
|
||||
const r = document.querySelector(".studio-brand-logo").getBoundingClientRect();
|
||||
return { x: r.x, y: r.y, h: r.height };
|
||||
});
|
||||
const shot = await page.screenshot({ clip: { x: 0, y: logo.y, width: 120, height: logo.h } });
|
||||
const inkLeft = await page.evaluate(async (b64) => {
|
||||
const img = new Image();
|
||||
img.src = `data:image/png;base64,${b64}`;
|
||||
await img.decode();
|
||||
const c = document.createElement("canvas");
|
||||
c.width = img.width; c.height = img.height;
|
||||
const g = c.getContext("2d");
|
||||
g.drawImage(img, 0, 0);
|
||||
const d = g.getImageData(0, 0, c.width, c.height).data;
|
||||
const bg = [d[0], d[1], d[2]];
|
||||
for (let x = 0; x < c.width; x++) for (let y = 0; y < c.height; y++) {
|
||||
const i = (y * c.width + x) * 4;
|
||||
if (Math.abs(d[i] - bg[0]) + Math.abs(d[i + 1] - bg[1]) + Math.abs(d[i + 2] - bg[2]) > 60) return x / (c.width / 120);
|
||||
}
|
||||
return null;
|
||||
}, shot.toString("base64"));
|
||||
const labelLeft = seen[0].text;
|
||||
check(`${scheme}: the logo's first letter starts where the row labels start`, inkLeft !== null && Math.abs(inkLeft - labelLeft) <= TOLERANCE, `logo ink ${inkLeft}, labels ${labelLeft}`);
|
||||
const avatar = await page.evaluate(() => {
|
||||
const ini = document.querySelector(".studio-user-foot .ini");
|
||||
const row = ini?.closest(".acctrow");
|
||||
if (!ini || !row) return null;
|
||||
const a = ini.getBoundingClientRect();
|
||||
const r = row.getBoundingClientRect();
|
||||
return { same: Math.abs(a.width - r.width) <= 1 && Math.abs(a.height - r.height) <= 1, radius: getComputedStyle(ini).borderRadius, rowRadius: getComputedStyle(row).borderRadius };
|
||||
});
|
||||
check(`${scheme}: the account avatar is one rounded square, not a circle inside another shape`, avatar !== null && avatar.same && avatar.radius === avatar.rowRadius, JSON.stringify(avatar));
|
||||
const tabGapsAt = async () => {
|
||||
const boxes = await page.evaluate(() => [...document.querySelectorAll(".studio-session-segments button")].map((e) => {
|
||||
const r = e.getBoundingClientRect();
|
||||
return { x: r.x, y: r.y, width: r.width, height: r.height };
|
||||
}));
|
||||
const gaps = [];
|
||||
for (const box of boxes) {
|
||||
const shot = await page.screenshot({ clip: box });
|
||||
gaps.push(await page.evaluate(async (b64) => {
|
||||
const img = new Image();
|
||||
img.src = `data:image/png;base64,${b64}`;
|
||||
await img.decode();
|
||||
const c = document.createElement("canvas");
|
||||
c.width = img.width; c.height = img.height;
|
||||
const g = c.getContext("2d");
|
||||
g.drawImage(img, 0, 0);
|
||||
const d = g.getImageData(0, 0, c.width, c.height).data;
|
||||
const mid = (c.width >> 1) * 4;
|
||||
const bg = [d[mid], d[mid + 1], d[mid + 2]];
|
||||
let top = -1, bottom = -1;
|
||||
for (let y = 0; y < c.height; y++) {
|
||||
for (let x = Math.floor(c.width * 0.2); x < Math.floor(c.width * 0.8); x++) {
|
||||
const i = (y * c.width + x) * 4;
|
||||
if (Math.abs(d[i] - bg[0]) + Math.abs(d[i + 1] - bg[1]) + Math.abs(d[i + 2] - bg[2]) > 90) { if (top < 0) top = y; bottom = y; break; }
|
||||
}
|
||||
}
|
||||
return { above: top / 4, below: (c.height - 1 - bottom) / 4 };
|
||||
}, shot.toString("base64")));
|
||||
}
|
||||
return gaps.reduce((w, g) => (Math.abs(g.above - g.below) > Math.abs(w.above - w.below) ? g : w), gaps[0]);
|
||||
};
|
||||
const inkExtent = async () => {
|
||||
const box = await page.evaluate(() => {
|
||||
const r = document.querySelector(".studio-session-segments button").getBoundingClientRect();
|
||||
return { x: r.x, y: r.y, width: r.width, height: r.height };
|
||||
});
|
||||
const shot = await page.screenshot({ clip: box });
|
||||
return page.evaluate(async (b64) => {
|
||||
const img = new Image();
|
||||
img.src = `data:image/png;base64,${b64}`;
|
||||
await img.decode();
|
||||
const c = document.createElement("canvas");
|
||||
c.width = img.width; c.height = img.height;
|
||||
const g = c.getContext("2d");
|
||||
g.drawImage(img, 0, 0);
|
||||
const d = g.getImageData(0, 0, c.width, c.height).data;
|
||||
const mid = (c.width >> 1) * 4;
|
||||
const bg = [d[mid], d[mid + 1], d[mid + 2]];
|
||||
let top = -1, bottom = -1;
|
||||
for (let y = 0; y < c.height; y++) {
|
||||
for (let x = Math.floor(c.width * 0.2); x < Math.floor(c.width * 0.8); x++) {
|
||||
const i = (y * c.width + x) * 4;
|
||||
if (Math.abs(d[i] - bg[0]) + Math.abs(d[i + 1] - bg[1]) + Math.abs(d[i + 2] - bg[2]) > 90) { if (top < 0) top = y; bottom = y; break; }
|
||||
}
|
||||
}
|
||||
return { top: top / 4, bottom: bottom / 4 };
|
||||
}, shot.toString("base64"));
|
||||
};
|
||||
const clipped = await inkExtent();
|
||||
await page.addStyleTag({ content: ".studio-session-segments button > span { overflow: visible !important; }" });
|
||||
const open = await inkExtent();
|
||||
check(`${scheme}: the session tabs' glyphs are not cut by the label's clip box`, Math.abs(clipped.top - open.top) <= 0.25 && Math.abs(clipped.bottom - open.bottom) <= 0.25, `clipped ${JSON.stringify(clipped)} open ${JSON.stringify(open)}`);
|
||||
await page.evaluate(() => { document.head.lastElementChild.remove(); });
|
||||
for (const scale of [1]) {
|
||||
await page.evaluate((f) => document.documentElement.style.setProperty("--fs-scale", String(f)), scale);
|
||||
await page.waitForTimeout(120);
|
||||
const worst = await tabGapsAt();
|
||||
check(`${scheme}: at text size ${scale} each session tab's text has equal space above and below`, Math.abs(worst.above - worst.below) <= 1.5, JSON.stringify(worst));
|
||||
}
|
||||
await page.evaluate(() => document.documentElement.style.removeProperty("--fs-scale"));
|
||||
const pillY = await page.evaluate(() => document.querySelector(".studio-session-segments").getBoundingClientRect().y);
|
||||
check(`${scheme}: the session pill sits on a whole pixel, so its fill has equal edges above and below`, Number.isInteger(pillY), `y ${pillY}`);
|
||||
const looks = new Set(seen.map((r) => r.look));
|
||||
check(`${scheme}: every row's label has one size, weight and colour`, looks.size === 1, seen.map((r) => `${r.name} ${r.look}`).join("; "));
|
||||
const fill = seen.find((r) => r.name === "新建会话")?.fill ?? "";
|
||||
check(`${scheme}: New session has no fill at rest`, /rgba\(0, 0, 0, 0\)|transparent/.test(fill), fill);
|
||||
await ctx.close();
|
||||
}
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
if (fails.length) {
|
||||
console.error(`\n${fails.length} failed: ${fails.join("; ")}`);
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
// With the icon rail hidden, the sidebar's own shortcut rows are one family: the same size, weight and colour,
|
||||
// so none of them reads as bigger or bolder than its neighbours. The new-session button and the feedback row are
|
||||
// emphasis by design and are not part of the family.
|
||||
import { chromium } from "playwright";
|
||||
|
||||
const BASE = process.env.PERF_URL ?? "http://localhost:4399/perf.html?pref=zh&ws=3&sess=3&turns=2";
|
||||
const FAMILY = ["搜索与快捷操作", "文件", "工具与权限", "扩展", "记忆", "远程", "钱包与用量"];
|
||||
const fails = [];
|
||||
const check = (name, ok, detail = "") => {
|
||||
console.log(`${ok ? " ok" : "FAIL"} ${name}${detail ? " — " + detail : ""}`);
|
||||
if (!ok) fails.push(name);
|
||||
};
|
||||
|
||||
const browser = await chromium.launch();
|
||||
try {
|
||||
for (const scheme of ["light", "dark"]) {
|
||||
const ctx = await browser.newContext({ locale: "zh-CN", colorScheme: scheme, viewport: { width: 1280, height: 900 }, reducedMotion: "reduce" });
|
||||
await ctx.addInitScript(() => { try { localStorage.setItem("rx-nav-rail", "off"); } catch {} });
|
||||
const page = await ctx.newPage();
|
||||
await page.goto(BASE, { waitUntil: "networkidle" });
|
||||
await page.waitForSelector(".app");
|
||||
const looks = {};
|
||||
for (const name of FAMILY) {
|
||||
const el = page.getByText(name, { exact: true }).first();
|
||||
await el.waitFor();
|
||||
looks[name] = await el.evaluate((e) => { const c = getComputedStyle(e); return `${c.fontSize} / ${c.fontWeight} / ${c.color}`; });
|
||||
}
|
||||
const distinct = new Set(Object.values(looks));
|
||||
check(`${scheme}: the sidebar's shortcut rows share one size, weight and colour`, distinct.size === 1,
|
||||
distinct.size === 1 ? [...distinct][0] : Object.entries(looks).map(([k, v]) => `${k}: ${v}`).join("; "));
|
||||
const [search, first] = await Promise.all([page.locator(".studio-search").first().boundingBox(), page.locator(".studio-quicknav > button").first().boundingBox()]);
|
||||
const gap = first.y - (search.y + search.height);
|
||||
check(`${scheme}: the shortcut rows follow the search row without a blank band`, gap <= 4, `${gap.toFixed(1)}px`);
|
||||
await ctx.close();
|
||||
}
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
if (fails.length) {
|
||||
console.error(`\n${fails.length} failed: ${fails.join("; ")}`);
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -3,6 +3,8 @@
|
||||
// median frame with PERF_GUARD panes stays within FRAME_RATIO_MAX of the one-pane median, a pane that
|
||||
// was hidden comes back at the scroll position it left, and the transcript does not inherit the animated
|
||||
// column widths: with a long transcript the fold costs style recalculation of every element under it.
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdir } from "node:fs/promises";
|
||||
import { chromium } from "playwright";
|
||||
|
||||
const CPU = Number(process.env.PERF_CPU ?? 4);
|
||||
@@ -12,6 +14,8 @@ const COUNTS = (process.env.PERF_N ?? "1,3,6").split(",").map(Number);
|
||||
const GUARD = Number(process.env.PERF_GUARD ?? 3);
|
||||
const FRAME_RATIO_MAX = Number(process.env.PERF_FRAME_RATIO ?? 2.5);
|
||||
const URL = process.env.PERF_URL ?? "http://localhost:4399/perf.html";
|
||||
const SHOTS = process.env.RAIL_TOGGLE_SHOTS;
|
||||
if (SHOTS) await mkdir(SHOTS, { recursive: true });
|
||||
const pct = (a, p) => [...a].sort((x, y) => x - y)[Math.min(a.length - 1, Math.floor(a.length * p))];
|
||||
const browser = await chromium.launch();
|
||||
const rows = [];
|
||||
@@ -62,13 +66,41 @@ for (const n of COUNTS) {
|
||||
}
|
||||
rows.push({ panes: n, frames: frames.length, p50: pct(frames, 0.5).toFixed(1), p95: pct(frames, 0.95).toFixed(1), worst: Math.max(...frames).toFixed(1), over50: frames.filter((f) => f > 50).length });
|
||||
if (n === GUARD) {
|
||||
await page.evaluate(() => { document.querySelector(".pane:not([data-off]) .scroll[data-pane]:not([hidden])").scrollTop = 400; });
|
||||
const first = page.locator('.sessrow[data-target="/sessions/pane-1.jsonl"]');
|
||||
const second = page.locator('.sessrow[data-target="/sessions/pane-2.jsonl"]');
|
||||
assert.equal(await first.getAttribute("data-live"), "", "the first row must identify an existing pane");
|
||||
assert.equal(await second.getAttribute("data-live"), "", "the second row must identify another existing pane");
|
||||
assert.equal(await first.getAttribute("aria-selected"), "true", "the measured pane must be the first session");
|
||||
const scroller = await page.locator('.pane:not([data-off]) .scroll[data-pane="flow"]').elementHandle();
|
||||
assert.ok(scroller);
|
||||
await scroller.evaluate((root) => { root.scrollTop = 400; });
|
||||
await page.waitForTimeout(200);
|
||||
await page.locator(".sessrow").nth(1).click();
|
||||
await page.waitForTimeout(500);
|
||||
await page.locator(".sessrow").nth(0).click();
|
||||
await page.waitForTimeout(500);
|
||||
kept = await page.evaluate(() => document.querySelector(".pane:not([data-off]) .scroll[data-pane]:not([hidden])").scrollTop);
|
||||
assert.equal(await scroller.evaluate((root) => root.scrollTop), 400, "the initial reading position must be reachable");
|
||||
if (SHOTS) await page.screenshot({ path: `${SHOTS}/before-hidden.png` });
|
||||
await second.click();
|
||||
await page.waitForFunction(() => document.querySelector('.sessrow[data-target="/sessions/pane-2.jsonl"]')?.getAttribute("aria-selected") === "true");
|
||||
assert.ok(await scroller.evaluate((root) => root.closest(".pane").hasAttribute("data-off")), "the original pane must actually be hidden");
|
||||
assert.equal(await page.locator(".pane").count(), n, "switching existing conversations must not open a new pane");
|
||||
await page.evaluate(() => {
|
||||
window.__feed({ kind: "turn_started" });
|
||||
window.__feed({ kind: "text", text: "Background scroll preservation probe." });
|
||||
window.__feed({ kind: "message" });
|
||||
window.__feed({ kind: "turn_done" });
|
||||
});
|
||||
await page.waitForFunction(() => document.querySelector(".pane:not([data-off]) .flow")?.textContent.includes("Background scroll preservation probe."));
|
||||
if (SHOTS) await page.screenshot({ path: `${SHOTS}/while-hidden.png` });
|
||||
await first.click();
|
||||
await page.waitForFunction(() => document.querySelector('.sessrow[data-target="/sessions/pane-1.jsonl"]')?.getAttribute("aria-selected") === "true");
|
||||
assert.ok(await scroller.evaluate((root) => root === document.querySelector('.pane:not([data-off]) .scroll[data-pane="flow"]')), "scroll-retention precondition: inspect the originally hidden transcript");
|
||||
kept = await scroller.evaluate((root) => root.scrollTop);
|
||||
if (SHOTS) await page.screenshot({ path: `${SHOTS}/after-return.png` });
|
||||
await second.click();
|
||||
await page.waitForFunction(() => {
|
||||
if (document.querySelector('.sessrow[data-target="/sessions/pane-2.jsonl"]')?.getAttribute("aria-selected") !== "true") return false;
|
||||
const root = document.querySelector('.pane:not([data-off]) .scroll[data-pane="flow"]');
|
||||
return root && root.scrollHeight - root.clientHeight - root.scrollTop <= 48;
|
||||
});
|
||||
console.log(`existing pane round trip: scrollTop 400 -> hidden with new output -> ${kept}; following neighbour remains at latest`);
|
||||
}
|
||||
await ctx.close();
|
||||
}
|
||||
|
||||
@@ -73,7 +73,7 @@ for (const [label, width, height] of [["wide", 1440, 900], ["phone", 390, 800]])
|
||||
await page.goto(`${PAGE}?queue=2&queuebody`, { waitUntil: "networkidle" });
|
||||
await page.waitForSelector(".queue .qi", { timeout: 15000 });
|
||||
await page.waitForTimeout(600);
|
||||
await page.locator('.queue .qi').first().locator('button:has-text("改")').click();
|
||||
await page.locator('.queue .qi').first().getByRole("button", { name: "改", exact: true }).click();
|
||||
await page.waitForTimeout(600);
|
||||
const q = await page.evaluate(() => {
|
||||
const el = document.querySelector(".queue .qedit");
|
||||
@@ -92,7 +92,7 @@ for (const [label, width, height] of [["wide", 1440, 900], ["phone", 390, 800]])
|
||||
await page.goto(`${PAGE}?queue=4&queuebody`, { waitUntil: "networkidle" });
|
||||
await page.waitForSelector(".queue .qi", { timeout: 15000 });
|
||||
await page.waitForTimeout(600);
|
||||
await page.locator(".queue .qi").nth(1).locator('button:has-text("改")').click();
|
||||
await page.locator(".queue .qi").nth(1).getByRole("button", { name: "改", exact: true }).click();
|
||||
await page.waitForTimeout(600);
|
||||
if (fill) { await page.locator(".queue .qedit").fill(fill); await page.waitForTimeout(400); }
|
||||
const g = await page.evaluate(() => {
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdir } from "node:fs/promises";
|
||||
import { chromium } from "playwright";
|
||||
|
||||
const base = process.env.PERF_URL ?? "http://localhost:4399/perf.html";
|
||||
const before = process.env.RECENT_BEFORE_URL;
|
||||
const shots = process.env.RECENT_SHOTS;
|
||||
if (shots) await mkdir(shots, { recursive: true });
|
||||
const browser = await chromium.launch();
|
||||
const errors = [];
|
||||
async function settle(page, selector) {
|
||||
await page.locator(selector).evaluateAll((elements) => Promise.all(elements.flatMap((element) => {
|
||||
element.getBoundingClientRect();
|
||||
return element.getAnimations().map((animation) => animation.finished.catch(() => {}));
|
||||
})));
|
||||
}
|
||||
async function showRail(page) {
|
||||
if (await page.locator(".rail[inert]").count()) {
|
||||
await page.locator('[data-action="chrome.rail"]:not(.railveil)').filter({ visible: true }).first().click();
|
||||
}
|
||||
await page.locator(".rail:not([inert]) .projects-header").waitFor();
|
||||
await settle(page, ".app, .rail");
|
||||
}
|
||||
try {
|
||||
for (const scheme of ["light", "dark"]) {
|
||||
for (const [size, width] of [["wide", 1440], ["phone", 390]]) {
|
||||
const page = await browser.newPage({ viewport: { width, height: 900 }, colorScheme: scheme, reducedMotion: "reduce" });
|
||||
page.on("pageerror", (error) => errors.push(error.message));
|
||||
const tag = `${scheme}-${size}`;
|
||||
if (before && shots) {
|
||||
await page.goto(`${before}?pref=en&rows=1&ws=2&sess=180&turns=2`, { waitUntil: "networkidle" });
|
||||
await showRail(page);
|
||||
await page.screenshot({ path: `${shots}/before-${tag}.png` });
|
||||
}
|
||||
await page.goto(`${base}?pref=en&rows=1&ws=2&sess=180&turns=2`, { waitUntil: "networkidle" });
|
||||
await showRail(page);
|
||||
if (shots) await page.screenshot({ path: `${shots}/after-heading-${tag}.png` });
|
||||
await page.locator('.wsrow[data-target="~/projects/workspace-1"]').click();
|
||||
await page.locator('.studio-session-segments [data-value="live"]').click();
|
||||
const recent = page.getByRole("button", { name: "Recent sessions", exact: true });
|
||||
const hit = await recent.evaluate((element) => {
|
||||
const r = element.getBoundingClientRect();
|
||||
return r.left >= 0 && r.right <= innerWidth && element.contains(document.elementFromPoint(r.x + r.width / 2, r.y + r.height / 2));
|
||||
});
|
||||
assert.ok(hit, `${tag}: Recent is reachable in the project heading`);
|
||||
await recent.click();
|
||||
const dialog = page.getByRole("dialog", { name: "Recent sessions", exact: true });
|
||||
await dialog.locator(".recent-session").first().waitFor();
|
||||
await settle(page, ".pickveil");
|
||||
assert.equal(await dialog.evaluate((element) => getComputedStyle(element).opacity), "1", "the dialog is actually painted");
|
||||
assert.equal(await dialog.locator(".recent-session").count(), 50);
|
||||
const bounds = await dialog.locator(".pickcard").boundingBox();
|
||||
assert.ok(bounds && bounds.x >= 0 && bounds.x + bounds.width <= width && bounds.y >= 0 && bounds.y + bounds.height <= 900, `${tag}: dialog stays inside the viewport`);
|
||||
if (shots) await page.screenshot({ path: `${shots}/after-recent-${tag}.png` });
|
||||
for (let i = 1; i <= 6; i++) {
|
||||
await dialog.getByRole("button", { name: "Next page", exact: true }).click();
|
||||
await dialog.getByText(`Page ${i + 1}`, { exact: true }).waitFor();
|
||||
await dialog.locator('.picklist[aria-busy="false"] .recent-session').first().waitFor();
|
||||
}
|
||||
assert.equal(await dialog.locator(".recent-session").count(), 50, "history past 300 remains paged rather than hidden");
|
||||
const selected = dialog.locator('.recent-session[data-target="/sessions/w1-s150.jsonl"]');
|
||||
await selected.click();
|
||||
await dialog.waitFor({ state: "detached" });
|
||||
const located = page.locator('.rail .sessrow[data-target="/sessions/w1-s150.jsonl"]');
|
||||
await located.waitFor();
|
||||
await page.waitForFunction(() => document.querySelector('.rail .sessrow[data-target="/sessions/w1-s150.jsonl"]')?.getAttribute("aria-selected") === "true");
|
||||
assert.equal(await page.locator('.studio-session-segments [data-value="all"]').getAttribute("aria-pressed"), "true");
|
||||
assert.equal(await page.locator('.wsrow[data-target="~/projects/workspace-1"]').getAttribute("aria-expanded"), "true");
|
||||
if (size === "wide") {
|
||||
assert.ok(await located.evaluate((element) => element === document.activeElement), `${tag}: selection focuses the session in its project`);
|
||||
} else {
|
||||
assert.equal(await page.locator(".app").getAttribute("data-rail"), "off", "phone navigation reveals the conversation");
|
||||
await showRail(page);
|
||||
assert.ok(await located.isVisible(), "reopening the phone rail reveals the selected session in its expanded project");
|
||||
const selectedBounds = await located.boundingBox();
|
||||
assert.ok(selectedBounds && selectedBounds.x >= 0 && selectedBounds.x + selectedBounds.width <= width, "the selected phone row is on screen, not covered by a closing rail");
|
||||
}
|
||||
if (shots) await page.screenshot({ path: `${shots}/after-located-${tag}.png` });
|
||||
console.log(`${tag}: heading hit target, 50-row global pages beyond 300, viewport containment, finished-session project location`);
|
||||
await page.close();
|
||||
}
|
||||
}
|
||||
const page = await browser.newPage({ viewport: { width: 1440, height: 900 } });
|
||||
await page.goto(`${base}?pref=en&turns=2`, { waitUntil: "networkidle" });
|
||||
await showRail(page);
|
||||
await page.evaluate(() => window.__feed({ kind: "turn_started" }));
|
||||
await page.locator('.studio-session-segments [data-value="live"]').click();
|
||||
await page.locator('.rail .sessrow[data-target="/sessions/mock.jsonl"]').waitFor();
|
||||
await page.evaluate(() => window.__feed({ kind: "turn_done" }));
|
||||
await page.locator('.rail .sessrow[data-target="/sessions/mock.jsonl"]').waitFor({ state: "detached" });
|
||||
await page.getByRole("button", { name: "Recent sessions", exact: true }).click();
|
||||
await page.locator('.recent-session[data-target="/sessions/mock.jsonl"]').click();
|
||||
await page.locator('.rail .sessrow[data-target="/sessions/mock.jsonl"]').waitFor();
|
||||
console.log("running -> finished -> Recent -> existing pane: passed");
|
||||
await page.close();
|
||||
assert.deepEqual(errors, [], "no page errors");
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
@@ -36,8 +36,9 @@ try {
|
||||
<time class="sessmeta sessage">9 天前</time>
|
||||
<span class="sessquick"><button data-action="session.archive">归档</button></span>
|
||||
</div>
|
||||
<div class="sessrow sesscopy">
|
||||
<div class="sessrow sesscopy" tabindex="0">
|
||||
<span class="sesstitle" title="早先版本 · 4 轮"><span>早先版本 · 4 轮</span></span>
|
||||
<button class="wsdel" data-action="session.delete" aria-label="删除该会话"><svg viewBox="0 0 16 16"><path d="M4 4l8 8M12 4l-8 8" /></svg></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>`);
|
||||
@@ -96,7 +97,26 @@ try {
|
||||
assert.ok(contained, "quick actions fit inside the compact row");
|
||||
}
|
||||
}
|
||||
console.log(`${scheme}/${width}: 34px rows, 1px spacing, single-line ellipsis, hover/focus stable`);
|
||||
// An earlier version has no row menu: its × is the only way a pointer deletes it.
|
||||
for (const how of ["hover", "focus"]) {
|
||||
await page.mouse.move(width + 200, 400);
|
||||
if (how === "hover") await page.locator(".sesscopy").hover();
|
||||
else await page.locator(".sesscopy").focus();
|
||||
await page.waitForFunction(() => getComputedStyle(document.querySelector(".sesscopy .wsdel")).opacity === "1", null, { timeout: 1000 }).catch(() => {});
|
||||
const del = await page.evaluate(() => {
|
||||
const row = document.querySelector(".sesscopy");
|
||||
const button = row.querySelector(".wsdel");
|
||||
const box = button.getBoundingClientRect();
|
||||
const edge = row.getBoundingClientRect();
|
||||
return {
|
||||
shown: box.width > 0 && box.height > 0 && getComputedStyle(button).opacity === "1",
|
||||
inside: box.top >= edge.top && box.bottom <= edge.bottom && box.left >= edge.left && box.right <= edge.right,
|
||||
};
|
||||
});
|
||||
assert.ok(del.shown, `${scheme}/${width}/${how}: an earlier version shows its delete button`);
|
||||
assert.ok(del.inside, `${scheme}/${width}/${how}: the delete button sits inside its row`);
|
||||
}
|
||||
console.log(`${scheme}/${width}: 34px rows, 1px spacing, single-line ellipsis, hover/focus stable, an earlier version's × shows`);
|
||||
await page.close();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
// The settings menu is short enough to show whole on a 1080p window, and each of its icons is drawn centred in
|
||||
// its box, so none of them sits above the label it belongs to.
|
||||
import { chromium } from "playwright";
|
||||
|
||||
const BASE = process.env.PERF_URL ?? "http://localhost:4399/perf.html?pref=zh&ws=1&sess=1&turns=1";
|
||||
const fails = [];
|
||||
const check = (name, ok, detail = "") => {
|
||||
console.log(`${ok ? " ok" : "FAIL"} ${name}${detail ? " — " + detail : ""}`);
|
||||
if (!ok) fails.push(name);
|
||||
};
|
||||
|
||||
const browser = await chromium.launch();
|
||||
try {
|
||||
for (const scheme of ["light", "dark"]) {
|
||||
const ctx = await browser.newContext({ locale: "zh-CN", colorScheme: scheme, viewport: { width: 1920, height: 1032 }, reducedMotion: "reduce" });
|
||||
const page = await ctx.newPage();
|
||||
await page.goto(BASE, { waitUntil: "networkidle" });
|
||||
await page.waitForSelector(".app");
|
||||
await page.evaluate(() => document.querySelector('[data-action="chrome.settings"]')?.click());
|
||||
await page.waitForSelector(".prefs-nav");
|
||||
await page.waitForTimeout(600);
|
||||
const nav = await page.evaluate(() => {
|
||||
const el = document.querySelector(".prefs-nav");
|
||||
return { need: el.scrollHeight, have: el.clientHeight };
|
||||
});
|
||||
check(`${scheme}: the whole menu shows without a scrollbar`, nav.need <= nav.have, `${nav.need}px needed, ${nav.have}px shown`);
|
||||
const icons = await page.evaluate(() =>
|
||||
[...document.querySelectorAll('.prefs-nav button[data-action="settings.section"] svg')].map((svg) => {
|
||||
const box = svg.viewBox.baseVal;
|
||||
const bb = svg.getBBox();
|
||||
return { name: svg.closest("button").textContent.trim().slice(0, 6), off: bb.y + bb.height / 2 - (box.y + box.height / 2) };
|
||||
}),
|
||||
);
|
||||
const worst = icons.reduce((a, b) => (Math.abs(b.off) > Math.abs(a.off) ? b : a), { name: "", off: 0 });
|
||||
check(`${scheme}: every icon is centred in its box vertically`, icons.length > 0 && Math.abs(worst.off) <= 0.8, `${icons.length} icons, worst ${worst.name} ${worst.off.toFixed(2)} units`);
|
||||
await ctx.close();
|
||||
}
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
if (fails.length) {
|
||||
console.error(`\n${fails.length} failed: ${fails.join("; ")}`);
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdir } from "node:fs/promises";
|
||||
import { chromium } from "playwright";
|
||||
|
||||
const base = process.env.PERF_URL ?? "http://localhost:4399/perf.html";
|
||||
const shots = process.env.SURFACE_SCREENSHOTS;
|
||||
if (shots) await mkdir(shots, { recursive: true });
|
||||
const browser = await chromium.launch();
|
||||
const errors = [];
|
||||
const surfaces = ["nav", "rail", "chrome", "side", "compose"];
|
||||
const opacity = {
|
||||
dark: { nav: 0.45, rail: 0.46, chrome: 0.8, side: 0.81, compose: 0.88 },
|
||||
light: { nav: 0.6, rail: 0.61, chrome: 0.86, side: 0.87, compose: 0.89 },
|
||||
};
|
||||
try {
|
||||
for (const scheme of ["light", "dark"]) for (const width of [1600, 390]) {
|
||||
const context = await browser.newContext({ viewport: { width, height: width === 390 ? 844 : 1000 }, reducedMotion: "reduce" });
|
||||
await context.addInitScript((scheme) => localStorage.setItem("rx-theme", scheme), scheme);
|
||||
const page = await context.newPage();
|
||||
page.on("pageerror", (error) => errors.push(error.message));
|
||||
await page.route("**/themes/bench-photo/background", (route) => route.fulfill({ contentType: "image/svg+xml", body: '<svg xmlns="http://www.w3.org/2000/svg" width="1600" height="1000"><rect width="1600" height="1000" fill="#638c99"/><circle cx="1100" cy="400" r="400" fill="#ddbf88"/></svg>' }));
|
||||
await page.goto(`${base}?pack=photo&ws=1&sess=2`, { waitUntil: "networkidle" });
|
||||
await page.waitForSelector(".compose");
|
||||
await page.evaluate(() => document.fonts.ready);
|
||||
await page.addStyleTag({ content: "* { transition: none !important; animation: none !important; caret-color: transparent !important }" });
|
||||
// Details no longer mount .side in the dock layout; phone omits .nav.
|
||||
// Exercise their actual stylesheet selectors without changing visible UI.
|
||||
await page.evaluate((surfaces) => {
|
||||
for (const name of surfaces) if (!document.querySelector(`.${name}`)) {
|
||||
const probe = document.createElement("aside");
|
||||
probe.className = name;
|
||||
probe.style.cssText = "position:fixed;left:-9999px;width:10px;height:10px";
|
||||
document.body.append(probe);
|
||||
}
|
||||
}, surfaces);
|
||||
const styles = () => page.evaluate((surfaces) => Object.fromEntries(surfaces.map((name) => {
|
||||
const style = getComputedStyle(document.querySelector(`.${name}`));
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = canvas.height = 1;
|
||||
const ctx = canvas.getContext("2d");
|
||||
ctx.fillStyle = style.backgroundColor;
|
||||
ctx.fillRect(0, 0, 1, 1);
|
||||
return [name, { alpha: ctx.getImageData(0, 0, 1, 1).data[3] / 255, background: style.backgroundColor, blur: style.backdropFilter, shadow: style.textShadow }];
|
||||
})), surfaces);
|
||||
const paint = async (surfaceOpacity, backdrop = "photo") => page.evaluate(async ({ surfaceOpacity, backdrop, scheme }) => {
|
||||
const { apply } = await import("/src/ui/theme.ts");
|
||||
const tokens = {
|
||||
light: { bg: "#F2F6F8", bgSoft: "#F9FCFD", panel: "#FFFFFF", border: "#CBD9E0", fg: "#12191C", fgDim: "#4E5D65", accent: "#0E6E82" },
|
||||
dark: { bg: "#080D10", bgSoft: "#0C1316", panel: "#131C21", border: "#23333A", fg: "#E4EEF2", fgDim: "#8298A2", accent: "#4FB6CE" },
|
||||
};
|
||||
apply({ id: "bench-photo", name: "Surface fixture", tokens, background: { image: backdrop === "photo", focusX: 0.5, focusY: 0.5, homeOpacity: 0.9, taskOpacity: 0.5, overlayStrength: 0.4, surfaceOpacity }, ...(backdrop === "sky" ? { sky: { rayAlpha: 0.85, cloudAlpha: 0.4 } } : {}) }, scheme);
|
||||
}, { surfaceOpacity, backdrop, scheme });
|
||||
const baseline = await styles();
|
||||
await paint({});
|
||||
assert.deepEqual(await styles(), baseline, "omission keeps every computed paint exactly");
|
||||
const empty = await page.screenshot({ animations: "disabled" });
|
||||
await paint(undefined);
|
||||
assert.deepEqual(await page.screenshot({ animations: "disabled" }), empty, "omission keeps pixels exactly");
|
||||
await paint(opacity);
|
||||
const custom = await styles();
|
||||
for (const name of surfaces) {
|
||||
assert.ok(Math.abs(custom[name].alpha - opacity[scheme][name]) <= 1 / 255, `${scheme} ${name}: ${custom[name].background}`);
|
||||
assert.equal(custom[name].blur, baseline[name].blur);
|
||||
assert.equal(custom[name].shadow, baseline[name].shadow);
|
||||
}
|
||||
if (shots) await page.screenshot({ path: `${shots}/${scheme}-${width}-after.png`, animations: "disabled" });
|
||||
await paint(opacity, "sky");
|
||||
for (const [name, value] of Object.entries(await styles())) assert.ok(Math.abs(value.alpha - opacity[scheme][name]) <= 1 / 255, `sky ${name}`);
|
||||
await paint(opacity, "none");
|
||||
const withoutBackdrop = await styles();
|
||||
await paint(undefined, "none");
|
||||
assert.deepEqual(await styles(), withoutBackdrop, "data-bg off ignores surface map");
|
||||
await paint({ [scheme]: { nav: 0, compose: 1 } });
|
||||
const boundaries = await styles();
|
||||
assert.equal(boundaries.nav.alpha, 0);
|
||||
assert.equal(boundaries.compose.alpha, 1);
|
||||
for (const name of ["rail", "chrome", "side"]) assert.equal(boundaries[name].background, baseline[name].background, `omitted ${name}`);
|
||||
await paint(undefined);
|
||||
assert.deepEqual(await styles(), baseline, "switching to an old pack clears custom paint");
|
||||
await page.evaluate(async () => (await import("/src/ui/theme.ts")).apply(null, "light"));
|
||||
assert.equal(await page.locator("#pack-theme").count(), 0);
|
||||
assert.equal(await page.evaluate(() => document.documentElement.dataset.bg), undefined);
|
||||
console.log(`ok ${scheme} ${width}: five computed alphas; exact omitted pixels; sky, backdrop removal, boundaries and pack clearing`);
|
||||
await context.close();
|
||||
}
|
||||
assert.deepEqual(errors, []);
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { createRoot } from "react-dom/client";
|
||||
import CodeEditor from "../src/ui/CodeEditor";
|
||||
|
||||
// A real editor on a given text, mounted into a given element, for the guard
|
||||
// that reads what a pack's syntax colours do to it.
|
||||
export function mount(host: HTMLElement, path: string, value: string, dark: boolean) {
|
||||
createRoot(host).render(<CodeEditor path={path} value={value} dark={dark} onChange={() => {}} onSave={() => {}} readOnly />);
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
// A pack's nine syntax colours must reach both places code is drawn — the file
|
||||
// editor and a rendered code block — and a pack that names none must leave both
|
||||
// exactly as they are. Only the browser resolves the variables, so only the
|
||||
// browser can say so.
|
||||
import { chromium } from "playwright";
|
||||
|
||||
const BASE = process.env.PERF_URL ?? "http://localhost:4399/perf.html";
|
||||
const fails = [];
|
||||
const check = (name, ok, detail = "") => {
|
||||
console.log(`${ok ? " ok" : "FAIL"} ${name}${detail ? " — " + detail : ""}`);
|
||||
if (!ok) fails.push(name);
|
||||
};
|
||||
|
||||
const NAMES = ["keyword", "string", "number", "function", "comment", "type", "operator", "punctuation", "variable"];
|
||||
const TOKEN = { keyword: "synKeyword", string: "synString", number: "synNumber", function: "synFunction", comment: "synComment", type: "synType", operator: "synOperator", punctuation: "synPunctuation", variable: "synVariable" };
|
||||
const HEX = ["#C01010", "#10A010", "#1010C0", "#C0A010", "#10A0A0", "#A010A0", "#808010", "#108060", "#6040C0"];
|
||||
const SAMPLE = 'class Box {}\n// note\nfunction add(a) { return a + 1 }\nconst s = "x"; run(3);\n';
|
||||
const HLJS = {
|
||||
keyword: "hljs-keyword", string: "hljs-string", number: "hljs-number", function: "hljs-title", comment: "hljs-comment",
|
||||
type: "hljs-type", operator: "hljs-operator", punctuation: "hljs-punctuation", variable: "hljs-variable",
|
||||
};
|
||||
|
||||
const browser = await chromium.launch();
|
||||
try {
|
||||
for (const scheme of ["light", "dark"]) {
|
||||
const page = await browser.newPage({ viewport: { width: 1200, height: 800 }, colorScheme: scheme });
|
||||
await page.goto(BASE, { waitUntil: "networkidle" });
|
||||
await page.waitForSelector(".app");
|
||||
const out = await page.evaluate(async ({ scheme, sample, hljs, names, token, hex }) => {
|
||||
const theme = window.__syntax;
|
||||
const { mount } = theme;
|
||||
const host = document.createElement("div");
|
||||
host.style.cssText = "position:fixed;left:0;top:0;width:700px;height:260px;z-index:2147483647";
|
||||
document.body.appendChild(host);
|
||||
mount(host, "probe.js", sample, scheme === "dark");
|
||||
const block = document.createElement("div");
|
||||
block.className = "md";
|
||||
block.style.cssText = "position:fixed;left:0;top:300px;width:700px;z-index:2147483647";
|
||||
block.innerHTML = `<pre class="term"><code>${names.map((n) => `<span class="${hljs[n]}" data-k="${n}">${n}</span>`).join(" ")}</code></pre>`;
|
||||
document.body.appendChild(block);
|
||||
for (let i = 0; i < 100 && !host.querySelector(".cm-line span"); i++) await new Promise((r) => setTimeout(r, 100));
|
||||
const rgb = (el) => getComputedStyle(el).color;
|
||||
const spanOf = (text, nth = 0) => [...host.querySelectorAll(".cm-line span")].filter((s) => s.textContent === text)[nth];
|
||||
const probe = {
|
||||
keyword: spanOf("class"), string: spanOf('"x"'), number: spanOf("3"), function: spanOf("run"), comment: spanOf("// note"),
|
||||
type: spanOf("Box"), operator: spanOf("+"), punctuation: spanOf("{"), variable: spanOf("a", 1) ?? spanOf("a"),
|
||||
};
|
||||
const read = () => ({
|
||||
editor: Object.fromEntries(names.map((n) => [n, probe[n] ? rgb(probe[n]) : null])),
|
||||
block: Object.fromEntries(names.map((n) => [n, rgb(block.querySelector(`[data-k="${n}"]`))])),
|
||||
inherited: rgb(block.querySelector("code")),
|
||||
});
|
||||
const tokens = (all) => Object.fromEntries(names.filter((n) => all || ["keyword", "string", "number", "function"].includes(n)).map((n) => [token[n], hex[names.indexOf(n)]]));
|
||||
const base = { bg: "#FFFFFF", bgSoft: "#F4F4F4", fg: "#1A1A1A", fgDim: "#5A5A5A", fgFaint: "#767676", accent: "#0066CC" };
|
||||
const pack = (all) => ({ id: "syn", name: "Syn", tokens: { light: { ...base, ...tokens(all) }, dark: { ...base, ...tokens(all) } } });
|
||||
const root = getComputedStyle(document.documentElement);
|
||||
const answer = {};
|
||||
theme.apply(null, scheme);
|
||||
answer.plain = read();
|
||||
answer.defaults = { faint: "", text: "", muted: "", fn: "" };
|
||||
const swatch = document.createElement("i");
|
||||
document.body.appendChild(swatch);
|
||||
for (const [k, v] of [["faint", "--faint"], ["text", "--text"], ["muted", "--muted"], ["fn", "--syn-fn"]]) { swatch.style.color = `var(${v})`; answer.defaults[k] = getComputedStyle(swatch).color; }
|
||||
theme.apply(pack(false), scheme);
|
||||
answer.four = read();
|
||||
answer.fourDefaults = {};
|
||||
swatch.style.color = "var(--syn-fn)"; answer.fourDefaults.fn = getComputedStyle(swatch).color;
|
||||
swatch.style.color = "var(--text)"; answer.fourDefaults.text = getComputedStyle(swatch).color;
|
||||
swatch.style.color = "var(--muted)"; answer.fourDefaults.muted = getComputedStyle(swatch).color;
|
||||
swatch.style.color = "var(--faint)"; answer.fourDefaults.faint = getComputedStyle(swatch).color;
|
||||
theme.apply(pack(true), scheme);
|
||||
answer.nine = read();
|
||||
theme.apply(null, scheme);
|
||||
void root;
|
||||
return answer;
|
||||
}, { scheme, sample: SAMPLE, hljs: HLJS, names: NAMES, token: TOKEN, hex: HEX });
|
||||
|
||||
const asRgb = (h) => { const n = parseInt(h.slice(1), 16); return `rgb(${n >> 16}, ${(n >> 8) & 255}, ${n & 255})`; };
|
||||
for (const surface of ["editor", "block"]) {
|
||||
const seen = NAMES.map((n) => out.nine[surface][n]);
|
||||
check(`${scheme} ${surface}: nine syntax tokens give nine distinct colours`, new Set(seen).size === 9, seen.join(" "));
|
||||
for (const n of NAMES) check(`${scheme} ${surface}: ${n} takes the pack's colour`, out.nine[surface][n] === asRgb(HEX[NAMES.indexOf(n)]), `${out.nine[surface][n]} wanted ${asRgb(HEX[NAMES.indexOf(n)])}`);
|
||||
}
|
||||
// A pack that names only the four old tokens leaves each new one on what that surface drew before.
|
||||
const d = out.fourDefaults;
|
||||
check(`${scheme} editor: comment keeps the faint ink`, out.four.editor.comment === d.faint);
|
||||
check(`${scheme} editor: type keeps the function colour`, out.four.editor.type === d.fn);
|
||||
for (const n of ["operator", "punctuation", "variable"]) check(`${scheme} editor: ${n} keeps the text ink`, out.four.editor[n] === d.text, `${out.four.editor[n]} vs ${d.text}`);
|
||||
check(`${scheme} block: comment keeps the faint ink`, out.four.block.comment === d.faint);
|
||||
check(`${scheme} block: type keeps the function colour`, out.four.block.type === d.fn);
|
||||
for (const n of ["operator", "punctuation"]) check(`${scheme} block: ${n} keeps the muted ink`, out.four.block[n] === d.muted, `${out.four.block[n]} vs ${d.muted}`);
|
||||
check(`${scheme} block: a variable keeps the ink it inherits`, out.four.block.variable === out.four.inherited, `${out.four.block.variable} vs ${out.four.inherited}`);
|
||||
await page.close();
|
||||
}
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
if (fails.length) {
|
||||
console.error(`\n${fails.length} failed: ${fails.join("; ")}`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log("\nall passed");
|
||||
@@ -0,0 +1,59 @@
|
||||
// A pane tab's close button has to stay inside its own tab whatever the tab
|
||||
// carries: past the tab's edge it sits on the next tab, and a press closes
|
||||
// nothing. The folder and host labels share the row with the title, so a long
|
||||
// one is what pushes it out. Only the browser lays this out.
|
||||
import { chromium } from "playwright";
|
||||
|
||||
const BASE = process.env.PERF_URL ?? "http://localhost:4399/perf.html";
|
||||
const fails = [];
|
||||
const check = (name, ok, detail = "") => {
|
||||
console.log(`${ok ? " ok" : "FAIL"} ${name}${detail ? " — " + detail : ""}`);
|
||||
if (!ok) fails.push(name);
|
||||
};
|
||||
|
||||
const LONG = "一个很长很长很长很长很长很长很长很长很长的会话标题";
|
||||
const cases = [
|
||||
["a long title", { title: LONG }],
|
||||
["a long title in a long folder", { title: LONG, ws: "streamhub-02-pipelines-smc-workspace" }],
|
||||
["a long folder and a long host", { title: LONG, ws: "streamhub-02-pipelines-smc-workspace", host: "build-runner-eu-west-long-hostname" }],
|
||||
];
|
||||
|
||||
const browser = await chromium.launch();
|
||||
try {
|
||||
const page = await browser.newPage({ viewport: { width: 1200, height: 760 } });
|
||||
await page.goto(BASE, { waitUntil: "networkidle" });
|
||||
await page.waitForSelector(".app");
|
||||
for (const [label, tab] of cases) {
|
||||
const got = await page.evaluate(({ title, ws, host }) => {
|
||||
document.querySelector(".probe-tabs")?.remove();
|
||||
const bar = document.createElement("div");
|
||||
bar.className = "panetabs probe-tabs";
|
||||
bar.style.cssText = "position:fixed;left:0;top:0;right:0;z-index:2147483647";
|
||||
const one = (t) => `<div class="ptab" aria-selected="${t === title}"><i class="pip"></i><span class="ptab-nm">${t}</span>`
|
||||
+ (ws ? `<span class="ptab-ws">${ws}</span>` : "") + (host ? `<span class="ptab-host">${host}</span>` : "")
|
||||
+ `<button class="ptab-x">×</button></div>`;
|
||||
bar.innerHTML = one(title) + one("hi");
|
||||
document.body.appendChild(bar);
|
||||
const first = bar.querySelector(".ptab");
|
||||
const box = first.getBoundingClientRect();
|
||||
const x = first.querySelector(".ptab-x").getBoundingClientRect();
|
||||
const hit = document.elementFromPoint(x.left + x.width / 2, x.top + x.height / 2);
|
||||
return {
|
||||
inside: x.left >= box.left - 0.5 && x.right <= box.right + 0.5,
|
||||
reached: hit === first.querySelector(".ptab-x"),
|
||||
title: Math.round(first.querySelector(".ptab-nm").getBoundingClientRect().width),
|
||||
detail: `tab ${Math.round(box.left)}–${Math.round(box.right)}, close ${Math.round(x.left)}–${Math.round(x.right)}`,
|
||||
};
|
||||
}, tab);
|
||||
check(`${label}: the close button stays inside its tab`, got.inside, got.detail);
|
||||
check(`${label}: a press on the close button reaches it`, got.reached);
|
||||
check(`${label}: the title keeps room to be read`, got.title >= 48, `${got.title}px`);
|
||||
}
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
if (fails.length) {
|
||||
console.error(`\n${fails.length} failed: ${fails.join("; ")}`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log("\nall passed");
|
||||
@@ -0,0 +1,110 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdir } from "node:fs/promises";
|
||||
import { chromium } from "playwright";
|
||||
|
||||
const BASE = process.env.PERF_URL ?? "http://localhost:4399/perf.html";
|
||||
const SHOTS = process.env.THEME_SCHEDULE_SHOTS;
|
||||
if (SHOTS) await mkdir(SHOTS, { recursive: true });
|
||||
const browser = await chromium.launch();
|
||||
const errors = [];
|
||||
try {
|
||||
for (const width of [1440, 390]) {
|
||||
const page = await browser.newPage({ viewport: { width, height: 1000 }, timezoneId: "America/New_York", reducedMotion: "reduce" });
|
||||
page.on("pageerror", (error) => errors.push(error.message));
|
||||
await page.clock.install({ time: new Date("2026-10-29T19:58:00-04:00") });
|
||||
await page.goto(`${BASE}?pref=en&turns=0`);
|
||||
await page.waitForSelector(".app");
|
||||
await page.keyboard.press("Meta+Comma");
|
||||
await page.getByRole("tab", { name: /Appearance/ }).click();
|
||||
await page.locator('[data-action="appearance.scheme"][data-value="scheduled"]').click();
|
||||
const start = page.getByLabel("Dark mode starts", { exact: true });
|
||||
const end = page.getByLabel("Light mode resumes", { exact: true });
|
||||
assert.equal(await start.inputValue(), "20:00");
|
||||
assert.equal(await end.inputValue(), "07:00");
|
||||
const boxes = await page.locator('#set-mode input[type="time"]').evaluateAll((inputs) => inputs.map((input) => {
|
||||
const box = input.getBoundingClientRect();
|
||||
return { left: box.left, right: box.right, width: box.width };
|
||||
}));
|
||||
assert.ok(boxes.every((box) => box.width > 0 && box.left >= 0 && box.right <= width));
|
||||
await page.clock.pauseAt(new Date("2026-10-29T19:59:59-04:00"));
|
||||
assert.equal(await page.locator("html").getAttribute("data-theme"), "light");
|
||||
assert.equal(await start.evaluate((input) => getComputedStyle(input).colorScheme), "light");
|
||||
if (SHOTS) {
|
||||
await page.locator("#set-mode").scrollIntoViewIfNeeded();
|
||||
await page.screenshot({ path: `${SHOTS}/after-light-${width}.png`, animations: "disabled" });
|
||||
}
|
||||
await page.clock.runFor(1000);
|
||||
assert.equal(await page.locator("html").getAttribute("data-theme"), "dark");
|
||||
assert.equal(await start.evaluate((input) => getComputedStyle(input).colorScheme), "dark");
|
||||
await page.clock.runFor(1000);
|
||||
if (SHOTS) await page.screenshot({ path: `${SHOTS}/after-dark-${width}.png`, animations: "disabled" });
|
||||
await page.clock.fastForward("03:59:58");
|
||||
assert.equal(await page.locator("html").getAttribute("data-theme"), "dark");
|
||||
await page.clock.runFor(1000);
|
||||
assert.equal(await page.locator("html").getAttribute("data-theme"), "dark");
|
||||
await page.clock.fastForward("07:00:00");
|
||||
assert.equal(await page.locator("html").getAttribute("data-theme"), "light");
|
||||
await start.fill("21:30");
|
||||
await end.fill("06:15");
|
||||
await page.clock.resume();
|
||||
await page.reload();
|
||||
await page.waitForSelector(".app");
|
||||
await page.keyboard.press("Meta+Comma");
|
||||
await page.getByRole("tab", { name: /Appearance/ }).click();
|
||||
assert.equal(await start.inputValue(), "21:30");
|
||||
assert.equal(await end.inputValue(), "06:15");
|
||||
assert.equal(await page.locator('[data-value="scheduled"]').getAttribute("aria-pressed"), "true");
|
||||
await page.clock.setSystemTime(new Date("2026-10-30T23:00:00-04:00"));
|
||||
await page.evaluate(() => window.dispatchEvent(new Event("focus")));
|
||||
assert.equal(await page.locator("html").getAttribute("data-theme"), "dark");
|
||||
await page.clock.setSystemTime(new Date("2026-10-31T09:00:00-04:00"));
|
||||
await page.evaluate(() => document.dispatchEvent(new Event("visibilitychange")));
|
||||
assert.equal(await page.locator("html").getAttribute("data-theme"), "light");
|
||||
await page.locator('[data-action="appearance.scheme"][data-value="dark"]').click();
|
||||
assert.equal(await start.count(), 0);
|
||||
await page.emulateMedia({ colorScheme: "light" });
|
||||
assert.equal(await page.locator("html").getAttribute("data-theme"), "dark");
|
||||
await page.locator('[data-action="appearance.scheme"][data-value="auto"]').click();
|
||||
assert.equal(await page.locator("html").getAttribute("data-theme"), "light");
|
||||
await page.emulateMedia({ colorScheme: "dark" });
|
||||
await page.waitForFunction(() => document.documentElement.dataset.theme === "dark");
|
||||
console.log(`ok ${width}px: exact boundaries, midnight, saved controls/reload, focus/visibility resume, explicit/system modes`);
|
||||
await page.close();
|
||||
}
|
||||
const page = await browser.newPage({ viewport: { width: 1440, height: 1000 }, timezoneId: "America/New_York", reducedMotion: "reduce" });
|
||||
page.on("pageerror", (error) => errors.push(error.message));
|
||||
await page.clock.install({ time: new Date("2026-11-01T00:59:00-04:00") });
|
||||
await page.goto(`${BASE}?pref=zh&turns=0&pack=sky`);
|
||||
await page.waitForSelector(".app");
|
||||
await page.keyboard.press("Meta+Comma");
|
||||
await page.getByRole("tab", { name: /外观/ }).click();
|
||||
await page.locator('[data-action="appearance.scheme"][data-value="scheduled"]').click();
|
||||
await page.getByLabel("深色开始时间", { exact: true }).fill("01:30");
|
||||
await page.getByLabel("恢复浅色时间", { exact: true }).fill("01:45");
|
||||
await page.clock.pauseAt(new Date("2026-11-01T01:29:59-04:00"));
|
||||
await page.clock.runFor(1000);
|
||||
assert.equal(await page.locator("html").getAttribute("data-theme"), "dark");
|
||||
assert.equal(await page.locator("html").getAttribute("data-pack"), "bench-sky");
|
||||
assert.equal(await page.evaluate(() => getComputedStyle(document.documentElement).getPropertyValue("--page").trim()), "#080D10");
|
||||
await page.clock.fastForward("15:00");
|
||||
assert.equal(await page.locator("html").getAttribute("data-theme"), "light");
|
||||
await page.clock.fastForward("45:00");
|
||||
assert.equal(await page.locator("html").getAttribute("data-theme"), "dark");
|
||||
await page.clock.fastForward("15:00");
|
||||
assert.equal(await page.locator("html").getAttribute("data-theme"), "light");
|
||||
assert.equal(await page.evaluate(() => getComputedStyle(document.documentElement).getPropertyValue("--page").trim()), "#F2F6F8");
|
||||
await page.getByLabel("深色开始时间", { exact: true }).fill("02:30");
|
||||
await page.getByLabel("恢复浅色时间", { exact: true }).fill("04:00");
|
||||
await page.clock.setSystemTime(new Date("2026-03-08T01:59:59-05:00"));
|
||||
await page.evaluate(() => window.dispatchEvent(new Event("focus")));
|
||||
assert.equal(await page.locator("html").getAttribute("data-theme"), "light");
|
||||
await page.clock.fastForward(1000);
|
||||
assert.equal(await page.locator("html").getAttribute("data-theme"), "dark");
|
||||
await page.clock.fastForward("01:00:00");
|
||||
assert.equal(await page.locator("html").getAttribute("data-theme"), "light");
|
||||
console.log("ok Chinese controls, active pack repaint, skipped and repeated DST hour transitions");
|
||||
await page.close();
|
||||
assert.deepEqual(errors, []);
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
@@ -1,21 +1,31 @@
|
||||
// Ink contrast per tier and scheme. Each tier's inks must be at least as legible
|
||||
// as the default palette stepped once, and as the recorded floors below (ratios
|
||||
// on --page; --ghost shares --faint's), and every ink must clear AA on both grounds.
|
||||
// Built-in soft deliberately lowers contrast without crossing AA, including
|
||||
// small ghost text. Unset and the higher tiers retain their recorded palettes.
|
||||
// Custom packs keep authored inks for soft; see TOKENS.md for that boundary.
|
||||
import { chromium } from "playwright";
|
||||
|
||||
const PAGE = process.env.PERF_URL ?? "http://localhost:4399/perf.html?pref=zh&ws=2&sess=6&turns=1";
|
||||
const STEPS = { "--text": [3.0, 10.1], "--muted": [3.5, 7.5], "--faint": [3.5, 7.0], "--ghost": [3.5, 7.0] };
|
||||
const INKS = Object.keys(STEPS);
|
||||
const GROUNDS = ["--page", "--surface"];
|
||||
const GROUNDS = ["--page", "--surface", "--raised", "--overlay", "--float", "--float-hi"];
|
||||
const TIERS = ["soft", "normal", "strong"];
|
||||
const FLOOR = {
|
||||
"light/soft": { "--text": 11.69, "--muted": 7.63, "--faint": 5.76 },
|
||||
"light/normal": { "--text": 14.07, "--muted": 10.22, "--faint": 7.86 },
|
||||
"light/strong": { "--text": 17.86, "--muted": 13.55, "--faint": 10.42 },
|
||||
"dark/soft": { "--text": 11.48, "--muted": 7.48, "--faint": 5.63 },
|
||||
"dark/normal": { "--text": 13.92, "--muted": 9.79, "--faint": 7.57 },
|
||||
"dark/strong": { "--text": 18.01, "--muted": 13.16, "--faint": 9.92 },
|
||||
};
|
||||
const SNAPSHOT = {
|
||||
light: {
|
||||
"": ["#292b29", "#5e625c", "#666a63"],
|
||||
normal: ["oklch(.245 .0218 85)", "oklch(.34 .0196 85)", "oklch(.405 .0196 85)"],
|
||||
strong: ["oklch(.103 .0196 85)", "oklch(.26 .0184 85)", "oklch(.335 .0184 85)"],
|
||||
},
|
||||
dark: {
|
||||
"": ["#e7e8e3", "#b4b7b0", "#8e9289"],
|
||||
normal: ["oklch(.9588 .006756 115.3)", "oklch(.81 .01031 125.5)", "oklch(.728 .013 255)"],
|
||||
strong: ["oklch(1 .006756 115.3)", "oklch(.893 .011 255)", "oklch(.806 .012 255)"],
|
||||
},
|
||||
};
|
||||
|
||||
const fails = [];
|
||||
const check = (name, ok, detail = "") => {
|
||||
@@ -29,7 +39,7 @@ page.on("pageerror", (e) => fails.push("page error: " + e.message));
|
||||
await page.goto(PAGE, { waitUntil: "networkidle" });
|
||||
await page.waitForSelector(".compose");
|
||||
|
||||
const measured = await page.evaluate(({ STEPS, INKS, GROUNDS, TIERS }) => {
|
||||
const measured = await page.evaluate(({ STEPS, INKS, GROUNDS, TIERS, SNAPSHOT }) => {
|
||||
const root = document.documentElement;
|
||||
const probe = document.createElement("div");
|
||||
document.body.append(probe);
|
||||
@@ -61,6 +71,15 @@ const measured = await page.evaluate(({ STEPS, INKS, GROUNDS, TIERS }) => {
|
||||
root.dataset.theme = scheme;
|
||||
delete root.dataset.contrast;
|
||||
const base = Object.fromEntries(INKS.map((n) => [n, read(n === "--ghost" ? "--faint" : n)]));
|
||||
for (const [tier, expected] of Object.entries(SNAPSHOT[scheme])) {
|
||||
if (tier) root.dataset.contrast = tier;
|
||||
else delete root.dataset.contrast;
|
||||
for (const [i, ink] of INKS.entries()) {
|
||||
probe.style.color = expected[Math.min(i, 2)];
|
||||
const ref = rgb(getComputedStyle(probe).color);
|
||||
out[`${scheme}/${tier || "unset"}/${ink}/snapshot`] = { got: rgb(read(ink)).join(","), ref: ref.join(",") };
|
||||
}
|
||||
}
|
||||
const grounds = Object.fromEntries(GROUNDS.map((g) => [g, rgb(read(g))]));
|
||||
for (const tier of TIERS) {
|
||||
const k = TIERS.indexOf(tier);
|
||||
@@ -70,21 +89,74 @@ const measured = await page.evaluate(({ STEPS, INKS, GROUNDS, TIERS }) => {
|
||||
probe.style.color = by ? `oklch(from ${base[ink]} calc(l ${scheme === "light" ? "-" : "+"} ${(by / 100).toFixed(3)}) c h)` : base[ink];
|
||||
const ref = rgb(getComputedStyle(probe).color);
|
||||
const got = rgb(read(ink));
|
||||
for (const g of GROUNDS) out[`${scheme}/${tier}/${ink}/${g}`] = { ref: ratio(ref, grounds[g]), got: ratio(got, grounds[g]) };
|
||||
for (const g of GROUNDS) out[`${scheme}/${tier}/${ink}/${g}`] = { ref: ratio(ref, grounds[g]), base: ratio(rgb(base[ink]), grounds[g]), got: ratio(got, grounds[g]) };
|
||||
}
|
||||
}
|
||||
}
|
||||
probe.remove();
|
||||
return out;
|
||||
}, { STEPS, INKS, GROUNDS, TIERS });
|
||||
}, { STEPS, INKS, GROUNDS, TIERS, SNAPSHOT });
|
||||
|
||||
for (const [key, { ref, got }] of Object.entries(measured)) {
|
||||
for (const [key, { ref, base, got }] of Object.entries(measured)) {
|
||||
const [scheme, tier, ink, ground] = key.split("/");
|
||||
const floor = ground === "--page" ? (FLOOR[`${scheme}/${tier}`][ink === "--ghost" ? "--faint" : ink] ?? 0) : 0;
|
||||
check(`${key} not below the stepped palette or its floor`, got >= Math.max(ref, floor) - 0.005, `${got.toFixed(2)} vs ${Math.max(ref, floor).toFixed(2)}`);
|
||||
if (ground === "snapshot") {
|
||||
check(`${key} stays unchanged`, got === ref, `${got} vs ${ref}`);
|
||||
continue;
|
||||
}
|
||||
if (tier === "soft") {
|
||||
const lowers = ink === "--text" || ink === "--muted";
|
||||
check(`${key} ${lowers ? "is genuinely softer than" : "retains the AA floor of"} unset`, lowers ? got < base - 0.1 : Math.abs(got - base) < 0.005, `${got.toFixed(2)} vs ${base.toFixed(2)}`);
|
||||
} else {
|
||||
const floor = ground === "--page" ? (FLOOR[`${scheme}/${tier}`][ink === "--ghost" ? "--faint" : ink] ?? 0) : 0;
|
||||
check(`${key} not below the stepped palette or its floor`, got >= Math.max(ref, floor) - 0.005, `${got.toFixed(2)} vs ${Math.max(ref, floor).toFixed(2)}`);
|
||||
}
|
||||
check(`${key} clears AA`, got >= 4.5, got.toFixed(2));
|
||||
}
|
||||
|
||||
const explicit = await page.evaluate((INKS) => {
|
||||
const root = document.documentElement;
|
||||
const p = document.createElement("div");
|
||||
document.body.append(p);
|
||||
const read = () => INKS.map((n) => ((p.style.color = `var(${n})`), getComputedStyle(p).color));
|
||||
const out = {};
|
||||
for (const scheme of ["light", "dark"]) {
|
||||
root.dataset.theme = scheme;
|
||||
for (const tier of ["soft", "normal", "strong"]) {
|
||||
root.dataset.contrast = tier;
|
||||
out[`${scheme}/${tier}`] = read();
|
||||
}
|
||||
}
|
||||
p.remove();
|
||||
return out;
|
||||
}, INKS);
|
||||
for (const system of ["light", "dark"]) {
|
||||
await page.emulateMedia({ colorScheme: system, contrast: "more" });
|
||||
for (const [key, expected] of Object.entries(explicit)) {
|
||||
const got = await page.evaluate(({ key, INKS }) => {
|
||||
const [scheme, tier] = key.split("/");
|
||||
document.documentElement.dataset.theme = scheme;
|
||||
document.documentElement.dataset.contrast = tier;
|
||||
const p = document.createElement("div");
|
||||
document.body.append(p);
|
||||
const out = INKS.map((n) => ((p.style.color = `var(${n})`), getComputedStyle(p).color));
|
||||
p.remove();
|
||||
return out;
|
||||
}, { key, INKS });
|
||||
check(`${key} outranks system ${system} and prefers-contrast: more`, JSON.stringify(got) === JSON.stringify(expected));
|
||||
}
|
||||
}
|
||||
await page.emulateMedia({ colorScheme: "dark", contrast: "no-preference" });
|
||||
const automaticDark = await page.evaluate((INKS) => {
|
||||
delete document.documentElement.dataset.theme;
|
||||
document.documentElement.dataset.contrast = "soft";
|
||||
const p = document.createElement("div");
|
||||
document.body.append(p);
|
||||
const out = INKS.map((n) => ((p.style.color = `var(${n})`), getComputedStyle(p).color));
|
||||
p.remove();
|
||||
return out;
|
||||
}, INKS);
|
||||
check("system dark soft matches explicit dark soft", JSON.stringify(automaticDark) === JSON.stringify(explicit["dark/soft"]));
|
||||
|
||||
for (const scheme of ["light", "dark"]) {
|
||||
await page.emulateMedia({ colorScheme: scheme, contrast: "more" });
|
||||
const same = await page.evaluate((scheme) => {
|
||||
|
||||
@@ -23,6 +23,7 @@ for (const scheme of ["light", "dark"]) {
|
||||
await page.evaluate(() => document.querySelector('[data-action="chrome.settings"]')?.click());
|
||||
await page.waitForSelector(".prefs-nav button");
|
||||
await page.evaluate(() => [...document.querySelectorAll(".prefs-nav button")].find((b) => b.textContent.includes("工具与权限"))?.click());
|
||||
await page.evaluate(() => document.getElementById("prefs-tools-web")?.click());
|
||||
await page.waitForSelector("#set-web-access");
|
||||
const r = await page.evaluate(() => {
|
||||
const g = document.querySelector("#set-web-access");
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
import { mkdirSync } from "node:fs";
|
||||
import { chromium } from "playwright";
|
||||
|
||||
const BASE = process.env.PERF_URL ?? "http://localhost:4399/perf.html";
|
||||
const SHOTS = process.env.SHOTS_DIR;
|
||||
const fails = [];
|
||||
const check = (name, ok, detail = "") => {
|
||||
console.log(`${ok ? " ok" : "FAIL"} ${name}${detail ? ` — ${detail}` : ""}`);
|
||||
if (!ok) fails.push(name);
|
||||
};
|
||||
if (SHOTS) mkdirSync(SHOTS, { recursive: true });
|
||||
|
||||
const measure = (page) => page.locator(".workbench-save").evaluate((button) => {
|
||||
const style = getComputedStyle(button);
|
||||
const bar = button.parentElement;
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = canvas.height = 1;
|
||||
const ctx = canvas.getContext("2d", { willReadFrequently: true });
|
||||
const rgba = (color) => {
|
||||
ctx.clearRect(0, 0, 1, 1);
|
||||
ctx.fillStyle = color;
|
||||
ctx.fillRect(0, 0, 1, 1);
|
||||
return [...ctx.getImageData(0, 0, 1, 1).data].map((v, i) => i === 3 ? v / 255 : v);
|
||||
};
|
||||
const over = (front, back) => front.slice(0, 3).map((v, i) => v * front[3] + back[i] * (1 - front[3]));
|
||||
const ancestors = [];
|
||||
for (let node = button.parentElement; node; node = node.parentElement) ancestors.unshift(node);
|
||||
let ground = [255, 255, 255];
|
||||
for (const node of ancestors) ground = over(rgba(getComputedStyle(node).backgroundColor), ground);
|
||||
const opacity = Number(style.opacity);
|
||||
const fill = over(rgba(style.backgroundColor), ground);
|
||||
const ink = over(rgba(style.color), fill);
|
||||
const lum = (rgb) => rgb.map((v) => {
|
||||
v /= 255;
|
||||
return v <= 0.04045 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4;
|
||||
}).reduce((sum, v, i) => sum + v * [0.2126, 0.7152, 0.0722][i], 0);
|
||||
const [hi, lo] = [lum(over([...ink, opacity], ground)), lum(over([...fill, opacity], ground))].sort((a, b) => b - a);
|
||||
const text = [...button.childNodes].find((node) => node.nodeType === Node.TEXT_NODE && node.textContent.trim());
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(text);
|
||||
const width = button.getBoundingClientRect().width;
|
||||
const natural = range.getBoundingClientRect().width + button.querySelector("svg").getBoundingClientRect().width
|
||||
+ parseFloat(style.columnGap) + parseFloat(style.paddingLeft) + parseFloat(style.paddingRight)
|
||||
+ parseFloat(style.borderLeftWidth) + parseFloat(style.borderRightWidth);
|
||||
const rect = bar.getBoundingClientRect();
|
||||
const children = [...bar.children].map((node) => node.getBoundingClientRect());
|
||||
return {
|
||||
width, natural, contrast: (hi + 0.05) / (lo + 0.05), disabled: button.disabled,
|
||||
singleRow: Math.max(...children.map((r) => r.top)) < Math.min(...children.map((r) => r.bottom)),
|
||||
contained: children.every((r) => r.left >= rect.left - 1 && r.right <= rect.right + 1),
|
||||
};
|
||||
});
|
||||
|
||||
const browser = await chromium.launch();
|
||||
try {
|
||||
for (const scheme of ["light", "dark"]) {
|
||||
for (const locale of ["zh", "en"]) {
|
||||
for (const width of [1440, 390]) {
|
||||
for (const desktop of [true, false]) {
|
||||
const page = await browser.newPage({ viewport: { width, height: 1000 }, colorScheme: scheme, reducedMotion: "reduce" });
|
||||
page.setDefaultTimeout(15000);
|
||||
page.on("pageerror", (error) => fails.push(error.message));
|
||||
// Native capabilities render both optional toolbar controls; no native action is invoked.
|
||||
if (desktop) await page.addInitScript(() => {
|
||||
window.reasonixHost = {
|
||||
shell: "electron", platform: "win32", titleBar: false,
|
||||
isWindowMaximised: async () => false, setUnreadBadge: async () => {},
|
||||
revealPath: async () => null, revealWorkspace: async () => null,
|
||||
};
|
||||
});
|
||||
const url = new URL(BASE);
|
||||
url.searchParams.set("pref", locale);
|
||||
url.searchParams.set("ws", "2");
|
||||
url.searchParams.set("sess", "6");
|
||||
url.searchParams.set("turns", "2");
|
||||
await page.goto(url.href, { waitUntil: "networkidle" });
|
||||
const workbench = page.locator('[data-action="pane.view"][data-value="browser"]');
|
||||
if (!(await workbench.isVisible())) await page.locator('[data-action="chrome.more"]').click();
|
||||
await workbench.click();
|
||||
await page.locator('[data-action="workbench.launch-files"]').click();
|
||||
await page.locator(".workbench-filelist button").filter({ hasText: "README.md" }).click();
|
||||
await page.locator(".workbench-save").waitFor();
|
||||
await page.evaluate(() => document.fonts.ready);
|
||||
const tag = `${scheme}/${locale}/${width}/${desktop ? "desktop" : "browser"}`;
|
||||
const verify = async (state) => {
|
||||
await page.locator(".workbench-save").evaluate(async (button) => {
|
||||
await Promise.all(button.getAnimations().map((animation) => animation.finished.catch(() => {})));
|
||||
});
|
||||
const result = await measure(page);
|
||||
check(`${tag}/${state}: content-sized Save`, result.width <= result.natural + 1, `${result.width.toFixed(1)}px vs ${result.natural.toFixed(1)}px content`);
|
||||
check(`${tag}/${state}: controls share one row`, result.singleRow);
|
||||
check(`${tag}/${state}: controls stay inside toolbar`, result.contained);
|
||||
check(`${tag}/${state}: readable label`, result.contrast >= 4.5, `${result.contrast.toFixed(2)}:1`);
|
||||
check(`${tag}/${state}: native disabled state`, result.disabled === (state !== "modified"));
|
||||
if (SHOTS && desktop && locale === "zh") await page.screenshot({ path: `${SHOTS}/${scheme}-${width}-${state}.png` });
|
||||
};
|
||||
await verify("unchanged");
|
||||
await page.locator('[data-action="workbench.mode"][data-value="file"]').click();
|
||||
const editor = page.locator(".cm-content");
|
||||
await editor.fill("# Save button regression\n\nChanged in the editor.");
|
||||
await page.locator(".workbench-save:not(:disabled)").waitFor();
|
||||
await verify("modified");
|
||||
await page.locator(".workbench-save").click();
|
||||
await page.locator(".workbench-save:disabled").waitFor();
|
||||
await verify("saved");
|
||||
await page.locator('[data-action="workbench.mode"][data-value="read"]').click();
|
||||
check(`${tag}: saved content remains readable`, await page.locator(".workbench-read h1").textContent() === "Save button regression");
|
||||
await page.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
if (fails.length) {
|
||||
console.error(`${fails.length} failures: ${fails.join("; ")}`);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
@@ -69,14 +69,14 @@ importers:
|
||||
specifier: ^1.2.5
|
||||
version: 1.2.5
|
||||
'@mantine/charts':
|
||||
specifier: 9.7.2
|
||||
version: 9.7.2(@mantine/[email protected].2(@mantine/[email protected].2([email protected]))(@types/[email protected])([email protected]([email protected]))([email protected]))(@mantine/[email protected].2([email protected]))([email protected]([email protected]))([email protected])([email protected](@types/[email protected])([email protected]([email protected]))([email protected])([email protected])([email protected]))
|
||||
specifier: 9.7.3
|
||||
version: 9.7.3(@mantine/[email protected].3(@mantine/[email protected].3([email protected]))(@types/[email protected])([email protected]([email protected]))([email protected]))(@mantine/[email protected].3([email protected]))([email protected]([email protected]))([email protected])([email protected](@types/[email protected])([email protected]([email protected]))([email protected])([email protected])([email protected]))
|
||||
'@mantine/core':
|
||||
specifier: 9.7.2
|
||||
version: 9.7.2(@mantine/[email protected].2([email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])
|
||||
specifier: 9.7.3
|
||||
version: 9.7.3(@mantine/[email protected].3([email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])
|
||||
'@mantine/hooks':
|
||||
specifier: 9.7.2
|
||||
version: 9.7.2([email protected])
|
||||
specifier: 9.7.3
|
||||
version: 9.7.3([email protected])
|
||||
'@noble/curves':
|
||||
specifier: 2.4.0
|
||||
version: 2.4.0
|
||||
@@ -90,11 +90,11 @@ importers:
|
||||
specifier: ^0.19.0
|
||||
version: 0.19.0
|
||||
lucide-react:
|
||||
specifier: ^1.60.0
|
||||
version: 1.60.0(@types/[email protected])([email protected])
|
||||
specifier: ^1.61.0
|
||||
version: 1.61.0(@types/[email protected])([email protected])
|
||||
maplibre-gl:
|
||||
specifier: 6.14.0
|
||||
version: 6.14.0
|
||||
specifier: 6.15.0
|
||||
version: 6.15.0
|
||||
papaparse:
|
||||
specifier: ^5.7.0
|
||||
version: 5.7.0
|
||||
@@ -109,7 +109,7 @@ importers:
|
||||
version: 19.3.1([email protected])
|
||||
react-map-gl:
|
||||
specifier: 8.1.3
|
||||
version: 8.1.3([email protected]4.0)([email protected]([email protected]))([email protected])
|
||||
version: 8.1.3([email protected]5.0)([email protected]([email protected]))([email protected])
|
||||
react-markdown:
|
||||
specifier: ^10.1.0
|
||||
version: 10.1.0(@types/[email protected])([email protected])
|
||||
@@ -161,7 +161,7 @@ importers:
|
||||
version: 19.3.0(@types/[email protected])
|
||||
'@vitejs/plugin-react':
|
||||
specifier: ^6.1.2
|
||||
version: 6.1.2([email protected].4(@types/[email protected])([email protected]))
|
||||
version: 6.1.2([email protected].5(@types/[email protected])([email protected]))
|
||||
jsdom:
|
||||
specifier: ^30.1.3
|
||||
version: 30.1.3(@noble/[email protected])
|
||||
@@ -172,11 +172,11 @@ importers:
|
||||
specifier: ^7.0.2
|
||||
version: 7.0.2
|
||||
vite:
|
||||
specifier: ^8.3.4
|
||||
version: 8.3.4(@types/[email protected])([email protected])
|
||||
specifier: ^8.3.5
|
||||
version: 8.3.5(@types/[email protected])([email protected])
|
||||
vitest:
|
||||
specifier: ^5.0.4
|
||||
version: 5.0.4(@types/[email protected])([email protected](@noble/[email protected]))([email protected].4(@types/[email protected])([email protected]))
|
||||
specifier: ^5.0.5
|
||||
version: 5.0.5(@types/[email protected])([email protected](@noble/[email protected]))([email protected].5(@types/[email protected])([email protected]))
|
||||
|
||||
packages:
|
||||
|
||||
@@ -580,24 +580,24 @@ packages:
|
||||
'@lit/[email protected]':
|
||||
resolution: {integrity: sha512-pbCDiVMnne1lYUIaYNN5wrwQXDtHaYtg7YEFPeW+hws6U47WeFvISGUWekPGKWOP1ygrs0ef0o1VJMk1exos5A==}
|
||||
|
||||
'@mantine/[email protected].2':
|
||||
resolution: {integrity: sha512-biwAZ4RDSmhy2dqeyqBK1XlFg3+eZGYyt3bEDuTBu/at/fbAguhIsmFTbqYjqMcTpWNyX9BNxJ+mhMFslee1eQ==}
|
||||
'@mantine/[email protected].3':
|
||||
resolution: {integrity: sha512-p8SMOVEmQATcHIszSYdKdU6MBq8Ozdm+QJeFz+Zrtpquv0+ZVHIb262emZV544a7UmwOGdMN5DxmhQ8lewrbvg==}
|
||||
peerDependencies:
|
||||
'@mantine/core': 9.7.2
|
||||
'@mantine/hooks': 9.7.2
|
||||
'@mantine/core': 9.7.3
|
||||
'@mantine/hooks': 9.7.3
|
||||
react: ^19.2.0
|
||||
react-dom: ^19.2.0
|
||||
recharts: '>=3.2.1'
|
||||
|
||||
'@mantine/[email protected].2':
|
||||
resolution: {integrity: sha512-+cggJDD0PO057SwZU/dKlWPjp0LaAn1nEM9W1m/Ia4Cjb/Hy9DjZ4I7R7xirp/eZ6xnV7OqeGmPFSJ8zeGa5kQ==}
|
||||
'@mantine/[email protected].3':
|
||||
resolution: {integrity: sha512-wYJ9Bd82a7HqwJ+hubFqUIocBGMuw6txzdPnnleHvv1h0NlnkESevK/WHTRh4FtOw33B5N2ADT34s3x1/8d8jg==}
|
||||
peerDependencies:
|
||||
'@mantine/hooks': 9.7.2
|
||||
'@mantine/hooks': 9.7.3
|
||||
react: ^19.2.0
|
||||
react-dom: ^19.2.0
|
||||
|
||||
'@mantine/[email protected].2':
|
||||
resolution: {integrity: sha512-11emsRp/Mg+vfaKVyoMwiMdCHmO/15XOL6JG9fACkclVZnLPBVvRIaDdRBudsR3ALGZ5iTzrArPJDjjoeO6X4w==}
|
||||
'@mantine/[email protected].3':
|
||||
resolution: {integrity: sha512-GMHoMS1qul1dBLIHd/N9ef2Pr8UKYYHfuM+g2ej3gYqU0kH4sTiR9FNHksoTQi0PzATRoaNlUYjjMe9rbDRw5A==}
|
||||
peerDependencies:
|
||||
react: ^19.2.0
|
||||
|
||||
@@ -620,8 +620,8 @@ packages:
|
||||
'@mapbox/[email protected]':
|
||||
resolution: {integrity: sha512-Qf10S1uIHMk20ri/IVBnpS+esUEkVaR5Hftmz88jTInrpmWgPGJfPe3LVjjlE77trLx8tH6qjTG7uWH9hIq/0Q==}
|
||||
|
||||
'@maplibre/geojson-vt@6.1.2':
|
||||
resolution: {integrity: sha512-2ZYhobdAY+y4/P/z1xe0698165LywD0yDdWugdu3NRUrajgVWut+tLkcz7i/RJguMTURwK7gxwmwYMFJbgbQFw==}
|
||||
'@maplibre/geojson-vt@6.3.0':
|
||||
resolution: {integrity: sha512-3Zw2jsU9pIJG3G2CQFbLjWj2MlAHodhD7cSY+15iywUuLmmkgzcKRnueVRZ4AcC/5wOtfhidcJAAzBpoBNs3Xg==}
|
||||
|
||||
'@maplibre/[email protected]':
|
||||
resolution: {integrity: sha512-cOZZOVhDSulgK0meTsTkmNXb1ahVvmTmWmfx9gRBwc6hq98wS9JP35ESIoNq3xqEan+UN+gn8187Z6E4NKhLsw==}
|
||||
@@ -723,8 +723,8 @@ packages:
|
||||
resolution: {integrity: sha512-X5XaVWZIBCT7HHZGm5I7ZQXDwLG+bGXuSrMQAW+7Zvl87h1kmc1ZB1VSRJcpUfoUrGQp4Fkoxm5kZ+Ms+aW+eA==}
|
||||
engines: {node: '>= 20.19.0'}
|
||||
|
||||
'@oxc-project/[email protected]2.0':
|
||||
resolution: {integrity: sha512-oM/5rLBm2tPkg0iBgkH/FOeR3PCDpY19GTgAZjMFM8h9WI9VW7cLgzp6nwtarYKmovavIQZ+Fe/RKX/8C8O/Rw==}
|
||||
'@oxc-project/[email protected]5.0':
|
||||
resolution: {integrity: sha512-SeE8JPwW1xs1tHeNycjUtUBdhorRa8o4jlvjwk49R7/Z291i+4DQa7JJIgOm/TaYQZx+QrPQbVJ/9c8dMIZN8Q==}
|
||||
|
||||
'@preact/[email protected]':
|
||||
resolution: {integrity: sha512-HNB6HYeYKhQbJ1aKl+YRjrS4+QWHLKX6qKoUsfS/m0vqzsVaEBiZiaKbG/e+NKk2ch5ALQr/ihWaMHxiCuuWHA==}
|
||||
@@ -740,98 +740,98 @@ packages:
|
||||
react-redux:
|
||||
optional: true
|
||||
|
||||
'@rolldown/[email protected]2':
|
||||
resolution: {integrity: sha512-dB/a1214qKfHMXCpgqR4OZT+jS4kTyEXbQGJPqzobt5EwH5rX080pxE37alt3RzvR1bf1Yz/yGqRfrYAxuPw0A==}
|
||||
'@rolldown/[email protected]5':
|
||||
resolution: {integrity: sha512-aLcgK9gIJ3YRvrrtfSBbRa27F5WrpiTaPulnFpsHPxpUHgFlSgnU4854MHwoRGEBhfRJDWCS2WQ7lIjskE9Olw==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm]
|
||||
os: [android]
|
||||
|
||||
'@rolldown/[email protected]2':
|
||||
resolution: {integrity: sha512-7KHFgQ5VJxIHcLlrwrc3Xbds7oTNQT7Pgi9gQCJKrd2VGab/UksIOYp6VD8MzCstGxOKMgNamPwUCfxPdP1OHg==}
|
||||
'@rolldown/[email protected]5':
|
||||
resolution: {integrity: sha512-xD7DdZCrbThKuxNg7ooQmvp07ys4J/pnSEMTG3/OdOWl6daC67m/Q4fL78sRHMxTH/N4fGUbYhcPM0MF5WFHkg==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm64]
|
||||
os: [android]
|
||||
|
||||
'@rolldown/[email protected]2':
|
||||
resolution: {integrity: sha512-3YIhqHD96nA5SaYNRBR16HnGv4oavZvXfD/ayHM+oYZ0WD/8lBAtf6zQua4kEyAvpqrluKXl0lnOBoiNby7x9w==}
|
||||
'@rolldown/[email protected]5':
|
||||
resolution: {integrity: sha512-m2WuQ6r0B4OTYxyPpNLtXaMRbRDUbG8YQV2Q3KTdSSK8Th6KHCnC8jVCJt8bvYeSB5WivkkNbc85mKadaSdOxA==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@rolldown/[email protected]2':
|
||||
resolution: {integrity: sha512-UuuJ35MFw4gmFOrE9pEqIV+K3syIKveph+Qc1/ljHZVdoDW4pz/JHR/eMVom+TZGl/5OOvGJOWaOCVt3ZfqhxA==}
|
||||
'@rolldown/[email protected]5':
|
||||
resolution: {integrity: sha512-5RtnsHM36nodM9WbMy+zip0LGS/G+y5wro5RHrpOlr4ac24+1zSi9kiV2MP1myZeZ/ifEHfulQcWjnAXzc2C2A==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@rolldown/[email protected]2':
|
||||
resolution: {integrity: sha512-uMvssit0a4W+/7D8CbHUvG719mH3R2jwXAlh/XcPvuHTE0g++LymF88DCGNX0HM2rBOn0xrzgXktIB6fLSJBTQ==}
|
||||
'@rolldown/[email protected]5':
|
||||
resolution: {integrity: sha512-g3l09pyFqhu+135JKkbkwiKGQmwU5hUMUPJ4VdBADvuqR8d33L6H578rh8m7rtxbdp+VmX/+nzI7nvhSZlzpUQ==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [x64]
|
||||
os: [freebsd]
|
||||
|
||||
'@rolldown/[email protected]2':
|
||||
resolution: {integrity: sha512-XcFu0R0xWnwzSf4IQgFH1rJIckPN1pLy2R+4r9IDB7Yfu/ys9cVqfa4pBrMHj7a3gl8mIR4nRNPg0e5IvEVs6g==}
|
||||
'@rolldown/[email protected]5':
|
||||
resolution: {integrity: sha512-nIe3VugI97nGQtspPxDqA4sXMUpeiocKIP/CqpY/u/vgbT+alX+cVxu1C/oVBPKg4feNTmYovE91v4DgXvXNwQ==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
|
||||
'@rolldown/[email protected]2':
|
||||
resolution: {integrity: sha512-260UrKgn8tz39ak+SMDOirKzr7V04M9dWPw5llW00SwBivCZoWcRBKV1d8cXnRkUmSZA3BdiUmBHWk7734Ulpw==}
|
||||
'@rolldown/[email protected]5':
|
||||
resolution: {integrity: sha512-iTBjXx+B5QSvgI89lJfmqh4wTh8NabR1YZIIN3hNx4zSG0EVfVqkOvLulNpG4251pbd6/IAmZ/LefVk1Y4rPhw==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rolldown/[email protected]2':
|
||||
resolution: {integrity: sha512-5YK1I9SqDkbPgc1IA8BgDl34suqUS2q0KWnBrirm0E51YjOs6eo6dV6jbQfNE/argHRSvd0QUGgtpIoYx+WWpw==}
|
||||
'@rolldown/[email protected]5':
|
||||
resolution: {integrity: sha512-uN26XOnVR7rqQupG3V9oAG2EGGatWjcJHOeDOHUFCh4TBWmP2tHTFoVN13aYbwfm0aHqJAEmcwhAu6sooIjfqQ==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rolldown/[email protected]2':
|
||||
resolution: {integrity: sha512-Rkcrmp7eFRg74yL5fXEU91JEWbdEPLevWwGtXpmhbjlD1StScbWTmO94Bhly+Mo+ketKYkdmM1vNUKeWSlx8cQ==}
|
||||
'@rolldown/[email protected]5':
|
||||
resolution: {integrity: sha512-9nWkcdd+H+XhaA3fQIwpHnKIrfx/PQ6rO0pivr78Og07tXlUlctBBJoEYLPfhTmCR/QxEVdn6uPjHV5SymPhdw==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rolldown/[email protected]2':
|
||||
resolution: {integrity: sha512-qvK4DuAsQc2BSjlx+Xr+IzOIvvxbGZqxFwdWfG6F518Erj0GGISyQbJ6pIappnOxlNPzNHvo/L0BwB30GZ+zVw==}
|
||||
'@rolldown/[email protected]5':
|
||||
resolution: {integrity: sha512-Ggh0ciFjLwIusLSLz7LZYEaGdxOAUJXI+Qu5JCbhPJJHEt8vP+IjRATTJheiJsdzzTYgUiHUiw3X1Ea3ZpMBdA==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rolldown/[email protected]2':
|
||||
resolution: {integrity: sha512-Q9uLBO53Xd4QIq1WOycVQyPP1O4HhraEV2qqb3uTrnVw6QZih9duY4vNXOivL1xoUS1/z+W8eF4NMfl2a8Sdjw==}
|
||||
'@rolldown/[email protected]5':
|
||||
resolution: {integrity: sha512-FHg60rS6DBePAYZ9X9F67x+JObFdmgn6tupA1C/a3B+CufFmTD4mX+e8M75tCiAO3foR/6xwVBI0KQiI7sn/rw==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rolldown/[email protected]2':
|
||||
resolution: {integrity: sha512-3IBxWFMjbOZskDPKv8Lf9BCnahlKuHthWkYnyIxOH/QcJrFcS4EmcenthApkwr/5+nEqZlLzeYbxeMaX7A5u4g==}
|
||||
'@rolldown/[email protected]5':
|
||||
resolution: {integrity: sha512-/cvV/7v5VGz/moiz0XKRj2dE8DzsKyhIu1KpOvJGPjRywg3yscON889UGvAe8zfiJLHup7VLjBduoGWi3HusWQ==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rolldown/[email protected]2':
|
||||
resolution: {integrity: sha512-xtX61xg4LKPkPWilZU1ynKClz5Gj4bf74LML4r3eVLWumKnGjoEr1OSHQhMdbBDoYTi+yjrujvpZe2pUnqCrrA==}
|
||||
'@rolldown/[email protected]5':
|
||||
resolution: {integrity: sha512-MyzC5Hrv/sm+Z7TSGKP/j8001RO2xQm1zOpCNVL4j0cY/VhBZviludA2YFQ7i4lH6ZAXxQm0lcMdBzM+99mE7w==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm64]
|
||||
os: [openharmony]
|
||||
|
||||
'@rolldown/[email protected]2':
|
||||
resolution: {integrity: sha512-At7fPB6PCaIjzgIhEZFxuT+BBFqiQibJDT4d3PhiR3f4E7bbMZF4aKblbFfEM3sETRDd1YiQx/+U/g/B/ou5Ew==}
|
||||
'@rolldown/[email protected]5':
|
||||
resolution: {integrity: sha512-+PgYiS+AH5omy6L45ZLxybasauJaj3M8buoT83D+nzPmCayXlxUV9hydhciCKVyIg0B/v0BtXPDE1uGqNwCPpg==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
|
||||
'@rolldown/[email protected]2':
|
||||
resolution: {integrity: sha512-WIw2haVKwjuYdXkHaoC0mF8Le71TuCBxjrdKqLbJGctbBABj+ClfmNvtbOnzpq3RokNo5+V1qhtSzJyXorsklQ==}
|
||||
'@rolldown/[email protected]5':
|
||||
resolution: {integrity: sha512-HFZd2QNQYn1JbIi+Hpd7H+r/lkWxcfIu28nirZ6eCn2LafuvmExMlb/P96Sfx5tf7pPArw39IdhmCF8vmV+NhQ==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
@@ -1115,8 +1115,8 @@ packages:
|
||||
oxc-transform-react:
|
||||
optional: true
|
||||
|
||||
'@vitest/[email protected].4':
|
||||
resolution: {integrity: sha512-tqAlrYWce4iUxhr3ncNohejtCTOcHeE8XWrSNZY3OhPoDn01yeGv+gnaBy64ATp7o6pb03eIbBAjzmibpc9vIQ==}
|
||||
'@vitest/[email protected].5':
|
||||
resolution: {integrity: sha512-xLL3lkDegJZw+MGoGaCCsFayDZthGgpAtr3Vziyfc4p7xH89K7KRPcRBBh/kEYm0Aqkt5DtTmkYpBtRV/FrHZg==}
|
||||
peerDependencies:
|
||||
msw: ^2.4.9 || ^3.0.0
|
||||
vite: ^6.0.0 || ^7.0.0 || ^8.0.0
|
||||
@@ -1126,8 +1126,8 @@ packages:
|
||||
vite:
|
||||
optional: true
|
||||
|
||||
'@vitest/[email protected].4':
|
||||
resolution: {integrity: sha512-MZKk/gSkm2q+ICobMxCpH1mA+cCAWm8WV7UhxlqGjq/sJ3AGc1BqFOs5CRVXxH78wQtme9q/NAAgBquj4AcyMw==}
|
||||
'@vitest/[email protected].5':
|
||||
resolution: {integrity: sha512-lwJRExuH417257Q5wv9QYugN9IMJkoxcrKrYxGPQkBzl7wlNpTUxD/5yNX4ESKVUIShKPgkGchYPFMWOOk0wzw==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==}
|
||||
@@ -1613,8 +1613,8 @@ packages:
|
||||
resolution: {integrity: sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==}
|
||||
engines: {node: 20 || >=22}
|
||||
|
||||
[email protected]0.0:
|
||||
resolution: {integrity: sha512-UqxOW0FgAfDyb9VmjKb6/BkOzW6yfhf/QNboKtS2NDsscnNRW2CDzlyhbMKBQ1ontmcxuIDqHJ2AVgFR3NI+GA==}
|
||||
[email protected]1.0:
|
||||
resolution: {integrity: sha512-v2SQPPPNjnr+BlhL9Spuzmo5yR+hLkftfuE4E3jaxdNPgSH0hJKPcLJBP7yAta87jQNPsSERS59PJjCJ/CorBA==}
|
||||
peerDependencies:
|
||||
'@types/react': '*'
|
||||
react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
@@ -1629,8 +1629,8 @@ packages:
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-z12OxmaPGE0F4xlpGdjuAUckipLpQlTAuAmyGhNoD7ODpYUzvDfvtxUbjM/jwznyP178oju/KDdyi220wNJ0RQ==}
|
||||
|
||||
[email protected]4.0:
|
||||
resolution: {integrity: sha512-L7U9y9TyPJ3P/JJbW+eH+A3FzdPq+3fnZFqqeCwf/S6cRGganHwVI5b/C8/zOYzreUzsloDPZVVaMQA94wOt4A==}
|
||||
[email protected]5.0:
|
||||
resolution: {integrity: sha512-kxFetbvUHDOVaAP1CYb226Mdk2QhzIPddqM0xJTFD35wu78Mpd6NM7+H68ALjc6Oa0kAOX5EAdMKyvD2u3qfcg==}
|
||||
engines: {node: '>=16.14.0', npm: '>=8.1.0'}
|
||||
|
||||
[email protected]:
|
||||
@@ -2008,8 +2008,8 @@ packages:
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-kI5ffTiZWmJaS/huM8wZfEMer1eRd7oJQhDuxeCLe3t7N7mX3z94CN0xPxBQxFYQTSNz9T0i+v6inKqSdK8xrQ==}
|
||||
|
||||
[email protected]2:
|
||||
resolution: {integrity: sha512-8wafseiaG80xmXSfqidUNqZcylTlhmPZZt+za2m+js2sFZ8dTNlhIOV2WcbIPx2hgwPBJpEUGFAMZ9bgBBLTSQ==}
|
||||
[email protected]5:
|
||||
resolution: {integrity: sha512-6odND+fel5zVw3I4Yc2e69gvguDnX0CAAGiNDZSq4ik5WUcaLrkD2GLuog0XcODbor31hZ5ayEMgVDno+KPV5g==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
hasBin: true
|
||||
|
||||
@@ -2139,8 +2139,8 @@ packages:
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==}
|
||||
|
||||
[email protected].1:
|
||||
resolution: {integrity: sha512-usz3OVQBeh1LxNn0DzU0voLXMgn5CU/WUdpBsY6Ti5afNao+vTkmn4H/NxoXolc10cOqe2+YIG0Hspq2rsl64w==}
|
||||
[email protected].3:
|
||||
resolution: {integrity: sha512-+fdndUTwBP86cVjihTp21hZHo7d6oj1c/47XPA3lL8mM0XHs+C4EkcjmgRsFnFk2f/UONGDy9ItBItmXsLOQHw==}
|
||||
engines: {node: '>=22.19.0'}
|
||||
|
||||
[email protected]:
|
||||
@@ -2211,13 +2211,13 @@ packages:
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==}
|
||||
|
||||
[email protected].4:
|
||||
resolution: {integrity: sha512-IftLE8phJhdNRIGNEV4JCCvfNScoZQVfcXaN8TQropWGJtOHcu+iAB9pHcuGvmEb/kddw0LBe/jS8ztdC6xdiQ==}
|
||||
[email protected].5:
|
||||
resolution: {integrity: sha512-z1YpjieJ9g0ebTmQpLBv/oq9E3F9EIlOe+7LahW8SrE3LTiG3udBRUXQOlYXP9b+IC2q2phHeVxj9xGlw34OEQ==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
hasBin: true
|
||||
peerDependencies:
|
||||
'@types/node': ^20.19.0 || >=22.12.0
|
||||
'@vitejs/devtools': ^0.7.1
|
||||
'@vitejs/devtools': ^0.7.1 || ^0.8.0
|
||||
esbuild: ^0.27.0 || ^0.28.0
|
||||
jiti: '>=1.21.0'
|
||||
less: ^4.0.0
|
||||
@@ -2254,20 +2254,20 @@ packages:
|
||||
yaml:
|
||||
optional: true
|
||||
|
||||
[email protected].4:
|
||||
resolution: {integrity: sha512-z0WxZRV2Sg48wF0FSgMq3TFG8JS38/Qr96vZVZdX6+Em9raQTvnXQvdTFJe2m3rfyJnEHeHQ/snceIdi8D1pVg==}
|
||||
[email protected].5:
|
||||
resolution: {integrity: sha512-cPODOMF8YnkiiYtDdHo6n4wWmBrOBBbOIYH2d33M4l3CdNn72h+qMmO6uXffUN6A5AZFXNND/d+krS9mgkXRfg==}
|
||||
engines: {node: ^22.12.0 || ^24.0.0 || >=26.0.0}
|
||||
hasBin: true
|
||||
peerDependencies:
|
||||
'@edge-runtime/vm': '*'
|
||||
'@opentelemetry/api': ^1.9.0
|
||||
'@types/node': ^22.0.0 || >=24.0.0
|
||||
'@vitest/browser-playwright': 5.0.4
|
||||
'@vitest/browser-preview': 5.0.4
|
||||
'@vitest/browser-playwright': 5.0.5
|
||||
'@vitest/browser-preview': 5.0.5
|
||||
'@vitest/browser-webdriverio': ^5.0.0-beta.5 || >=5.0.0
|
||||
'@vitest/coverage-istanbul': 5.0.4
|
||||
'@vitest/coverage-v8': 5.0.4
|
||||
'@vitest/ui': 5.0.4
|
||||
'@vitest/coverage-istanbul': 5.0.5
|
||||
'@vitest/coverage-v8': 5.0.5
|
||||
'@vitest/ui': 5.0.5
|
||||
happy-dom: '*'
|
||||
jsdom: '*'
|
||||
vite: ^6.4.0 || ^7.0.0 || ^8.0.0
|
||||
@@ -2783,18 +2783,18 @@ snapshots:
|
||||
dependencies:
|
||||
'@lit-labs/ssr-dom-shim': 1.6.0
|
||||
|
||||
'@mantine/[email protected].2(@mantine/[email protected].2(@mantine/[email protected].2([email protected]))(@types/[email protected])([email protected]([email protected]))([email protected]))(@mantine/[email protected].2([email protected]))([email protected]([email protected]))([email protected])([email protected](@types/[email protected])([email protected]([email protected]))([email protected])([email protected])([email protected]))':
|
||||
'@mantine/[email protected].3(@mantine/[email protected].3(@mantine/[email protected].3([email protected]))(@types/[email protected])([email protected]([email protected]))([email protected]))(@mantine/[email protected].3([email protected]))([email protected]([email protected]))([email protected])([email protected](@types/[email protected])([email protected]([email protected]))([email protected])([email protected])([email protected]))':
|
||||
dependencies:
|
||||
'@mantine/core': 9.7.2(@mantine/[email protected].2([email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])
|
||||
'@mantine/hooks': 9.7.2([email protected])
|
||||
'@mantine/core': 9.7.3(@mantine/[email protected].3([email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])
|
||||
'@mantine/hooks': 9.7.3([email protected])
|
||||
react: 19.3.1
|
||||
react-dom: 19.3.1([email protected])
|
||||
recharts: 3.10.1(@types/[email protected])([email protected]([email protected]))([email protected])([email protected])([email protected])
|
||||
|
||||
'@mantine/[email protected].2(@mantine/[email protected].2([email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])':
|
||||
'@mantine/[email protected].3(@mantine/[email protected].3([email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])':
|
||||
dependencies:
|
||||
'@floating-ui/react': 0.27.20([email protected]([email protected]))([email protected])
|
||||
'@mantine/hooks': 9.7.2([email protected])
|
||||
'@mantine/hooks': 9.7.3([email protected])
|
||||
clsx: 2.1.1
|
||||
react: 19.3.1
|
||||
react-dom: 19.3.1([email protected])
|
||||
@@ -2804,7 +2804,7 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- '@types/react'
|
||||
|
||||
'@mantine/[email protected].2([email protected])':
|
||||
'@mantine/[email protected].3([email protected])':
|
||||
dependencies:
|
||||
react: 19.3.1
|
||||
|
||||
@@ -2824,7 +2824,7 @@ snapshots:
|
||||
'@types/geojson': 7946.0.16
|
||||
pbf: 5.1.2
|
||||
|
||||
'@maplibre/geojson-vt@6.1.2':
|
||||
'@maplibre/geojson-vt@6.3.0':
|
||||
dependencies:
|
||||
kdbush: 4.1.0
|
||||
|
||||
@@ -2912,7 +2912,7 @@ snapshots:
|
||||
|
||||
'@noble/[email protected]': {}
|
||||
|
||||
'@oxc-project/[email protected]2.0': {}
|
||||
'@oxc-project/[email protected]5.0': {}
|
||||
|
||||
'@preact/[email protected]': {}
|
||||
|
||||
@@ -2928,49 +2928,49 @@ snapshots:
|
||||
react: 19.3.1
|
||||
react-redux: 9.3.0(@types/[email protected])([email protected])([email protected])
|
||||
|
||||
'@rolldown/[email protected]2':
|
||||
'@rolldown/[email protected]5':
|
||||
optional: true
|
||||
|
||||
'@rolldown/[email protected]2':
|
||||
'@rolldown/[email protected]5':
|
||||
optional: true
|
||||
|
||||
'@rolldown/[email protected]2':
|
||||
'@rolldown/[email protected]5':
|
||||
optional: true
|
||||
|
||||
'@rolldown/[email protected]2':
|
||||
'@rolldown/[email protected]5':
|
||||
optional: true
|
||||
|
||||
'@rolldown/[email protected]2':
|
||||
'@rolldown/[email protected]5':
|
||||
optional: true
|
||||
|
||||
'@rolldown/[email protected]2':
|
||||
'@rolldown/[email protected]5':
|
||||
optional: true
|
||||
|
||||
'@rolldown/[email protected]2':
|
||||
'@rolldown/[email protected]5':
|
||||
optional: true
|
||||
|
||||
'@rolldown/[email protected]2':
|
||||
'@rolldown/[email protected]5':
|
||||
optional: true
|
||||
|
||||
'@rolldown/[email protected]2':
|
||||
'@rolldown/[email protected]5':
|
||||
optional: true
|
||||
|
||||
'@rolldown/[email protected]2':
|
||||
'@rolldown/[email protected]5':
|
||||
optional: true
|
||||
|
||||
'@rolldown/[email protected]2':
|
||||
'@rolldown/[email protected]5':
|
||||
optional: true
|
||||
|
||||
'@rolldown/[email protected]2':
|
||||
'@rolldown/[email protected]5':
|
||||
optional: true
|
||||
|
||||
'@rolldown/[email protected]2':
|
||||
'@rolldown/[email protected]5':
|
||||
optional: true
|
||||
|
||||
'@rolldown/[email protected]2':
|
||||
'@rolldown/[email protected]5':
|
||||
optional: true
|
||||
|
||||
'@rolldown/[email protected]2':
|
||||
'@rolldown/[email protected]5':
|
||||
optional: true
|
||||
|
||||
'@rolldown/[email protected]': {}
|
||||
@@ -3152,29 +3152,29 @@ snapshots:
|
||||
react: 19.3.1
|
||||
react-dom: 19.3.1([email protected])
|
||||
|
||||
'@vis.gl/[email protected]([email protected]4.0)([email protected]([email protected]))([email protected])':
|
||||
'@vis.gl/[email protected]([email protected]5.0)([email protected]([email protected]))([email protected])':
|
||||
dependencies:
|
||||
'@maplibre/maplibre-gl-style-spec': 19.3.3
|
||||
react: 19.3.1
|
||||
react-dom: 19.3.1([email protected])
|
||||
optionalDependencies:
|
||||
maplibre-gl: 6.14.0
|
||||
maplibre-gl: 6.15.0
|
||||
|
||||
'@vitejs/[email protected]([email protected].4(@types/[email protected])([email protected]))':
|
||||
'@vitejs/[email protected]([email protected].5(@types/[email protected])([email protected]))':
|
||||
dependencies:
|
||||
'@rolldown/pluginutils': 1.0.1
|
||||
vite: 8.3.4(@types/[email protected])([email protected])
|
||||
vite: 8.3.5(@types/[email protected])([email protected])
|
||||
|
||||
'@vitest/[email protected].4([email protected].4(@types/[email protected])([email protected]))':
|
||||
'@vitest/[email protected].5([email protected].5(@types/[email protected])([email protected]))':
|
||||
dependencies:
|
||||
'@jridgewell/trace-mapping': 0.3.31
|
||||
'@vitest/spy': 5.0.4
|
||||
'@vitest/spy': 5.0.5
|
||||
estree-walker: 3.0.3
|
||||
magic-string: 1.4.3
|
||||
optionalDependencies:
|
||||
vite: 8.3.4(@types/[email protected])([email protected])
|
||||
vite: 8.3.5(@types/[email protected])([email protected])
|
||||
|
||||
'@vitest/[email protected].4': {}
|
||||
'@vitest/[email protected].5': {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
@@ -3578,7 +3578,7 @@ snapshots:
|
||||
parse5: 8.0.1
|
||||
saxes: 6.0.0
|
||||
tough-cookie: 6.0.2
|
||||
undici: 8.12.1
|
||||
undici: 8.12.3
|
||||
w3c-xmlserializer: 6.0.0
|
||||
webidl-conversions: 8.0.1
|
||||
whatwg-mimetype: 5.0.0
|
||||
@@ -3682,7 +3682,7 @@ snapshots:
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]0.0(@types/[email protected])([email protected]):
|
||||
[email protected]1.0(@types/[email protected])([email protected]):
|
||||
dependencies:
|
||||
react: 19.3.1
|
||||
optionalDependencies:
|
||||
@@ -3694,13 +3694,13 @@ snapshots:
|
||||
dependencies:
|
||||
'@jridgewell/sourcemap-codec': 1.6.0
|
||||
|
||||
[email protected]4.0:
|
||||
[email protected]5.0:
|
||||
dependencies:
|
||||
'@mapbox/point-geometry': 1.1.0
|
||||
'@mapbox/tiny-sdf': 2.3.0
|
||||
'@mapbox/unitbezier': 1.0.0
|
||||
'@mapbox/vector-tile': 3.0.0
|
||||
'@maplibre/geojson-vt': 6.1.2
|
||||
'@maplibre/geojson-vt': 6.3.0
|
||||
'@maplibre/maplibre-gl-style-spec': 26.4.4
|
||||
'@maplibre/mlt': 1.3.0
|
||||
'@maplibre/vt-pbf': 4.3.2
|
||||
@@ -4175,14 +4175,14 @@ snapshots:
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]([email protected]4.0)([email protected]([email protected]))([email protected]):
|
||||
[email protected]([email protected]5.0)([email protected]([email protected]))([email protected]):
|
||||
dependencies:
|
||||
'@vis.gl/react-mapbox': 8.1.3([email protected]([email protected]))([email protected])
|
||||
'@vis.gl/react-maplibre': 8.1.3([email protected]4.0)([email protected]([email protected]))([email protected])
|
||||
'@vis.gl/react-maplibre': 8.1.3([email protected]5.0)([email protected]([email protected]))([email protected])
|
||||
react: 19.3.1
|
||||
react-dom: 19.3.1([email protected])
|
||||
optionalDependencies:
|
||||
maplibre-gl: 6.14.0
|
||||
maplibre-gl: 6.15.0
|
||||
|
||||
[email protected](@types/[email protected])([email protected]):
|
||||
dependencies:
|
||||
@@ -4365,26 +4365,26 @@ snapshots:
|
||||
dependencies:
|
||||
protocol-buffers-schema: 3.6.1
|
||||
|
||||
[email protected]2:
|
||||
[email protected]5:
|
||||
dependencies:
|
||||
'@oxc-project/types': 0.152.0
|
||||
'@oxc-project/types': 0.155.0
|
||||
'@rolldown/pluginutils': 1.0.1
|
||||
optionalDependencies:
|
||||
'@rolldown/binding-android-arm-eabi': 1.2.12
|
||||
'@rolldown/binding-android-arm64': 1.2.12
|
||||
'@rolldown/binding-darwin-arm64': 1.2.12
|
||||
'@rolldown/binding-darwin-x64': 1.2.12
|
||||
'@rolldown/binding-freebsd-x64': 1.2.12
|
||||
'@rolldown/binding-linux-arm-gnueabihf': 1.2.12
|
||||
'@rolldown/binding-linux-arm64-gnu': 1.2.12
|
||||
'@rolldown/binding-linux-arm64-musl': 1.2.12
|
||||
'@rolldown/binding-linux-ppc64-gnu': 1.2.12
|
||||
'@rolldown/binding-linux-s390x-gnu': 1.2.12
|
||||
'@rolldown/binding-linux-x64-gnu': 1.2.12
|
||||
'@rolldown/binding-linux-x64-musl': 1.2.12
|
||||
'@rolldown/binding-openharmony-arm64': 1.2.12
|
||||
'@rolldown/binding-win32-arm64-msvc': 1.2.12
|
||||
'@rolldown/binding-win32-x64-msvc': 1.2.12
|
||||
'@rolldown/binding-android-arm-eabi': 1.2.15
|
||||
'@rolldown/binding-android-arm64': 1.2.15
|
||||
'@rolldown/binding-darwin-arm64': 1.2.15
|
||||
'@rolldown/binding-darwin-x64': 1.2.15
|
||||
'@rolldown/binding-freebsd-x64': 1.2.15
|
||||
'@rolldown/binding-linux-arm-gnueabihf': 1.2.15
|
||||
'@rolldown/binding-linux-arm64-gnu': 1.2.15
|
||||
'@rolldown/binding-linux-arm64-musl': 1.2.15
|
||||
'@rolldown/binding-linux-ppc64-gnu': 1.2.15
|
||||
'@rolldown/binding-linux-s390x-gnu': 1.2.15
|
||||
'@rolldown/binding-linux-x64-gnu': 1.2.15
|
||||
'@rolldown/binding-linux-x64-musl': 1.2.15
|
||||
'@rolldown/binding-openharmony-arm64': 1.2.15
|
||||
'@rolldown/binding-win32-arm64-msvc': 1.2.15
|
||||
'@rolldown/binding-win32-x64-msvc': 1.2.15
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
@@ -4515,7 +4515,7 @@ snapshots:
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected].1: {}
|
||||
[email protected].3: {}
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
@@ -4620,22 +4620,22 @@ snapshots:
|
||||
d3-time: 3.1.0
|
||||
d3-timer: 3.0.1
|
||||
|
||||
[email protected].4(@types/[email protected])([email protected]):
|
||||
[email protected].5(@types/[email protected])([email protected]):
|
||||
dependencies:
|
||||
lightningcss: 1.33.0
|
||||
picomatch: 4.0.7
|
||||
postcss: 8.5.30
|
||||
rolldown: 1.2.12
|
||||
rolldown: 1.2.15
|
||||
tinyglobby: 0.2.17
|
||||
optionalDependencies:
|
||||
'@types/node': 26.6.5
|
||||
esbuild: 0.28.2
|
||||
fsevents: 2.3.3
|
||||
|
||||
[email protected].4(@types/[email protected])([email protected](@noble/[email protected]))([email protected].4(@types/[email protected])([email protected])):
|
||||
[email protected].5(@types/[email protected])([email protected](@noble/[email protected]))([email protected].5(@types/[email protected])([email protected])):
|
||||
dependencies:
|
||||
'@types/chai': 5.2.3
|
||||
'@vitest/mocker': 5.0.4([email protected].4(@types/[email protected])([email protected]))
|
||||
'@vitest/mocker': 5.0.5([email protected].5(@types/[email protected])([email protected]))
|
||||
chai: 6.3.0
|
||||
es-module-lexer: 2.3.2
|
||||
expect-type: 1.4.0
|
||||
@@ -4646,7 +4646,7 @@ snapshots:
|
||||
tinybench: 6.2.2
|
||||
tinyexec: 1.3.1
|
||||
tinyglobby: 0.2.17
|
||||
vite: 8.3.4(@types/[email protected])([email protected])
|
||||
vite: 8.3.5(@types/[email protected])([email protected])
|
||||
why-is-node-running: 3.2.1
|
||||
optionalDependencies:
|
||||
'@types/node': 26.6.5
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { MARKET_ACTIONS } from "./actions_market";
|
||||
import { SCHEDULE_ACTIONS } from "./actions_schedule";
|
||||
import { DOCUMENT_ACTIONS } from "./actions_document";
|
||||
import { FEEDBACK_ACTIONS } from "./actions_feedback";
|
||||
|
||||
// The census oracle is independent of production literals so disagreements fail.
|
||||
// An id names the intent; data-target and data-value name its entity and choice.
|
||||
@@ -98,15 +99,22 @@ export const ACTIONS: UIAction[] = [
|
||||
{ id: "chrome.open-project", kind: "shell-native", target: "none", proof: "interaction" },
|
||||
{ id: "appearance.theme", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "appearance.nav-rail", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "appearance.nav-entry-visible", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "appearance.nav-entry-move", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "appearance.nav-entries-reset", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "appearance.live-grace", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "appearance.legacy-sessions", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "appearance.open-session-limit", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "appearance.live-stream-budget", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "appearance.session-quick-actions", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "appearance.new-answer-marker", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "appearance.plan-progress", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "appearance.composer-hint", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "appearance.graphics", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "window.global-shortcut", kind: "shell-native", target: "none", proof: "browser" },
|
||||
{ id: "appearance.session-tabs", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "appearance.esc-stop", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "appearance.page-sleep", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "workspace.configure", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "workspace.prepare-cancel", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
|
||||
{ id: "workspace.prepare-details", kind: "view", target: "none", proof: "interaction" },
|
||||
@@ -118,13 +126,16 @@ export const ACTIONS: UIAction[] = [
|
||||
{ id: "workspace.worktree-toggle", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "workspace.search", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "session.filter", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "session.recent", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "session.recent-environment", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "session.recent-page", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "session.recent-retry", kind: "repeatable", target: "none", proof: "interaction" },
|
||||
{ id: "session.more", kind: "repeatable", target: "none", proof: "interaction" },
|
||||
{ id: "session.selection", kind: "view", target: "optional", proof: "interaction" },
|
||||
{ id: "session.batch-archive", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
|
||||
{ id: "session.batch-delete", kind: "destructive", target: "none", proof: "authority-effect" },
|
||||
{ id: "chrome.settings", kind: "navigation", target: "none", proof: "interaction" },
|
||||
{ id: "browser.open", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "browser.wide", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "browser.full", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "browser.back", kind: "navigation", target: "none", proof: "interaction" },
|
||||
{ id: "browser.reload", kind: "repeatable", target: "none", proof: "interaction" },
|
||||
@@ -143,9 +154,11 @@ export const ACTIONS: UIAction[] = [
|
||||
{ id: "chrome.receipt-line", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "chrome.keep-awake", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "composer.send-shortcut", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "composer.running-message-intent", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "transcript.fold", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "transcript.fold-preset", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "transcript.fold-preview", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "transcript.thinking-preview", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "chrome.account", kind: "navigation", target: "none", proof: "interaction" },
|
||||
{ id: "metrics.details", kind: "view", target: "optional", proof: "interaction" },
|
||||
{ id: "metrics.hide-amounts", kind: "view", target: "none", proof: "interaction" },
|
||||
@@ -280,14 +293,7 @@ export const ACTIONS: UIAction[] = [
|
||||
{ id: "community.more", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "sponsor.paypal", kind: "shell-native", target: "none", proof: "interaction" },
|
||||
{ id: "sponsor.qr", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "feedback.open", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "feedback.tab", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "feedback.category", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "feedback.body", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "feedback.name", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "feedback.contact", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "feedback.shot.pick", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "feedback.shot.capture", kind: "shell-native", target: "none", proof: "interaction" },
|
||||
...FEEDBACK_ACTIONS,
|
||||
{ id: "side.collapse", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "side.toggle", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "side.quote", kind: "view", target: "none", proof: "interaction" },
|
||||
@@ -298,43 +304,10 @@ export const ACTIONS: UIAction[] = [
|
||||
{ id: "toast.report", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "errbar.report", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "toast.dismiss", kind: "view", target: "entity", proof: "interaction" },
|
||||
{ id: "toast.act", kind: "kernel-mutation", target: "entity", proof: "interaction" },
|
||||
{ id: "model.group", kind: "view", target: "entity", proof: "interaction" },
|
||||
{ id: "model.group-clear", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "feedback.shot.add", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "feedback.shot.remove", kind: "view", target: "entity", proof: "interaction" },
|
||||
{ id: "feedback.send", kind: "kernel-mutation", target: "none", proof: "interaction" },
|
||||
{ id: "feedback.another", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "feedback.thread.more", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "feedback.thread.toggle", kind: "kernel-mutation", target: "entity", proof: "interaction" },
|
||||
{ id: "feedback.reply.open", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "feedback.reply.body", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "feedback.reply.send", kind: "kernel-mutation", target: "none", proof: "interaction" },
|
||||
{ id: "feedback.reply.cancel", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "feedback.mine", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "feedback.logs", kind: "shell-native", target: "none", proof: "interaction" },
|
||||
{ id: "feedback.refresh", kind: "repeatable", target: "none", proof: "interaction" },
|
||||
{ id: "feedback.filter", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "feedback.account-link", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "feedback.account-link-confirm", kind: "kernel-mutation", target: "none", proof: "interaction" },
|
||||
{ id: "feedback.account-link-cancel", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "feedback.board-join-choice", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "feedback.board-offer-dismiss", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "feedback.account-unlink", kind: "kernel-mutation", target: "none", proof: "interaction" },
|
||||
{ id: "feedback.account-unlink-other", kind: "kernel-mutation", target: "entity", proof: "interaction" },
|
||||
{ id: "feedback.board-join", kind: "kernel-mutation", target: "none", proof: "interaction" },
|
||||
{ id: "feedback.board-leave", kind: "kernel-mutation", target: "none", proof: "interaction" },
|
||||
{ id: "feedback.board-release", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "feedback.board-name", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "feedback.retry-env", kind: "repeatable", target: "none", proof: "interaction" },
|
||||
{ id: "feedback.retry-mine", kind: "repeatable", target: "none", proof: "interaction" },
|
||||
{ id: "feedback.withdraw-open", kind: "view", target: "entity", proof: "interaction" },
|
||||
{ id: "feedback.withdraw-cancel", kind: "view", target: "entity", proof: "interaction" },
|
||||
{ id: "feedback.withdraw-confirm", kind: "kernel-mutation", target: "entity", proof: "interaction" },
|
||||
{ id: "paste.save", kind: "kernel-mutation", target: "none", proof: "interaction" },
|
||||
{ id: "feedback.link", kind: "shell-native", target: "none", proof: "interaction" },
|
||||
{ id: "feedback.focus", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "feedback.expand", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "feedback.close", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "backup.category", kind: "view", target: "entity", proof: "interaction" },
|
||||
{ id: "backup.label", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "backup.passphrase", kind: "view", target: "optional", proof: "interaction" },
|
||||
@@ -397,6 +370,7 @@ export const ACTIONS: UIAction[] = [
|
||||
// the row's own action, so this one never writes anything itself.
|
||||
{ id: "tree.navigate", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "settings.close", kind: "navigation", target: "none", proof: "interaction" },
|
||||
{ id: "settings.expand", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "pane.activate", kind: "navigation", target: "none", proof: "interaction" },
|
||||
// How this session is being read. One id across a bar button and a menu row,
|
||||
// because reaching a view from the bar and from the detail menu is one intent
|
||||
@@ -486,6 +460,7 @@ export const ACTIONS: UIAction[] = [
|
||||
{ id: "provider.preset", kind: "view", target: "entity", proof: "interaction" },
|
||||
{ id: "provider.preset-retry", kind: "repeatable", target: "none", proof: "interaction" },
|
||||
{ id: "provider.preset-toggle", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "provider.preset-vendor", kind: "view", target: "entity", proof: "interaction" },
|
||||
{ id: "provider.preset-back", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "provider.preset-cancel", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "provider.add-start", kind: "view", target: "none", proof: "interaction" },
|
||||
@@ -535,12 +510,14 @@ export const ACTIONS: UIAction[] = [
|
||||
{ id: "versions.reload", kind: "repeatable", target: "none", proof: "interaction" },
|
||||
{ id: "versions.pin", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
|
||||
{ id: "versions.activate", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
|
||||
{ id: "versions.cancel", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
|
||||
{ id: "versions.restart", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
|
||||
{ id: "versions.later", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "versions.more", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "versions.notes", kind: "repeatable", target: "none", proof: "interaction" },
|
||||
{ id: "versions.notes-retry", kind: "repeatable", target: "none", proof: "interaction" },
|
||||
{ id: "versions.notes-close", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "versions.full-installer", kind: "repeatable", target: "none", proof: "interaction" },
|
||||
{ id: "versions.notes-lang", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "shell.executor", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
|
||||
{ id: "shell.custom-path", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
|
||||
@@ -570,12 +547,17 @@ export const ACTIONS: UIAction[] = [
|
||||
{ id: "workspace.editor", kind: "repeatable", target: "none", proof: "authority-effect" },
|
||||
{ id: "wallpaper.remove", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
|
||||
{ id: "wallpaper.change", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
|
||||
{ id: "wallpaper.scene", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
|
||||
{ id: "wallpaper.sound", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
|
||||
{ id: "wallpaper.volume", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
|
||||
{ id: "wallpaper.parallax", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "theme.activate", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
|
||||
{ id: "theme.import", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
|
||||
{ id: "theme.folder", kind: "repeatable", target: "none", proof: "interaction" },
|
||||
{ id: "theme.remove", kind: "destructive", target: "entity", proof: "authority-effect" },
|
||||
|
||||
// Usage changes what report is read, not canonical session state.
|
||||
{ id: "usage.range.today", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "usage.range.start", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "usage.range.end", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "usage.range.month", kind: "view", target: "none", proof: "interaction" },
|
||||
@@ -587,11 +569,7 @@ export const ACTIONS: UIAction[] = [
|
||||
// different capability from changing its opacity, so the four sliders share
|
||||
// one id and say which parameter on data-value.
|
||||
//
|
||||
// These stay apart on purpose. theme.activate installs a palette pack;
|
||||
// appearance.scheme is light, dark or follow-the-system. wallpaper.change
|
||||
// and wallpaper.remove decide which image there is; appearance.background is
|
||||
// how the image that is already there is drawn. And weight and contrast are
|
||||
// two different things a reader wants, however alike they look in CSS.
|
||||
// A pack supplies colours; the scheme decides when its light or dark set is used.
|
||||
{ id: "appearance.language", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
|
||||
{ id: "appearance.zoom", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
|
||||
{ id: "web-access.backend", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
|
||||
@@ -608,16 +586,21 @@ export const ACTIONS: UIAction[] = [
|
||||
{ id: "appearance.reading-size", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
|
||||
{ id: "appearance.text-size", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
|
||||
{ id: "appearance.measure", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
|
||||
// Dragging the column's edge is a view adjustment held on this machine,
|
||||
// not a setting written to the kernel: it changes what is drawn, not what
|
||||
// the transcript says.
|
||||
{ id: "appearance.background", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
|
||||
{ id: "appearance.font", kind: "kernel-mutation", target: "entity", proof: "authority-effect" },
|
||||
// Kept where the window keeps them: these are this browser's, and no kernel
|
||||
// state moves.
|
||||
{ id: "appearance.scheme", kind: "view", target: "none", proof: "browser" },
|
||||
{ id: "appearance.schedule", kind: "view", target: "entity", proof: "browser" },
|
||||
{ id: "appearance.weight", kind: "view", target: "none", proof: "browser" },
|
||||
{ id: "appearance.contrast", kind: "view", target: "none", proof: "browser" },
|
||||
{ id: "appearance.effects", kind: "view", target: "none", proof: "browser" },
|
||||
{ id: "pane.close", kind: "destructive", target: "none", proof: "authority-effect" },
|
||||
{ id: "settings.section", kind: "navigation", target: "none", proof: "interaction" },
|
||||
{ id: "settings.page", kind: "navigation", target: "none", proof: "interaction" },
|
||||
// Finding a setting changes what is on screen and nothing else: it reaches
|
||||
// no kernel state, and the jump it offers is settings.section, because
|
||||
// arriving from a search is the same arrival as arriving from the nav.
|
||||
@@ -648,6 +631,9 @@ export const ACTIONS: UIAction[] = [
|
||||
{ id: "workbench.copy-path", kind: "shell-native", target: "entity", proof: "interaction" },
|
||||
{ id: "workbench.add-to-chat", kind: "view", target: "entity", proof: "interaction" },
|
||||
{ id: "workbench.quote-selection", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "workbench.text-undo", kind: "view", target: "none", proof: "interaction" }, { id: "workbench.text-redo", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "workbench.text-cut", kind: "view", target: "none", proof: "interaction" }, { id: "workbench.text-copy", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "workbench.text-paste", kind: "view", target: "none", proof: "interaction" }, { id: "workbench.text-select-all", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "workbench.copy-ref", kind: "shell-native", target: "entity", proof: "interaction" },
|
||||
{ id: "workbench.menu", kind: "view", target: "entity", proof: "interaction" },
|
||||
{ id: "palette.pick", kind: "view", target: "entity", proof: "interaction" },
|
||||
@@ -664,6 +650,7 @@ export const ACTIONS: UIAction[] = [
|
||||
{ id: "deck.agents", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "deck.jobs", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "plan.fold", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "plan.collapse", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "workbench.file", kind: "view", target: "entity", proof: "interaction" },
|
||||
{ id: "tool-approval.mode", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
|
||||
// The folder-trust question and the no-sandbox note beside the composer.
|
||||
@@ -685,10 +672,12 @@ export const ACTIONS: UIAction[] = [
|
||||
{ id: "sandbox.add-write-root", kind: "kernel-mutation", target: "optional", proof: "authority-effect" },
|
||||
{ id: "sandbox.remove-write-root", kind: "destructive", target: "entity", proof: "authority-effect" },
|
||||
{ id: "browser-tools.enabled", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
|
||||
{ id: "present-files.enabled", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
|
||||
{ id: "size.set", kind: "kernel-mutation", target: "none", proof: "interaction" },
|
||||
{ id: "size.keep", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "size.restore", kind: "kernel-mutation", target: "none", proof: "interaction" },
|
||||
{ id: "tree.retry", kind: "kernel-mutation", target: "none", proof: "interaction" },
|
||||
{ id: "history.more", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "update.mode", kind: "kernel-mutation", target: "none", proof: "interaction" },
|
||||
{ id: "update.restart", kind: "kernel-mutation", target: "none", proof: "interaction" },
|
||||
{ id: "update.later", kind: "view", target: "none", proof: "interaction" },
|
||||
@@ -772,6 +761,8 @@ export const ACTIONS: UIAction[] = [
|
||||
|
||||
// ── The two side panels. Reached from the keyboard here, and from each
|
||||
// gutter's own grip, which this pass has not annotated yet.
|
||||
{ id: "rail.star", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "rail.pick", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "rail.toggle", kind: "view", target: "none", proof: "interaction" },
|
||||
...SCHEDULE_ACTIONS,
|
||||
...DOCUMENT_ACTIONS,
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import type { UIAction } from "./actions";
|
||||
|
||||
export const FEEDBACK_ACTIONS: UIAction[] = [
|
||||
{ id: "feedback.open", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "feedback.tab", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "feedback.category", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "feedback.body", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "feedback.preview", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "feedback.name", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "feedback.contact", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "feedback.shot.pick", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "feedback.shot.capture", kind: "shell-native", target: "none", proof: "interaction" },
|
||||
{ id: "feedback.shot.add", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "feedback.shot.remove", kind: "view", target: "entity", proof: "interaction" },
|
||||
{ id: "feedback.send", kind: "kernel-mutation", target: "none", proof: "interaction" },
|
||||
{ id: "feedback.another", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "feedback.thread.more", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "feedback.thread.toggle", kind: "kernel-mutation", target: "entity", proof: "interaction" },
|
||||
{ id: "feedback.reply.open", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "feedback.reply.body", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "feedback.reply.send", kind: "kernel-mutation", target: "none", proof: "interaction" },
|
||||
{ id: "feedback.reply.cancel", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "feedback.mine", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "feedback.logs", kind: "shell-native", target: "none", proof: "interaction" },
|
||||
{ id: "feedback.refresh", kind: "repeatable", target: "none", proof: "interaction" },
|
||||
{ id: "feedback.account-section", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "feedback.filter", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "feedback.account-link", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "feedback.account-link-confirm", kind: "kernel-mutation", target: "none", proof: "interaction" },
|
||||
{ id: "feedback.account-link-cancel", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "feedback.board-join-choice", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "feedback.board-offer-dismiss", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "feedback.account-unlink", kind: "kernel-mutation", target: "none", proof: "interaction" },
|
||||
{ id: "feedback.account-unlink-other", kind: "kernel-mutation", target: "entity", proof: "interaction" },
|
||||
{ id: "feedback.board-join", kind: "kernel-mutation", target: "none", proof: "interaction" },
|
||||
{ id: "feedback.board-leave", kind: "kernel-mutation", target: "none", proof: "interaction" },
|
||||
{ id: "feedback.board-release", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "feedback.board-name", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "feedback.retry-env", kind: "repeatable", target: "none", proof: "interaction" },
|
||||
{ id: "feedback.retry-mine", kind: "repeatable", target: "none", proof: "interaction" },
|
||||
{ id: "feedback.withdraw-open", kind: "view", target: "entity", proof: "interaction" },
|
||||
{ id: "feedback.withdraw-cancel", kind: "view", target: "entity", proof: "interaction" },
|
||||
{ id: "feedback.withdraw-confirm", kind: "kernel-mutation", target: "entity", proof: "interaction" },
|
||||
{ id: "feedback.link", kind: "shell-native", target: "none", proof: "interaction" },
|
||||
{ id: "feedback.focus", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "feedback.expand", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "feedback.close", kind: "view", target: "none", proof: "interaction" },
|
||||
];
|
||||
@@ -0,0 +1,29 @@
|
||||
# Vendor marks
|
||||
|
||||
These SVG files come from the MIT-licensed lobe-icons collection (npm package `@lobehub/icons-static-svg`, version 1.101.0, https://github.com/lobehub/lobe-icons). The licence of that collection is reproduced below.
|
||||
|
||||
The marks themselves remain trademarks of their respective vendors. Reasonix shows a mark only to identify the vendor of a service or model, which implies no affiliation or endorsement.
|
||||
|
||||
---
|
||||
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2023 LobeHub
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1 @@
|
||||
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Anthropic</title><path d="M13.827 3.52h3.603L24 20h-3.603l-6.57-16.48zm-7.258 0h3.767L16.906 20h-3.674l-1.343-3.461H5.017l-1.344 3.46H0L6.57 3.522zm4.132 9.959L8.453 7.687 6.205 13.48H10.7z"></path></svg>
|
||||
|
After Width: | Height: | Size: 368 B |
@@ -0,0 +1 @@
|
||||
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Azure</title><path d="M7.242 1.613A1.11 1.11 0 018.295.857h6.977L8.03 22.316a1.11 1.11 0 01-1.052.755h-5.43a1.11 1.11 0 01-1.053-1.466L7.242 1.613z" fill="url(#lobe-icons-azure-0-_R_0_)"></path><path d="M18.397 15.296H7.4a.51.51 0 00-.347.882l7.066 6.595c.206.192.477.298.758.298h6.226l-2.706-7.775z" fill="#0078D4"></path><path d="M15.272.857H7.497L0 23.071h7.775l1.596-4.73 5.068 4.73h6.665l-2.707-7.775h-7.998L15.272.857z" fill="url(#lobe-icons-azure-1-_R_0_)"></path><path d="M17.193 1.613a1.11 1.11 0 00-1.052-.756h-7.81.035c.477 0 .9.304 1.052.756l6.748 19.992a1.11 1.11 0 01-1.052 1.466h-.12 7.895a1.11 1.11 0 001.052-1.466L17.193 1.613z" fill="url(#lobe-icons-azure-2-_R_0_)"></path><defs><linearGradient gradientUnits="userSpaceOnUse" id="lobe-icons-azure-0-_R_0_" x1="8.247" x2="1.002" y1="1.626" y2="23.03"><stop stop-color="#114A8B"></stop><stop offset="1" stop-color="#0669BC"></stop></linearGradient><linearGradient gradientUnits="userSpaceOnUse" id="lobe-icons-azure-1-_R_0_" x1="14.042" x2="12.324" y1="15.302" y2="15.888"><stop stop-opacity=".3"></stop><stop offset=".071" stop-opacity=".2"></stop><stop offset=".321" stop-opacity=".1"></stop><stop offset=".623" stop-opacity=".05"></stop><stop offset="1" stop-opacity="0"></stop></linearGradient><linearGradient gradientUnits="userSpaceOnUse" id="lobe-icons-azure-2-_R_0_" x1="12.841" x2="20.793" y1="1.626" y2="22.814"><stop stop-color="#3CCBF4"></stop><stop offset="1" stop-color="#2892DF"></stop></linearGradient></defs></svg>
|
||||
|
After Width: | Height: | Size: 1.6 KiB |
@@ -0,0 +1 @@
|
||||
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>DeepSeek</title><path d="M23.748 4.482c-.254-.124-.364.113-.512.234-.051.039-.094.09-.137.136-.372.397-.806.657-1.373.626-.829-.046-1.537.214-2.163.848-.133-.782-.575-1.248-1.247-1.548-.352-.156-.708-.311-.955-.65-.172-.241-.219-.51-.305-.774-.055-.16-.11-.323-.293-.35-.2-.031-.278.136-.356.276-.313.572-.434 1.202-.422 1.84.027 1.436.633 2.58 1.838 3.393.137.093.172.187.129.323-.082.28-.18.552-.266.833-.055.179-.137.217-.329.14a5.526 5.526 0 01-1.736-1.18c-.857-.828-1.631-1.742-2.597-2.458a11.365 11.365 0 00-.689-.471c-.985-.957.13-1.743.388-1.836.27-.098.093-.432-.779-.428-.872.004-1.67.295-2.687.684a3.055 3.055 0 01-.465.137 9.597 9.597 0 00-2.883-.102c-1.885.21-3.39 1.102-4.497 2.623C.082 8.606-.231 10.684.152 12.85c.403 2.284 1.569 4.175 3.36 5.653 1.858 1.533 3.997 2.284 6.438 2.14 1.482-.085 3.133-.284 4.994-1.86.47.234.962.327 1.78.397.63.059 1.236-.03 1.705-.128.735-.156.684-.837.419-.961-2.155-1.004-1.682-.595-2.113-.926 1.096-1.296 2.746-2.642 3.392-7.003.05-.347.007-.565 0-.845-.004-.17.035-.237.23-.256a4.173 4.173 0 001.545-.475c1.396-.763 1.96-2.015 2.093-3.517.02-.23-.004-.467-.247-.588zM11.581 18c-2.089-1.642-3.102-2.183-3.52-2.16-.392.024-.321.471-.235.763.09.288.207.486.371.739.114.167.192.416-.113.603-.673.416-1.842-.14-1.897-.167-1.361-.802-2.5-1.86-3.301-3.307-.774-1.393-1.224-2.887-1.298-4.482-.02-.386.093-.522.477-.592a4.696 4.696 0 011.529-.039c2.132.312 3.946 1.265 5.468 2.774.868.86 1.525 1.887 2.202 2.891.72 1.066 1.494 2.082 2.48 2.914.348.292.625.514.891.677-.802.09-2.14.11-3.054-.614zm1-6.44a.306.306 0 01.415-.287.302.302 0 01.2.288.306.306 0 01-.31.307.303.303 0 01-.304-.308zm3.11 1.596c-.2.081-.399.151-.59.16a1.245 1.245 0 01-.798-.254c-.274-.23-.47-.358-.552-.758a1.73 1.73 0 01.016-.588c.07-.327-.008-.537-.239-.727-.187-.156-.426-.199-.688-.199a.559.559 0 01-.254-.078c-.11-.054-.2-.19-.114-.358.028-.054.16-.186.192-.21.356-.202.767-.136 1.146.016.352.144.618.408 1.001.782.391.451.462.576.685.914.176.265.336.537.445.848.067.195-.019.354-.25.452z" fill="#4D6BFE"></path></svg>
|
||||
|
After Width: | Height: | Size: 2.1 KiB |
@@ -0,0 +1 @@
|
||||
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Doubao</title><path d="M5.31 15.756c.172-3.75 1.883-5.999 2.549-6.739-3.26 2.058-5.425 5.658-6.358 8.308v1.12C1.501 21.513 4.226 24 7.59 24a6.59 6.59 0 002.2-.375c.353-.12.7-.248 1.039-.378.913-.899 1.65-1.91 2.243-2.992-4.877 2.431-7.974.072-7.763-4.5l.002.001z" fill="#1E37FC"></path><path d="M22.57 10.283c-1.212-.901-4.109-2.404-7.397-2.8.295 3.792.093 8.766-2.1 12.773a12.782 12.782 0 01-2.244 2.992c3.764-1.448 6.746-3.457 8.596-5.219 2.82-2.683 3.353-5.178 3.361-6.66a2.737 2.737 0 00-.216-1.084v-.002z" fill="#37E1BE"></path><path d="M14.303 1.867C12.955.7 11.248 0 9.39 0 7.532 0 5.883.677 4.545 1.807 2.791 3.29 1.627 5.557 1.5 8.125v9.201c.932-2.65 3.097-6.25 6.357-8.307.5-.318 1.025-.595 1.569-.829 1.883-.801 3.878-.932 5.746-.706-.222-2.83-.718-5.002-.87-5.617h.001z" fill="#A569FF"></path><path d="M17.305 4.961a199.47 199.47 0 01-1.08-1.094c-.202-.213-.398-.419-.586-.622l-1.333-1.378c.151.615.648 2.786.869 5.617 3.288.395 6.185 1.898 7.396 2.8-1.306-1.275-3.475-3.487-5.266-5.323z" fill="#1E37FC"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -0,0 +1 @@
|
||||
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Gemini</title><path d="M20.616 10.835a14.147 14.147 0 01-4.45-3.001 14.111 14.111 0 01-3.678-6.452.503.503 0 00-.975 0 14.134 14.134 0 01-3.679 6.452 14.155 14.155 0 01-4.45 3.001c-.65.28-1.318.505-2.002.678a.502.502 0 000 .975c.684.172 1.35.397 2.002.677a14.147 14.147 0 014.45 3.001 14.112 14.112 0 013.679 6.453.502.502 0 00.975 0c.172-.685.397-1.351.677-2.003a14.145 14.145 0 013.001-4.45 14.113 14.113 0 016.453-3.678.503.503 0 000-.975 13.245 13.245 0 01-2.003-.678z" fill="#3186FF"></path><path d="M20.616 10.835a14.147 14.147 0 01-4.45-3.001 14.111 14.111 0 01-3.678-6.452.503.503 0 00-.975 0 14.134 14.134 0 01-3.679 6.452 14.155 14.155 0 01-4.45 3.001c-.65.28-1.318.505-2.002.678a.502.502 0 000 .975c.684.172 1.35.397 2.002.677a14.147 14.147 0 014.45 3.001 14.112 14.112 0 013.679 6.453.502.502 0 00.975 0c.172-.685.397-1.351.677-2.003a14.145 14.145 0 013.001-4.45 14.113 14.113 0 016.453-3.678.503.503 0 000-.975 13.245 13.245 0 01-2.003-.678z" fill="url(#lobe-icons-gemini-0-_R_0_)"></path><path d="M20.616 10.835a14.147 14.147 0 01-4.45-3.001 14.111 14.111 0 01-3.678-6.452.503.503 0 00-.975 0 14.134 14.134 0 01-3.679 6.452 14.155 14.155 0 01-4.45 3.001c-.65.28-1.318.505-2.002.678a.502.502 0 000 .975c.684.172 1.35.397 2.002.677a14.147 14.147 0 014.45 3.001 14.112 14.112 0 013.679 6.453.502.502 0 00.975 0c.172-.685.397-1.351.677-2.003a14.145 14.145 0 013.001-4.45 14.113 14.113 0 016.453-3.678.503.503 0 000-.975 13.245 13.245 0 01-2.003-.678z" fill="url(#lobe-icons-gemini-1-_R_0_)"></path><path d="M20.616 10.835a14.147 14.147 0 01-4.45-3.001 14.111 14.111 0 01-3.678-6.452.503.503 0 00-.975 0 14.134 14.134 0 01-3.679 6.452 14.155 14.155 0 01-4.45 3.001c-.65.28-1.318.505-2.002.678a.502.502 0 000 .975c.684.172 1.35.397 2.002.677a14.147 14.147 0 014.45 3.001 14.112 14.112 0 013.679 6.453.502.502 0 00.975 0c.172-.685.397-1.351.677-2.003a14.145 14.145 0 013.001-4.45 14.113 14.113 0 016.453-3.678.503.503 0 000-.975 13.245 13.245 0 01-2.003-.678z" fill="url(#lobe-icons-gemini-2-_R_0_)"></path><defs><linearGradient gradientUnits="userSpaceOnUse" id="lobe-icons-gemini-0-_R_0_" x1="7" x2="11" y1="15.5" y2="12"><stop stop-color="#08B962"></stop><stop offset="1" stop-color="#08B962" stop-opacity="0"></stop></linearGradient><linearGradient gradientUnits="userSpaceOnUse" id="lobe-icons-gemini-1-_R_0_" x1="8" x2="11.5" y1="5.5" y2="11"><stop stop-color="#F94543"></stop><stop offset="1" stop-color="#F94543" stop-opacity="0"></stop></linearGradient><linearGradient gradientUnits="userSpaceOnUse" id="lobe-icons-gemini-2-_R_0_" x1="3.5" x2="17.5" y1="13.5" y2="12"><stop stop-color="#FABC12"></stop><stop offset=".46" stop-color="#FABC12" stop-opacity="0"></stop></linearGradient></defs></svg>
|
||||
|
After Width: | Height: | Size: 2.8 KiB |
@@ -0,0 +1 @@
|
||||
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Hunyuan</title><circle cx="12" cy="12" fill="#0055E9" r="12"></circle><path d="M12 0c.518 0 1.028.033 1.528.096A6.188 6.188 0 0112.12 12.28l-.12.001c-2.99 0-5.242 2.179-5.554 5.11-.223 2.086.353 4.412 2.242 6.146C3.672 22.1 0 17.479 0 12 0 5.373 5.373 0 12 0z" fill="#A8DFF5"></path><path d="M5.286 5a2.438 2.438 0 01.682 3.38c-3.962 5.966-3.215 10.743 2.648 15.136C3.636 22.056 0 17.452 0 12c0-1.787.39-3.482 1.09-5.006.253-.435.525-.872.817-1.311A2.438 2.438 0 015.286 5z" fill="#0055E9"></path><path d="M12.98.04c.272.021.543.053.81.093.583.106 1.117.254 1.538.44 6.638 2.927 8.07 10.052 1.748 15.642a4.125 4.125 0 01-5.822-.358c-1.51-1.706-1.3-4.184.357-5.822.858-.848 3.108-1.223 4.045-2.441 1.257-1.634 2.122-6.009-2.523-7.506L12.98.039z" fill="#00BCFF"></path><path d="M13.528.096A6.187 6.187 0 0112 12.281a5.75 5.75 0 00-1.71.255c.147-.905.595-1.784 1.321-2.501.858-.848 3.108-1.223 4.045-2.441 1.27-1.651 2.14-6.104-2.676-7.554.184.014.367.033.548.056z" fill="#ECECEE"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -0,0 +1 @@
|
||||
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Kimi</title><path d="M21.846 0a1.923 1.923 0 110 3.846H20.15a.226.226 0 01-.227-.226V1.923C19.923.861 20.784 0 21.846 0z" fill="#1783FF"></path><path d="M11.065 11.199l7.257-7.2c.137-.136.06-.41-.116-.41H14.3a.164.164 0 00-.117.051l-7.82 7.756c-.122.12-.302.013-.302-.179V3.82c0-.127-.083-.23-.185-.23H3.186c-.103 0-.186.103-.186.23V19.77c0 .128.083.23.186.23h2.69c.103 0 .186-.102.186-.23v-3.25c0-.069.025-.135.069-.178l2.424-2.406a.158.158 0 01.205-.023l6.484 4.772a7.677 7.677 0 003.453 1.283c.108.012.2-.095.2-.23v-3.06c0-.117-.07-.212-.164-.227a5.028 5.028 0 01-2.027-.807l-5.613-4.064c-.117-.078-.132-.279-.028-.381z" fill="currentColor"></path></svg>
|
||||
|
After Width: | Height: | Size: 781 B |
@@ -0,0 +1 @@
|
||||
<svg fill="currentColor" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>LongCat</title><path clip-rule="evenodd" d="M.507 19.883a.507.507 0 01-.489-.642L4.29 3.745a1.013 1.013 0 011.533-.578l5.622 3.687a1.013 1.013 0 001.11 0L18.2 3.165a1.013 1.013 0 011.532.58l4.25 15.497a.506.506 0 01-.49.64H18.07a6.297 6.297 0 001.53-4.115v-.177a6.09 6.09 0 00-1.513-4.017l-.697-3.495a.438.438 0 00-.694-.266L14.07 9.781a.748.748 0 01-.654.121 5.156 5.156 0 00-2.833 0 .746.746 0 01-.653-.121L7.302 7.81a.435.435 0 00-.688.269l-.675 3.652a5.36 5.36 0 00-1.539 3.76v.333c0 1.474.527 2.9 1.488 4.02l.032.038H.507z" fill="#29E154" fill-rule="evenodd"></path><path d="M9.213 16.843h1.52v-3.546h-1.29l-.23 3.546zm5.573 0h-1.52v-3.546h1.29l.23 3.546z"></path></svg>
|
||||
|
After Width: | Height: | Size: 819 B |
@@ -0,0 +1 @@
|
||||
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Meta</title><path d="M6.897 4h-.024l-.031 2.615h.022c1.715 0 3.046 1.357 5.94 6.246l.175.297.012.02 1.62-2.438-.012-.019a48.763 48.763 0 00-1.098-1.716 28.01 28.01 0 00-1.175-1.629C10.413 4.932 8.812 4 6.896 4z" fill="url(#lobe-icons-meta-0-_R_0_)"></path><path d="M6.873 4C4.95 4.01 3.247 5.258 2.02 7.17a4.352 4.352 0 00-.01.017l2.254 1.231.011-.017c.718-1.083 1.61-1.774 2.568-1.785h.021L6.896 4h-.023z" fill="url(#lobe-icons-meta-1-_R_0_)"></path><path d="M2.019 7.17l-.011.017C1.2 8.447.598 9.995.274 11.664l-.005.022 2.534.6.004-.022c.27-1.467.786-2.828 1.456-3.845l.011-.017L2.02 7.17z" fill="url(#lobe-icons-meta-2-_R_0_)"></path><path d="M2.807 12.264l-2.533-.6-.005.022c-.177.918-.267 1.851-.269 2.786v.023l2.598.233v-.023a12.591 12.591 0 01.21-2.44z" fill="url(#lobe-icons-meta-3-_R_0_)"></path><path d="M2.677 15.537a5.462 5.462 0 01-.079-.813v-.022L0 14.468v.024a8.89 8.89 0 00.146 1.652l2.535-.585a4.106 4.106 0 01-.004-.022z" fill="url(#lobe-icons-meta-4-_R_0_)"></path><path d="M3.27 16.89c-.284-.31-.484-.756-.589-1.328l-.004-.021-2.535.585.004.021c.192 1.01.568 1.85 1.106 2.487l.014.017 2.018-1.745a2.106 2.106 0 01-.015-.016z" fill="url(#lobe-icons-meta-5-_R_0_)"></path><path d="M10.78 9.654c-1.528 2.35-2.454 3.825-2.454 3.825-2.035 3.2-2.739 3.917-3.871 3.917a1.545 1.545 0 01-1.186-.508l-2.017 1.744.014.017C2.01 19.518 3.058 20 4.356 20c1.963 0 3.374-.928 5.884-5.33l1.766-3.13a41.283 41.283 0 00-1.227-1.886z" fill="#0082FB"></path><path d="M13.502 5.946l-.016.016c-.4.43-.786.908-1.16 1.416.378.483.768 1.024 1.175 1.63.48-.743.928-1.345 1.367-1.807l.016-.016-1.382-1.24z" fill="url(#lobe-icons-meta-6-_R_0_)"></path><path d="M20.918 5.713C19.853 4.633 18.583 4 17.225 4c-1.432 0-2.637.787-3.723 1.944l-.016.016 1.382 1.24.016-.017c.715-.747 1.408-1.12 2.176-1.12.826 0 1.6.39 2.27 1.075l.015.016 1.589-1.425-.016-.016z" fill="#0082FB"></path><path d="M23.998 14.125c-.06-3.467-1.27-6.566-3.064-8.396l-.016-.016-1.588 1.424.015.016c1.35 1.392 2.277 3.98 2.361 6.971v.023h2.292v-.022z" fill="url(#lobe-icons-meta-7-_R_0_)"></path><path d="M23.998 14.15v-.023h-2.292v.022c.004.14.006.282.006.424 0 .815-.121 1.474-.368 1.95l-.011.022 1.708 1.782.013-.02c.62-.96.946-2.293.946-3.91 0-.083 0-.165-.002-.247z" fill="url(#lobe-icons-meta-8-_R_0_)"></path><path d="M21.344 16.52l-.011.02c-.214.402-.519.67-.917.787l.778 2.462a3.493 3.493 0 00.438-.182 3.558 3.558 0 001.366-1.218l.044-.065.012-.02-1.71-1.784z" fill="url(#lobe-icons-meta-9-_R_0_)"></path><path d="M19.92 17.393c-.262 0-.492-.039-.718-.14l-.798 2.522c.449.153.927.222 1.46.222.492 0 .943-.073 1.352-.215l-.78-2.462c-.167.05-.341.075-.517.073z" fill="url(#lobe-icons-meta-10-_R_0_)"></path><path d="M18.323 16.534l-.014-.017-1.836 1.914.016.017c.637.682 1.246 1.105 1.937 1.337l.797-2.52c-.291-.125-.573-.353-.9-.731z" fill="url(#lobe-icons-meta-11-_R_0_)"></path><path d="M18.309 16.515c-.55-.642-1.232-1.712-2.303-3.44l-1.396-2.336-.011-.02-1.62 2.438.012.02.989 1.668c.959 1.61 1.74 2.774 2.493 3.585l.016.016 1.834-1.914a2.353 2.353 0 01-.014-.017z" fill="url(#lobe-icons-meta-12-_R_0_)"></path><defs><linearGradient id="lobe-icons-meta-0-_R_0_" x1="75.897%" x2="26.312%" y1="89.199%" y2="12.194%"><stop offset=".06%" stop-color="#0867DF"></stop><stop offset="45.39%" stop-color="#0668E1"></stop><stop offset="85.91%" stop-color="#0064E0"></stop></linearGradient><linearGradient id="lobe-icons-meta-1-_R_0_" x1="21.67%" x2="97.068%" y1="75.874%" y2="23.985%"><stop offset="13.23%" stop-color="#0064DF"></stop><stop offset="99.88%" stop-color="#0064E0"></stop></linearGradient><linearGradient id="lobe-icons-meta-2-_R_0_" x1="38.263%" x2="60.895%" y1="89.127%" y2="16.131%"><stop offset="1.47%" stop-color="#0072EC"></stop><stop offset="68.81%" stop-color="#0064DF"></stop></linearGradient><linearGradient id="lobe-icons-meta-3-_R_0_" x1="47.032%" x2="52.15%" y1="90.19%" y2="15.745%"><stop offset="7.31%" stop-color="#007CF6"></stop><stop offset="99.43%" stop-color="#0072EC"></stop></linearGradient><linearGradient id="lobe-icons-meta-4-_R_0_" x1="52.155%" x2="47.591%" y1="58.301%" y2="37.004%"><stop offset="7.31%" stop-color="#007FF9"></stop><stop offset="100%" stop-color="#007CF6"></stop></linearGradient><linearGradient id="lobe-icons-meta-5-_R_0_" x1="37.689%" x2="61.961%" y1="12.502%" y2="63.624%"><stop offset="7.31%" stop-color="#007FF9"></stop><stop offset="100%" stop-color="#0082FB"></stop></linearGradient><linearGradient id="lobe-icons-meta-6-_R_0_" x1="34.808%" x2="62.313%" y1="68.859%" y2="23.174%"><stop offset="27.99%" stop-color="#007FF8"></stop><stop offset="91.41%" stop-color="#0082FB"></stop></linearGradient><linearGradient id="lobe-icons-meta-7-_R_0_" x1="43.762%" x2="57.602%" y1="6.235%" y2="98.514%"><stop offset="0%" stop-color="#0082FB"></stop><stop offset="99.95%" stop-color="#0081FA"></stop></linearGradient><linearGradient id="lobe-icons-meta-8-_R_Line truncated
|
||||
|
After Width: | Height: | Size: 5.9 KiB |
@@ -0,0 +1 @@
|
||||
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Minimax</title><defs><linearGradient id="lobe-icons-minimax-_R_0_" x1="0%" x2="100.182%" y1="50.057%" y2="50.057%"><stop offset="0%" stop-color="#E2167E"></stop><stop offset="100%" stop-color="#FE603C"></stop></linearGradient></defs><path d="M16.278 2c1.156 0 2.093.927 2.093 2.07v12.501a.74.74 0 00.744.709.74.74 0 00.743-.709V9.099a2.06 2.06 0 012.071-2.049A2.06 2.06 0 0124 9.1v6.561a.649.649 0 01-.652.645.649.649 0 01-.653-.645V9.1a.762.762 0 00-.766-.758.762.762 0 00-.766.758v7.472a2.037 2.037 0 01-2.048 2.026 2.037 2.037 0 01-2.048-2.026v-12.5a.785.785 0 00-.788-.753.785.785 0 00-.789.752l-.001 15.904A2.037 2.037 0 0113.441 22a2.037 2.037 0 01-2.048-2.026V18.04c0-.356.292-.645.652-.645.36 0 .652.289.652.645v1.934c0 .263.142.506.372.638.23.131.514.131.744 0a.734.734 0 00.372-.638V4.07c0-1.143.937-2.07 2.093-2.07zm-5.674 0c1.156 0 2.093.927 2.093 2.07v11.523a.648.648 0 01-.652.645.648.648 0 01-.652-.645V4.07a.785.785 0 00-.789-.78.785.785 0 00-.789.78v14.013a2.06 2.06 0 01-2.07 2.048 2.06 2.06 0 01-2.071-2.048V9.1a.762.762 0 00-.766-.758.762.762 0 00-.766.758v3.8a2.06 2.06 0 01-2.071 2.049A2.06 2.06 0 010 12.9v-1.378c0-.357.292-.646.652-.646.36 0 .653.29.653.646V12.9c0 .418.343.757.766.757s.766-.339.766-.757V9.099a2.06 2.06 0 012.07-2.048 2.06 2.06 0 012.071 2.048v8.984c0 .419.343.758.767.758.423 0 .766-.339.766-.758V4.07c0-1.143.937-2.07 2.093-2.07z" fill="url(#lobe-icons-minimax-_R_0_)" fill-rule="nonzero"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
@@ -0,0 +1 @@
|
||||
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Mistral</title><path d="M3.428 3.4h3.429v3.428H3.428V3.4zm13.714 0h3.43v3.428h-3.43V3.4z" fill="gold"></path><path d="M3.428 6.828h6.857v3.429H3.429V6.828zm10.286 0h6.857v3.429h-6.857V6.828z" fill="#FFAF00"></path><path d="M3.428 10.258h17.144v3.428H3.428v-3.428z" fill="#FF8205"></path><path d="M3.428 13.686h3.429v3.428H3.428v-3.428zm6.858 0h3.429v3.428h-3.429v-3.428zm6.856 0h3.43v3.428h-3.43v-3.428z" fill="#FA500F"></path><path d="M0 17.114h10.286v3.429H0v-3.429zm13.714 0H24v3.429H13.714v-3.429z" fill="#E10500"></path></svg>
|
||||
|
After Width: | Height: | Size: 655 B |
@@ -0,0 +1 @@
|
||||
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Nvidia</title><path d="M10.212 8.976V7.62c.127-.01.256-.017.388-.021 3.596-.117 5.957 3.184 5.957 3.184s-2.548 3.647-5.282 3.647a3.227 3.227 0 01-1.063-.175v-4.109c1.4.174 1.681.812 2.523 2.258l1.873-1.627a4.905 4.905 0 00-3.67-1.846 6.594 6.594 0 00-.729.044m0-4.476v2.025c.13-.01.259-.019.388-.024 5.002-.174 8.261 4.226 8.261 4.226s-3.743 4.69-7.643 4.69c-.338 0-.675-.031-1.007-.092v1.25c.278.038.558.057.838.057 3.629 0 6.253-1.91 8.794-4.169.421.347 2.146 1.193 2.501 1.564-2.416 2.083-8.048 3.763-11.24 3.763-.308 0-.603-.02-.894-.048V19.5H24v-15H10.21zm0 9.756v1.068c-3.356-.616-4.287-4.21-4.287-4.21a7.173 7.173 0 014.287-2.138v1.172h-.005a3.182 3.182 0 00-2.502 1.178s.615 2.276 2.507 2.931m-5.961-3.3c1.436-1.935 3.604-3.148 5.961-3.336V6.523C5.81 6.887 2 10.723 2 10.723s2.158 6.427 8.21 7.015v-1.166C5.77 16 4.25 10.958 4.25 10.958h-.002z" fill="#74B71B" fill-rule="nonzero"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.0 KiB |
@@ -0,0 +1 @@
|
||||
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Ollama</title><path d="M7.905 1.09c.216.085.411.225.588.41.295.306.544.744.734 1.263.191.522.315 1.1.362 1.68a5.054 5.054 0 012.049-.636l.051-.004c.87-.07 1.73.087 2.48.474.101.053.2.11.297.17.05-.569.172-1.134.36-1.644.19-.52.439-.957.733-1.264a1.67 1.67 0 01.589-.41c.257-.1.53-.118.796-.042.401.114.745.368 1.016.737.248.337.434.769.561 1.287.23.934.27 2.163.115 3.645l.053.04.026.019c.757.576 1.284 1.397 1.563 2.35.435 1.487.216 3.155-.534 4.088l-.018.021.002.003c.417.762.67 1.567.724 2.4l.002.03c.064 1.065-.2 2.137-.814 3.19l-.007.01.01.024c.472 1.157.62 2.322.438 3.486l-.006.039a.651.651 0 01-.747.536.648.648 0 01-.54-.742c.167-1.033.01-2.069-.48-3.123a.643.643 0 01.04-.617l.004-.006c.604-.924.854-1.83.8-2.72-.046-.779-.325-1.544-.8-2.273a.644.644 0 01.18-.886l.009-.006c.243-.159.467-.565.58-1.12a4.229 4.229 0 00-.095-1.974c-.205-.7-.58-1.284-1.105-1.683-.595-.454-1.383-.673-2.38-.61a.653.653 0 01-.632-.371c-.314-.665-.772-1.141-1.343-1.436a3.288 3.288 0 00-1.772-.332c-1.245.099-2.343.801-2.67 1.686a.652.652 0 01-.61.425c-1.067.002-1.893.252-2.497.703-.522.39-.878.935-1.066 1.588a4.07 4.07 0 00-.068 1.886c.112.558.331 1.02.582 1.269l.008.007c.212.207.257.53.109.785-.36.622-.629 1.549-.673 2.44-.05 1.018.186 1.902.719 2.536l.016.019a.643.643 0 01.095.69c-.576 1.236-.753 2.252-.562 3.052a.652.652 0 01-1.269.298c-.243-1.018-.078-2.184.473-3.498l.014-.035-.008-.012a4.339 4.339 0 01-.598-1.309l-.005-.019a5.764 5.764 0 01-.177-1.785c.044-.91.278-1.842.622-2.59l.012-.026-.002-.002c-.293-.418-.51-.953-.63-1.545l-.005-.024a5.352 5.352 0 01.093-2.49c.262-.915.777-1.701 1.536-2.269.06-.045.123-.09.186-.132-.159-1.493-.119-2.73.112-3.67.127-.518.314-.95.562-1.287.27-.368.614-.622 1.015-.737.266-.076.54-.059.797.042zm4.116 9.09c.936 0 1.8.313 2.446.855.63.527 1.005 1.235 1.005 1.94 0 .888-.406 1.58-1.133 2.022-.62.375-1.451.557-2.403.557-1.009 0-1.871-.259-2.493-.734-.617-.47-.963-1.13-.963-1.845 0-.707.398-1.417 1.056-1.946.668-.537 1.55-.849 2.485-.849zm0 .896a3.07 3.07 0 00-1.916.65c-.461.37-.722.835-.722 1.25 0 .428.21.829.61 1.134.455.347 1.124.548 1.943.548.799 0 1.473-.147 1.932-.426.463-.28.7-.686.7-1.257 0-.423-.246-.89-.683-1.256-.484-.405-1.14-.643-1.864-.643zm.662 1.21l.004.004c.12.151.095.37-.056.49l-.292.23v.446a.375.375 0 01-.376.373.375.375 0 01-.376-.373v-.46l-.271-.218a.347.347 0 01-.052-.49.353.353 0 01.494-.051l.215.172.22-.174a.353.353 0 01.49.051zm-5.04-1.919c.478 0 .867.39.867.871a.87.87 0 01-.868.871.87.87 0 01-.867-.87.87.87 0 01.867-.872zm8.706 0c.48 0 .868.39.868.871a.87.87 0 01-.868.871.87.87 0 01-.867-.87.87.87 0 01.867-.872zM7.44 2.3l-.003.002a.659.659 0 00-.285.238l-.005.006c-.138.189-.258.467-.348.832-.17.692-.216 1.631-.124 2.782.43-.128.899-.208 1.404-.237l.01-.001.019-.034c.046-.082.095-.161.148-.239.123-.771.022-1.692-.253-2.444-.134-.364-.297-.65-.453-.813a.628.628 0 00-.107-.09L7.44 2.3zm9.174.04l-.002.001a.628.628 0 00-.107.09c-.156.163-.32.45-.453.814-.29.794-.387 1.776-.23 2.572l.058.097.008.014h.03a5.184 5.184 0 011.466.212c.086-1.124.038-2.043-.128-2.722-.09-.365-.21-.643-.349-.832l-.004-.006a.659.659 0 00-.285-.239h-.004z"></path></svg>
|
||||
|
After Width: | Height: | Size: 3.2 KiB |
@@ -0,0 +1 @@
|
||||
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>OpenAI</title><path d="M9.205 8.658v-2.26c0-.19.072-.333.238-.428l4.543-2.616c.619-.357 1.356-.523 2.117-.523 2.854 0 4.662 2.212 4.662 4.566 0 .167 0 .357-.024.547l-4.71-2.759a.797.797 0 00-.856 0l-5.97 3.473zm10.609 8.8V12.06c0-.333-.143-.57-.429-.737l-5.97-3.473 1.95-1.118a.433.433 0 01.476 0l4.543 2.617c1.309.76 2.189 2.378 2.189 3.948 0 1.808-1.07 3.473-2.76 4.163zM7.802 12.703l-1.95-1.142c-.167-.095-.239-.238-.239-.428V5.899c0-2.545 1.95-4.472 4.591-4.472 1 0 1.927.333 2.712.928L8.23 5.067c-.285.166-.428.404-.428.737v6.898zM12 15.128l-2.795-1.57v-3.33L12 8.658l2.795 1.57v3.33L12 15.128zm1.796 7.23c-1 0-1.927-.332-2.712-.927l4.686-2.712c.285-.166.428-.404.428-.737v-6.898l1.974 1.142c.167.095.238.238.238.428v5.233c0 2.545-1.974 4.472-4.614 4.472zm-5.637-5.303l-4.544-2.617c-1.308-.761-2.188-2.378-2.188-3.948A4.482 4.482 0 014.21 6.327v5.423c0 .333.143.571.428.738l5.947 3.449-1.95 1.118a.432.432 0 01-.476 0zm-.262 3.9c-2.688 0-4.662-2.021-4.662-4.519 0-.19.024-.38.047-.57l4.686 2.71c.286.167.571.167.856 0l5.97-3.448v2.26c0 .19-.07.333-.237.428l-4.543 2.616c-.619.357-1.356.523-2.117.523zm5.899 2.83a5.947 5.947 0 005.827-4.756C22.287 18.339 24 15.84 24 13.296c0-1.665-.713-3.282-1.998-4.448.119-.5.19-.999.19-1.498 0-3.401-2.759-5.947-5.946-5.947-.642 0-1.26.095-1.88.31A5.962 5.962 0 0010.205 0a5.947 5.947 0 00-5.827 4.757C1.713 5.447 0 7.945 0 10.49c0 1.666.713 3.283 1.998 4.448-.119.5-.19 1-.19 1.499 0 3.401 2.759 5.946 5.946 5.946.642 0 1.26-.095 1.88-.309a5.96 5.96 0 004.162 1.713z"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.6 KiB |