Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cbcc2ebb1e | ||
|
|
674c2b45e7 | ||
|
|
ec6929c157 | ||
|
|
dcd4135049 | ||
|
|
08ea935ea9 | ||
|
|
2a3a3738db | ||
|
|
d1eb9c968e | ||
|
|
a2597fb17c | ||
|
|
41ea14ccc9 | ||
|
|
b3bea0d076 | ||
|
|
efff0a825f | ||
|
|
44712c3aad | ||
|
|
d188af4207 | ||
|
|
8a89f0874b | ||
|
|
350379ffff | ||
|
|
65ecbce661 | ||
|
|
2656f33f28 | ||
|
|
e31c2347b4 | ||
|
|
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 | ||
|
|
70b90453a5 | ||
|
|
7205271b07 | ||
|
|
a6ffc30339 | ||
|
|
f3e3ac9f2e | ||
|
|
f133704176 | ||
|
|
382016dfe1 | ||
|
|
4fefec819c | ||
|
|
c1079d99ba | ||
|
|
6c1f68df48 | ||
|
|
9e68ee8f25 | ||
|
|
74f0bf4dc9 | ||
|
|
9ce320d1c2 | ||
|
|
8ccc09925f | ||
|
|
da10d503d5 | ||
|
|
96cdd810da | ||
|
|
d2f94a6bc3 | ||
|
|
ccd0f0ca9f | ||
|
|
a001b16563 | ||
|
|
cc08d5552b | ||
|
|
bf42f9ad83 | ||
|
|
e511357b6d | ||
|
|
79b96679e0 | ||
|
|
6d29079a09 | ||
|
|
787886669f | ||
|
|
8378f0834c | ||
|
|
600bd55461 | ||
|
|
dc92a55618 | ||
|
|
80ebd88bf7 | ||
|
|
8ae73f9332 | ||
|
|
7352385cc6 | ||
|
|
6ade8b3238 | ||
|
|
87fb22211d | ||
|
|
cbdc515ce7 | ||
|
|
fb3e8535e7 | ||
|
|
1117fb2eca | ||
|
|
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 | ||
|
|
ef34969818 | ||
|
|
7639c30135 | ||
|
|
fd8729692b | ||
|
|
0f943c7f7a | ||
|
|
db465ec26f | ||
|
|
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,275 @@
|
||||
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 (for example studio-v2.51.0)"
|
||||
required: true
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: write # upload the APK to the release the tag belongs to
|
||||
|
||||
concurrency:
|
||||
group: android-${{ inputs.tag || 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 }}
|
||||
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 }}
|
||||
REF_NAME: ${{ github.ref_name }}
|
||||
run: |
|
||||
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"; } >> "$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
|
||||
|
||||
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7
|
||||
with:
|
||||
node-version: "24"
|
||||
cache: pnpm
|
||||
cache-dependency-path: desktop/frontend-next/pnpm-lock.yaml
|
||||
|
||||
- uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v4
|
||||
with:
|
||||
version: 10
|
||||
|
||||
- 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
|
||||
run: |
|
||||
./scripts/build-kernel.sh
|
||||
./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" ./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'
|
||||
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
|
||||
@@ -315,7 +1079,7 @@ 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'
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -159,9 +159,10 @@ jobs:
|
||||
node perf/composer-toolbar-row.mjs
|
||||
node perf/action-icons.mjs
|
||||
node perf/rowhit.mjs
|
||||
node perf/flowgrip-quiet.mjs
|
||||
node perf/flow-narrow.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
|
||||
|
||||
@@ -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}"
|
||||
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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,11 +10,13 @@
|
||||
"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",
|
||||
"browser-fit-pages": "electron test/browser_fit_pages.js",
|
||||
@@ -31,7 +33,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
|
||||
|
||||
@@ -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, Menu, 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.
|
||||
@@ -31,7 +31,8 @@ const { BrowserProtocol } = require("./browserprotocol");
|
||||
const { BrowserViews } = require("./browserviews");
|
||||
const { startBrowserRelay } = require("./browserrelay");
|
||||
const { groundFor, themeSourceFor } = require("./ground");
|
||||
const { loadPrefs, pageSleepAfterMs, 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");
|
||||
@@ -90,6 +91,7 @@ let browserRelay = null;
|
||||
let reload = null;
|
||||
let powerGuard = null;
|
||||
let logs = null;
|
||||
let windowShortcut = null;
|
||||
let handshaken = false;
|
||||
|
||||
let starting = null;
|
||||
@@ -335,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() {
|
||||
@@ -398,15 +403,18 @@ 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));
|
||||
@@ -554,7 +562,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);
|
||||
@@ -601,6 +620,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
|
||||
@@ -20,13 +21,12 @@ function pageSleepAfterMs(prefs) {
|
||||
return prefs && prefs["rx-page-sleep"] === "on" ? PAGE_SLEEP_MS : Infinity;
|
||||
}
|
||||
|
||||
// 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.
|
||||
// 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;
|
||||
}
|
||||
@@ -52,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
|
||||
@@ -68,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 });
|
||||
});
|
||||
}
|
||||
|
||||
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,6 +22,7 @@ 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"),
|
||||
@@ -31,6 +32,8 @@ contextBridge.exposeInMainWorld("reasonixHost", {
|
||||
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)),
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
@@ -1152,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;
|
||||
@@ -2183,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", () => {
|
||||
|
||||
@@ -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,9 +39,12 @@ 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 # 右栏:端点自己写的一句话有多长,都不该改变栏宽
|
||||
@@ -49,6 +52,7 @@ 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 # 双语验证:英文启动、界面译文到位、切回中文
|
||||
@@ -82,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每区会话数`,逗号分隔),
|
||||
@@ -98,6 +111,13 @@ node perf/start.mjs # 经工作台标签/边缘把手打开工作台先出
|
||||
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.
|
||||
|
||||
## 判据
|
||||
|
||||
|
||||
@@ -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,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();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { chromium } from "playwright";
|
||||
|
||||
const base = process.env.PERF_URL ?? "http://localhost:4399/perf.html?pref=zh&turns=0";
|
||||
const browser = await chromium.launch();
|
||||
try {
|
||||
const page = await browser.newPage({ viewport: { width: 1600, height: 860 } });
|
||||
const errors = [];
|
||||
page.on("pageerror", (error) => errors.push(error.message));
|
||||
await page.goto(base, { waitUntil: "networkidle" });
|
||||
await page.waitForSelector(".flowgrip-l");
|
||||
const width = () => page.locator(".compose").evaluate((el) => Math.round(el.getBoundingClientRect().width));
|
||||
const start = await width();
|
||||
const drag = async (dx) => {
|
||||
const box = await page.locator(".flowgrip-l").boundingBox();
|
||||
await page.mouse.move(box.x + 3, box.y + box.height / 2);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(box.x + 3 + dx, box.y + box.height / 2, { steps: 8 });
|
||||
await page.mouse.up();
|
||||
await page.waitForTimeout(200);
|
||||
return width();
|
||||
};
|
||||
const mid = await drag(40);
|
||||
assert.ok(mid < start && mid >= 640, `a drag inward goes under the readable measure (${start} -> ${mid})`);
|
||||
const floor = await drag(400);
|
||||
assert.equal(floor, 640, `the column stops at 640 (${floor})`);
|
||||
const slider = page.locator(".flowgrip-l");
|
||||
assert.equal(await slider.getAttribute("aria-valuemin"), "640");
|
||||
await slider.focus();
|
||||
await page.keyboard.press("Home");
|
||||
await page.waitForTimeout(200);
|
||||
assert.equal(await width(), start, "Home returns to the readable measure");
|
||||
assert.deepEqual(errors, []);
|
||||
console.log(`PASS the column is continuous from 640 up: ${start} -> ${mid} -> ${floor} -> ${start}`);
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { chromium } from "playwright";
|
||||
|
||||
const base = process.env.PERF_URL ?? "http://localhost:4399/perf.html?pref=zh&turns=0";
|
||||
const browser = await chromium.launch();
|
||||
try {
|
||||
for (const scheme of ["light", "dark"]) {
|
||||
const page = await browser.newPage({ viewport: { width: 1600, height: 860 }, colorScheme: scheme });
|
||||
const errors = [];
|
||||
page.on("pageerror", (error) => errors.push(error.message));
|
||||
await page.goto(base, { waitUntil: "networkidle" });
|
||||
await page.waitForSelector(".flowgrip-l");
|
||||
const paint = (selector) => page.locator(selector).evaluate((el) => getComputedStyle(el, "::before").backgroundColor);
|
||||
const alpha = (css) => (css.startsWith("rgba") ? Number(css.split(",")[3].replace(")", "")) : css === "transparent" ? 0 : 1);
|
||||
await page.mouse.move(800, 400);
|
||||
for (const side of ["l", "r"]) {
|
||||
assert.equal(alpha(await paint(`.flowgrip-${side}`)), 0, `${scheme}: the ${side} grip draws nothing at rest`);
|
||||
}
|
||||
const box = await page.locator(".flowgrip-l").boundingBox();
|
||||
await page.mouse.move(box.x + 3, box.y + box.height / 2);
|
||||
await page.waitForTimeout(400);
|
||||
assert.ok(alpha(await paint(".flowgrip-l")) > 0, `${scheme}: the grip appears under the pointer`);
|
||||
const before = await page.evaluate(() => Math.round(document.querySelector(".compose").getBoundingClientRect().width));
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(box.x - 60, box.y + box.height / 2, { steps: 6 });
|
||||
await page.mouse.up();
|
||||
await page.waitForTimeout(200);
|
||||
const after = await page.evaluate(() => Math.round(document.querySelector(".compose").getBoundingClientRect().width));
|
||||
assert.ok(after > before, `${scheme}: a hidden grip still resizes the column (${before} -> ${after})`);
|
||||
await page.mouse.move(800, 400);
|
||||
await page.waitForTimeout(400);
|
||||
assert.equal(alpha(await paint(".flowgrip-l")), 0, `${scheme}: it hides again once the pointer leaves`);
|
||||
assert.deepEqual(errors, []);
|
||||
console.log(`PASS ${scheme}: grips are invisible at rest, appear under the pointer, and still resize`);
|
||||
await page.close();
|
||||
}
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
@@ -2,11 +2,12 @@ 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";
|
||||
import "../src/styles/studio.css";
|
||||
import "../src/styles/flowgrip.css";
|
||||
import "../src/styles/changedpill.css";
|
||||
import "../src/styles/askskip.css";
|
||||
import "../src/styles/pastekeep.css";
|
||||
@@ -54,6 +55,7 @@ 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";
|
||||
@@ -101,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();
|
||||
@@ -337,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.
|
||||
@@ -351,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.
|
||||
@@ -362,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,
|
||||
@@ -399,6 +424,7 @@ class BenchPort extends MockPort {
|
||||
? { 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("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) => ({
|
||||
@@ -477,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);
|
||||
@@ -484,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);
|
||||
@@ -511,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);
|
||||
@@ -544,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,
|
||||
@@ -562,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),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -607,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) => {
|
||||
|
||||
@@ -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,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));
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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]: {}
|
||||
|
||||
@@ -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]: {}
|
||||
|
||||
@@ -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.
|
||||
@@ -107,7 +108,10 @@ export const ACTIONS: UIAction[] = [
|
||||
{ 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" },
|
||||
@@ -122,6 +126,10 @@ 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" },
|
||||
@@ -146,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" },
|
||||
@@ -283,15 +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.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" },
|
||||
...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" },
|
||||
@@ -302,44 +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.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: "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" },
|
||||
@@ -492,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" },
|
||||
@@ -548,6 +517,7 @@ export const ACTIONS: UIAction[] = [
|
||||
{ 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" },
|
||||
@@ -577,6 +547,10 @@ 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" },
|
||||
@@ -595,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" },
|
||||
@@ -619,17 +589,18 @@ export const ACTIONS: UIAction[] = [
|
||||
// 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.reading-width", kind: "view", target: "none", proof: "interaction" },
|
||||
{ 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.
|
||||
@@ -679,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.
|
||||
@@ -790,6 +762,7 @@ 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 |
@@ -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>opencode</title><path d="M16 6H8v12h8V6zm4 16H4V2h16v20z"></path></svg>
|
||||
|
After Width: | Height: | Size: 235 B |
@@ -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>OpenRouter</title><path d="M18.654 3.87a5.087 5.087 0 110 10.174L23.7 19.09c.64.641.187 1.737-.72 1.737H8.48a8.479 8.479 0 010-16.958h10.175zM8.479 7.26a5.087 5.087 0 100 10.176 5.087 5.087 0 000-10.175z"></path></svg>
|
||||
|
After Width: | Height: | Size: 382 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>Perplexity</title><path d="M19.785 0v7.272H22.5V17.62h-2.935V24l-7.037-6.194v6.145h-1.091v-6.152L4.392 24v-6.465H1.5V7.188h2.884V0l7.053 6.494V.19h1.09v6.49L19.786 0zm-7.257 9.044v7.319l5.946 5.234V14.44l-5.946-5.397zm-1.099-.08l-5.946 5.398v7.235l5.946-5.234V8.965zm8.136 7.58h1.844V8.349H13.46l6.105 5.54v2.655zm-8.982-8.28H2.59v8.195h1.8v-2.576l6.192-5.62zM5.475 2.476v4.71h5.115l-5.115-4.71zm13.219 0l-5.115 4.71h5.115v-4.71z" fill="#22B8CD" fill-rule="nonzero"></path></svg>
|
||||
|
After Width: | Height: | Size: 603 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>Qwen</title><path d="M19.713 2.825h-5.907c0-.251-.066-.503-.196-.728L12.819.728A1.46 1.46 0 0 0 11.559 0h-1.58c-.52 0-1 .275-1.26.728l-2.42 4.19a1.45 1.45 0 0 0 0 1.453l.791 1.37c.26.448.74.727 1.26.727h11.36c.52 0 1-.275 1.26-.728l.79-1.37a1.45 1.45 0 0 0 0-1.451l-.79-1.37a1.46 1.46 0 0 0-1.26-.728zM.195 9.905l2.951 5.115a1.46 1.46 0 0 0-.531.531l-.791 1.37a1.45 1.45 0 0 0 0 1.452l.79 1.37c.26.448.74.727 1.26.727h4.84c.52 0 1-.275 1.26-.728l.79-1.37a1.45 1.45 0 0 0 0-1.451L5.086 7.083a1.46 1.46 0 0 0-1.26-.728h-1.58c-.52 0-1 .276-1.26.728l-.79 1.37a1.45 1.45 0 0 0 0 1.452m15.888 13.367 2.951-5.115c.217.125.469.196.728.196h1.582c.52 0 1-.275 1.26-.728l.79-1.37a1.45 1.45 0 0 0 0-1.451l-2.42-4.191a1.46 1.46 0 0 0-1.259-.728h-1.582c-.52 0-1 .276-1.259.728l-5.678 9.838a1.45 1.45 0 0 0 0 1.452l.79 1.37c.26.448.74.727 1.26.727h1.582c.52 0 1-.275 1.26-.728z" fill="#082dff"></path></svg>
|
||||
|
After Width: | Height: | Size: 1016 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>Stepfun</title><path d="M22.012 0h1.032v.927H24v.968h-.956V3.78h-1.032V1.896h-1.878v-.97h1.878V0zM2.6 12.371V1.87h.969v10.502h-.97zm10.423.66h10.95v.918h-6.208v9.579h-4.742V13.03zM5.629 3.333v12.356H0v4.51h10.386V8L20.859 8l-.003-4.668-15.227.001z" fill="url(#lobe-icons-stepfun-_R_0_)" fill-rule="evenodd"></path><defs><linearGradient gradientUnits="userSpaceOnUse" id="lobe-icons-stepfun-_R_0_" x1="1.646" x2="18.342" y1="1.916" y2="22.091"><stop stop-color="#01A9FF"></stop><stop offset="1" stop-color="#0160FF"></stop></linearGradient></defs></svg>
|
||||
|
After Width: | Height: | Size: 676 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>Volcengine</title><path d="M19.44 10.153l-2.936 11.586a.215.215 0 00.214.261h5.87a.215.215 0 00.214-.261l-2.95-11.586a.214.214 0 00-.412 0zM3.28 12.778l-2.275 8.96A.214.214 0 001.22 22h4.532a.212.212 0 00.214-.165.214.214 0 000-.097l-2.276-8.96a.214.214 0 00-.41 0z" fill="#00E5E5"></path><path d="M7.29 5.359L3.148 21.738a.215.215 0 00.203.261h8.29a.214.214 0 00.215-.261L7.7 5.358a.214.214 0 00-.41 0z" fill="#006EFF"></path><path d="M14.44.15a.214.214 0 00-.41 0L8.366 21.739a.214.214 0 00.214.261H19.9a.216.216 0 00.171-.078.214.214 0 00.044-.183L14.439.15z" fill="#006EFF"></path><path d="M10.278 7.741L6.685 21.736a.214.214 0 00.214.264h7.17a.215.215 0 00.214-.264L10.688 7.741a.214.214 0 00-.41 0z" fill="#00E5E5"></path></svg>
|
||||
|
After Width: | Height: | Size: 858 B |
@@ -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>XiaomiMiMo</title><path d="M.958 15.936a.459.459 0 01.459.44v2.729a.46.46 0 01-.918 0v-2.729a.459.459 0 01.459-.44zm4.814-2.035a.46.46 0 01.553.45v4.754a.458.458 0 11-.918 0V15.48L3.74 17.202a.462.462 0 01-.655.016.462.462 0 01-.065-.082L.628 14.67a.459.459 0 01.658-.637l2.124 2.187 2.127-2.188a.46.46 0 01.235-.13zm2.068.004a.46.46 0 01.458.445v4.755a.46.46 0 01-.458.458.459.459 0 01-.458-.458V14.35a.459.459 0 01.458-.445zm1.973 2.014a.46.46 0 01.46.457v2.729a.46.46 0 01-.784.324.46.46 0 01-.134-.324v-2.729a.46.46 0 01.458-.458zm.002-2.045a.458.458 0 01.328.157l2.127 2.19 2.125-2.19a.459.459 0 01.784.318v4.756a.46.46 0 01-.455.458.46.46 0 01-.458-.458V15.48l-1.667 1.723a.46.46 0 01-.65.008l-.005-.005c0-.002-.002-.002-.004-.003l-2.455-2.534a.46.46 0 01-.008-.667.461.461 0 01.338-.128zm6.797 1.206a.46.46 0 01.53.651A1.966 1.966 0 0019.81 18.4a.462.462 0 01.623.18.46.46 0 01-.181.624 2.863 2.863 0 01-1.38.353l-.142-.004a2.88 2.88 0 01-2.393-4.263.461.461 0 01.274-.21zm.864-.931a2.884 2.884 0 013.915 3.914.46.46 0 01-.402.24l-.057-.004a.458.458 0 01-.164-.055.46.46 0 01-.182-.622 1.967 1.967 0 00-2.669-2.67.459.459 0 11-.441-.803zM9.59 6.368c1.481 0 1.696 1.202 1.696 1.654v2.648h-.917v-.432c-.26.346-.792.535-1.36.535-.133 0-1.289-.03-1.384-1.136-.082-.932.675-1.61 2.053-1.61h.691c0-.563-.367-.886-.983-.886-.44.013-.864.174-1.2.458l-.36-.664c.484-.379 1.012-.567 1.764-.567zm4.427.1c1.263 0 2.082.97 2.083 2.15 0 1.181-.824 2.154-2.083 2.154-1.26 0-2.084-.972-2.084-2.152 0-1.18.82-2.153 2.084-2.153zm6.801.015c.68 0 1.202.465 1.197 1.548v2.642H21.1V8.29c0-.312-.002-.98-.63-.98s-.628.667-.628.838v2.524h-.89V8.148c0-.17-.001-.838-.63-.838-.628 0-.628.668-.628.98v2.383h-.917v-4.03h.917V7a1.22 1.22 0 01.947-.516c.398 0 .76.193.982.686a1.321 1.321 0 011.195-.686zm-18.093.872l1.457-1.772H5.32L3.311 8.07l2.14 2.602H4.24L2.725 8.796 1.21 10.672H0L2.138 8.07.13 5.583h1.138l1.458 1.772zm4.149 3.317h-.916V6.644h.916v4.028zm16.99 0h-.916V6.644h.916v4.028zM9.925 8.71c-1.055 0-1.359.412-1.326.742.032.329.324.537.757.537a1.013 1.013 0 001.014-.968l.002-.31h-.447zM14.018 7.3c-.663 0-1.184.487-1.184 1.32 0 .832.52 1.32 1.184 1.32.662 0 1.182-.49 1.182-1.32 0-.832-.52-1.32-1.182-1.32zM6.417 5.001a.568.568 0 01.587.582.588.588 0 01-1.175 0A.57.57 0 016.417 5zm16.991 0a.57.57 0 01.592.582.588.588 0 01-1.174 0 .57.57 0 01.357-.542.572.572 0 01.225-.04z"></path></svg>
|
||||
|
After Width: | Height: | Size: 2.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>Zhipu</title><path d="M11.991 23.503a.24.24 0 00-.244.248.24.24 0 00.244.249.24.24 0 00.245-.249.24.24 0 00-.22-.247l-.025-.001zM9.671 5.365a1.697 1.697 0 011.099 2.132l-.071.172-.016.04-.018.054c-.07.16-.104.32-.104.498-.035.71.47 1.279 1.186 1.314h.366c1.309.053 2.338 1.173 2.286 2.523-.052 1.332-1.152 2.38-2.478 2.327h-.174c-.715.018-1.274.64-1.239 1.368 0 .124.018.23.053.337.209.373.54.658.96.8.75.23 1.517-.125 1.9-.782l.018-.035c.402-.64 1.17-.96 1.92-.711.854.284 1.378 1.226 1.099 2.167a1.661 1.661 0 01-2.077 1.102 1.711 1.711 0 01-.907-.711l-.017-.035c-.2-.323-.463-.58-.851-.711l-.056-.018a1.646 1.646 0 00-1.954.746 1.66 1.66 0 01-1.065.764 1.677 1.677 0 01-1.989-1.279c-.209-.906.332-1.83 1.257-2.043a1.51 1.51 0 01.296-.035h.018c.68-.071 1.151-.622 1.116-1.333a1.307 1.307 0 00-.227-.693 2.515 2.515 0 01-.366-1.403 2.39 2.39 0 01.366-1.208c.14-.195.21-.444.227-.693.018-.71-.506-1.261-1.186-1.332l-.07-.018a1.43 1.43 0 01-.299-.07l-.05-.019a1.7 1.7 0 01-1.047-2.114 1.68 1.68 0 012.094-1.101zm-5.575 10.11c.26-.264.639-.367.994-.27.355.096.633.379.728.74.095.362-.007.748-.267 1.013-.402.41-1.053.41-1.455 0a1.062 1.062 0 010-1.482zm14.845-.294c.359-.09.738.024.992.297.254.274.344.665.237 1.025-.107.36-.396.634-.756.718-.551.128-1.1-.22-1.23-.781a1.05 1.05 0 01.757-1.26zm-.064-4.39c.314.32.49.753.49 1.206 0 .452-.176.886-.49 1.206-.315.32-.74.5-1.185.5-.444 0-.87-.18-1.184-.5a1.727 1.727 0 010-2.412 1.654 1.654 0 012.369 0zm-11.243.163c.364.484.447 1.128.218 1.691a1.665 1.665 0 01-2.188.923c-.855-.36-1.26-1.358-.907-2.228a1.68 1.68 0 011.33-1.038c.593-.08 1.183.169 1.547.652zm11.545-4.221c.368 0 .708.2.892.524.184.324.184.724 0 1.048a1.026 1.026 0 01-.892.524c-.568 0-1.03-.47-1.03-1.048 0-.579.462-1.048 1.03-1.048zm-14.358 0c.368 0 .707.2.891.524.184.324.184.724 0 1.048a1.026 1.026 0 01-.891.524c-.569 0-1.03-.47-1.03-1.048 0-.579.461-1.048 1.03-1.048zm10.031-1.475c.925 0 1.675.764 1.675 1.706s-.75 1.705-1.675 1.705-1.674-.763-1.674-1.705c0-.942.75-1.706 1.674-1.706zm-2.626-.684c.362-.082.653-.356.761-.718a1.062 1.062 0 00-.238-1.028 1.017 1.017 0 00-.996-.294c-.547.14-.881.7-.752 1.257.13.558.675.907 1.225.783zm0 16.876c.359-.087.644-.36.75-.72a1.062 1.062 0 00-.237-1.019 1.018 1.018 0 00-.985-.301 1.037 1.037 0 00-.762.717c-.108.361-.017.754.239 1.028.245.263.606.377.953.305l.043-.01zM17.19 3.5a.631.631 0 00.628-.64c0-.355-.279-.64-.628-.64a.631.631 0 00-.628.64c0 .355.28.64.628.64zm-10.38 0a.631.631 0 00.628-.64c0-.355-.28-.64-.628-.64a.631.631 0 00-.628.64c0 .355.279.64.628.64zm-5.182 7.852a.631.631 0 00-.628.64c0 .354.28.639.628.639a.63.63 0 00.627-.606l.001-.034a.62.62 0 00-.628-.64zm5.182 9.13a.631.631 0 00-.628.64c0 .355.279.64.628.64a.631.631 0 00.628-.64c0-.355-.28-.64-.628-.64zm10.38.018a.631.631 0 00-.628.64c0 .355.28.64.628.64a.631.631 0 00.628-.64c0-.355-.279-.64-.628-.64zm5.182-9.148a.631.631 0 00-.628.64c0 .354.279.639.628.639a.631.631 0 00.628-.64c0-.355-.28-.64-.628-.64zm-.384-4.992a.24.24 0 00.244-.249.24.24 0 00-.244-.249.24.24 0 00-.244.249c0 .142.122.249.244.249zM11.991.497a.24.24 0 00.245-.248A.24.24 0 0011.99 0a.24.24 0 00-.244.249c0 .133.108.236.223.247l.021.001zM2.011 6.36a.24.24 0 00.245-.249.24.24 0 00-.244-.249.24.24 0 00-.244.249.24.24 0 00.244.249zm0 11.263a.24.24 0 00-.243.248.24.24 0 00.244.249.24.24 0 00.244-.249.252.252 0 00-.244-.248zm19.995-.018a.24.24 0 00-.245.248.24.24 0 00.245.25.24.24 0 00.244-.25.252.252 0 00-.244-.248z" fill="#3859FF" fill-rule="nonzero"></path></svg>
|
||||
|
After Width: | Height: | Size: 3.5 KiB |
@@ -0,0 +1,32 @@
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { boot, STORAGE } from "./index";
|
||||
import { say } from "./kernel";
|
||||
import { toolErrorDetail, toolRefusalReason } from "../ui/cards/outcome";
|
||||
import type { Tool } from "../port/wire";
|
||||
|
||||
afterEach(() => { localStorage.setItem(STORAGE, "zh"); boot(); });
|
||||
|
||||
// The calls the host stops before they run. The model keeps the English text;
|
||||
// the person reading the card gets the reason in their own language.
|
||||
const BLOCKED = [
|
||||
"shell.mixed_mutation_verification",
|
||||
"shell.inline_interpreter_not_last",
|
||||
"batch.skipped_after_failed_change",
|
||||
"batch.check_after_stopped_call",
|
||||
];
|
||||
|
||||
describe("calls the host stops before they run", () => {
|
||||
for (const language of ["zh", "en"]) {
|
||||
it(`are worded in ${language}, and the card shows that instead of the English line`, () => {
|
||||
localStorage.setItem(STORAGE, language);
|
||||
boot();
|
||||
for (const code of BLOCKED) {
|
||||
const tool = { name: "bash", args: {}, err: "blocked: raw English wording", refusalCode: code } as unknown as Tool;
|
||||
expect(say({ code }, ""), code).not.toBe("");
|
||||
expect(toolRefusalReason(tool), code).not.toBe("");
|
||||
expect(toolErrorDetail(tool), `${code}: the English line is not drawn when the reason is`).toBe("");
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -39,6 +39,7 @@ export const EN: Record<string, string> = {
|
||||
"正在读取历史消息…": "Reading history messages…",
|
||||
"历史加载失败 · 重试": "History could not be loaded · Retry",
|
||||
"加载更早的消息": "Load earlier messages",
|
||||
"更早还有 {n} 条消息,点击加载": "{n} more messages before these — click to load",
|
||||
"正在查找较早消息…": "Searching earlier messages…",
|
||||
"部分历史尚未读取": "Some history has not been read",
|
||||
"此检查点不包含文件快照": "This checkpoint contains no file snapshots",
|
||||
@@ -283,7 +284,6 @@ export const EN: Record<string, string> = {
|
||||
"收起度量栏": "Collapse the metrics rail",
|
||||
"展开度量栏": "Show the metrics rail",
|
||||
"调整工作区栏宽度": "Resize the workspace rail",
|
||||
"调整正文字宽": "Resize the reading column",
|
||||
"调整度量栏宽度": "Resize the metrics rail",
|
||||
"从列表移除": "Remove from the list",
|
||||
"改为输入路径": "Type a path instead",
|
||||
@@ -484,6 +484,7 @@ export const EN: Record<string, string> = {
|
||||
"批准后还会发生": "Approving also",
|
||||
"并行启动 {n} 个候选": "starts {n} attempts in parallel",
|
||||
"并行启动多个候选": "starts several attempts in parallel",
|
||||
"把下面 {n} 个文件夹加入你的项目列表并标记为可信;加入后,会话才能把任务派发到这些项目。没有出现在这里的文件夹不会被加入。": "Adds these {n} folders to your project list and marks them trusted, so sessions can then dispatch tasks to them. A folder that is not listed here is not added.",
|
||||
",各自在独立的 git 工作副本里无人值守地运行:它们的工具调用按「自动审核」处理,不再逐个询问(拒绝规则仍然生效;会话为「不询问」时候选也不询问)。最后由评审选出一个写回工作区。": ", each running unattended in its own git worktree: their tool calls are handled as Auto review, without asking one by one (deny rules still apply; if this session never asks, neither do they). A judge then writes one of them back to the workspace.",
|
||||
"候选要做的任务": "The attempts' task",
|
||||
"这次批准的效力超出这一次调用本身。": "This approval covers more than this one call.",
|
||||
@@ -544,6 +545,9 @@ export const EN: Record<string, string> = {
|
||||
"标题": "Titling",
|
||||
"本会话": "This session",
|
||||
"计划": "Plan",
|
||||
"收起计划": "Collapse the plan",
|
||||
"{n} 进行中": "{n} in progress",
|
||||
"{n} 待处理": "{n} pending",
|
||||
"执行计划中": "Executing plan",
|
||||
"正在执行已批准的计划": "Executing an approved plan",
|
||||
"尚未制定": "None yet",
|
||||
@@ -573,6 +577,9 @@ export const EN: Record<string, string> = {
|
||||
|
||||
// ── 会话树 ───────────────────────────────────────────────────────
|
||||
"新会话": "New session",
|
||||
"这个会话的历史超过了安全读取上限,没有打开(文件完好,未改动)。可以只取最近的内容,另存为新会话继续。": "This session's history is over the safe read limit, so it was not opened (the file is intact and unchanged). You can carry only its most recent part over into a new session.",
|
||||
"从最近的内容继续": "Continue from the most recent part",
|
||||
"已从最近的内容另存为新会话,较早的 {n} 条没有带入;原会话文件保持不变。": "Saved the most recent part as a new session; {n} earlier messages were not carried over. The original session file is unchanged.",
|
||||
"标签数已到上限 {n},已把「{title}」从标签栏收起,对话仍在列表里。": "The open-session limit ({n}) was reached, so \"{title}\" was folded out of the tab bar. The conversation is still in the list.",
|
||||
"打开项目文件夹": "Open project folder",
|
||||
"新会话 {n}": "New session {n}",
|
||||
@@ -599,8 +606,10 @@ export const EN: Record<string, string> = {
|
||||
"读取代码、运行测试与修改文件均只在你选定的文件夹内进行。": "Reading code, running tests and editing files all happen inside the folder you choose.",
|
||||
"使用当前位置": "Stay where I am",
|
||||
"隔离": "Isolated",
|
||||
"原目录": "Local checkout",
|
||||
"隔离目录": "Worktree",
|
||||
"独立工作树": "Isolated worktree",
|
||||
"直接在已打开的项目文件夹中工作,与使用该目录的其他会话共享文件。": "Work directly in the opened project folder, sharing files with other sessions using it.",
|
||||
"在单独的 Git 工作目录中修改代码;端口、数据库等运行资源仍可能共享。": "Edit code in a separate Git working directory; ports, databases, and other runtime resources may still be shared.",
|
||||
"发送消息时创建独立工作树,基于所选分支的已提交版本,不包含未提交修改;端口、数据库等运行资源仍可能共享。": "Create an isolated worktree when you send the message, using the selected branch's committed version without uncommitted changes; ports, databases, and other runtime resources may still be shared.",
|
||||
"移除": "Remove",
|
||||
"删除": "Delete",
|
||||
"删除该会话": "Delete this session",
|
||||
@@ -703,6 +712,14 @@ export const EN: Record<string, string> = {
|
||||
"字体预览 · 中文 Aa Bb 0123": "Typeface preview · Aa Bb 0123",
|
||||
"壁纸": "Wallpaper",
|
||||
"选择图片或视频…": "Choose an image or video…",
|
||||
"音量": "Volume",
|
||||
"导入 Wallpaper Engine…": "Import Wallpaper Engine…",
|
||||
"文件夹里需要 project.json 和 scene.pkg": "The folder needs project.json and scene.pkg",
|
||||
"Wallpaper Engine 场景": "Wallpaper Engine scene",
|
||||
"未命名场景": "Untitled scene",
|
||||
"打开声音": "Unmute sound",
|
||||
"关闭声音": "Mute sound",
|
||||
"场景以主图和声音播放;粒子和着色器效果暂未再现。": "The scene plays its backdrop and sound; particles and shader effects are not reproduced yet.",
|
||||
"换一张…": "Choose another…",
|
||||
"浓度": "Strength",
|
||||
"压暗": "Dim",
|
||||
@@ -743,9 +760,6 @@ export const EN: Record<string, string> = {
|
||||
"代码块、命令、差异与工具输出共用这一等宽字体,不跟随界面主题改变。":
|
||||
"Code blocks, commands, diffs and tool output share this monospace font and do not change with the interface theme.",
|
||||
"等宽字体": "Monospace font",
|
||||
"更多个性化": "More personalization",
|
||||
"已设置自定义壁纸": "Custom wallpaper set",
|
||||
"壁纸与背景位置": "Wallpaper and background position",
|
||||
"文字粗细": "Text weight",
|
||||
"视觉效果": "Visual effects",
|
||||
"窗口持续占用显卡时,改为「节能」:界面不变,只去掉磨砂玻璃、动态天空和颗粒这几层。": "If the window keeps your GPU busy, choose Reduced: the layout stays as it is, only frosted glass, the animated sky and the grain layer go.",
|
||||
@@ -776,7 +790,12 @@ export const EN: Record<string, string> = {
|
||||
"内置": "Built in",
|
||||
"第三方": "Third party",
|
||||
"{n} 个已装": "{n} installed",
|
||||
"跟随系统时会随系统主题切换;手动选择后将保持固定。": "Following the system switches with it; once you choose a theme yourself, it stays fixed.",
|
||||
"跟随系统时会随系统主题切换;浅色和深色保持固定;定时按本机时间每天切换。": "Follow the system switches with your OS; Light and Dark stay fixed; Scheduled switches daily using this device's clock.",
|
||||
"定时": "Scheduled",
|
||||
"深色开始时间": "Dark mode starts",
|
||||
"恢复浅色时间": "Light mode resumes",
|
||||
"每天按本机时间切换,可跨午夜;开始时进入深色,结束时恢复浅色。": "Switches daily in local time, including overnight: dark at the start, light at the end.",
|
||||
"开始和结束时间相同时全天使用浅色。": "Equal start and end times keep light mode all day.",
|
||||
|
||||
// ── 连接与来源 ───────────────────────────────────────────────────
|
||||
"添加模型来源": "Add a model source",
|
||||
@@ -954,7 +973,6 @@ export const EN: Record<string, string> = {
|
||||
"返回正文": "Back to the conversation",
|
||||
"完成": "Done",
|
||||
"快捷键": "Keyboard shortcuts",
|
||||
"下列按键在窗口中生效;标着 ⌘ 的地方,Windows 与 Linux 上是 Ctrl。": "These work anywhere in the window. Where a chord shows ⌘, Windows and Linux use Ctrl.",
|
||||
"在对话里查找": "Find in the conversation",
|
||||
"搜索会话": "Search conversations",
|
||||
"在提问之间移动": "Move between your questions",
|
||||
@@ -998,6 +1016,10 @@ export const EN: Record<string, string> = {
|
||||
"这些地址直连": "Reach these addresses directly",
|
||||
"测试目标": "Test target",
|
||||
"无法读取网络配置。": "Could not read the network config.",
|
||||
"正在读取网络配置…": "Loading network settings…",
|
||||
"测试已保存配置": "Test saved settings",
|
||||
"有未保存的改动;连接测试使用已保存的配置。": "There are unsaved changes. Connection tests use the saved settings.",
|
||||
"已保存配置的测试结果": "Test results for saved settings",
|
||||
|
||||
// ── 命令解释器 ───────────────────────────────────────────────────
|
||||
"当前生效": "In effect",
|
||||
@@ -1211,8 +1233,6 @@ export const EN: Record<string, string> = {
|
||||
"选择会话文件…": "Choose a session file…",
|
||||
"打开主题目录": "Open themes folder",
|
||||
"阅读": "Read",
|
||||
"设置需要一个打开的会话。请先在左栏添加一个文件夹。": "Settings need an open session. Add a folder in the sidebar first.",
|
||||
"反馈需要一个打开的会话。请先在左栏添加一个文件夹。": "Feedback needs an open session. Add a folder in the sidebar first.",
|
||||
"{host} 需要登录": "{host} asks you to sign in",
|
||||
"代理 {host} 需要登录": "The proxy {host} asks you to sign in",
|
||||
"用户名": "Username",
|
||||
@@ -1590,7 +1610,7 @@ export const EN: Record<string, string> = {
|
||||
"在文件管理器中显示": "Show in file manager",
|
||||
"文件夹已不在磁盘上": "Folder is gone",
|
||||
"删除会话:{title}": "Delete session: {title}",
|
||||
"会话在文件夹里打开,先添加一个": "A session opens in a folder; add one first",
|
||||
"没有文件夹也能开始:会话会在应用自己的目录里打开": "You can start without a folder: the session opens in the app's own directory",
|
||||
"添加文件夹": "Add folder",
|
||||
"其中 {live} 个对话正在运行,停止后才能移除": "{live} of them are running; stop them before removing",
|
||||
"正在运行,停止后才能删除": "Running; stop it before deleting",
|
||||
@@ -1681,6 +1701,10 @@ export const EN: Record<string, string> = {
|
||||
"正文和输入框都跟随窗口宽度;输入框很宽时一行不易回到行首": "Prose and the composer both follow the window; a very wide composer makes a line harder to find the start of",
|
||||
"每行长度控制在一眼能回到行首的范围": "Keeps a line short enough that your eye finds the next one",
|
||||
"正文跟随窗口宽度,宽屏上不留两侧空白": "Prose follows the window, with nothing left empty at the sides",
|
||||
"自定义比例": "Custom percentage",
|
||||
"按比例调整对话和输入框占用的区域": "Set the conversation and composer width as a percentage",
|
||||
"对话区域宽度": "Conversation width",
|
||||
"自定义比例同时调整对话和输入框;窄窗口会优先保证可用空间。": "Custom percentage resizes both the conversation and composer; narrow windows prioritize usable space.",
|
||||
"行宽只管正文:代码、命令与工具输出始终占满可用宽度。": "Line width applies to prose only — code, commands and tool output always use the width available.",
|
||||
"决策模型": "Decision models",
|
||||
"为 system_one 工具配置辅助决策后端,不会替换当前对话的主模型。": "Configure auxiliary decision backends for the system_one tool without replacing the main chat model.",
|
||||
|
||||
@@ -59,13 +59,10 @@ export const EN_COMPOSER: Record<string, string> = {
|
||||
"创建已取消,任务尚未发送": "Creation was cancelled. The task has not been sent.",
|
||||
"创建等待超时,任务和附件已保留,可查看详情后重试": "Workspace creation timed out. The draft and attachments are retained. Check the details before retrying.",
|
||||
"配置工作目录": "Configure workspace",
|
||||
"独立 worktree · {branch}": "Isolated worktree · {branch}",
|
||||
"当前目录 · {branch}": "Current folder · {branch}",
|
||||
"新建 worktree": "New worktree",
|
||||
"独立 worktree": "Isolated worktree",
|
||||
"独立工作树 · {branch}": "Isolated worktree · {branch}",
|
||||
"当前目录": "Current folder",
|
||||
"此会话已绑定独立 worktree。后续任务继续使用此目录。": "This session is bound to an isolated worktree. Future tasks will use this folder.",
|
||||
"如需创建新的 worktree,请在源目录新建会话。需继续本会话时可直接切换已有分支。": "To create another worktree, start a new session in the source folder. You can switch existing branches to continue this session.",
|
||||
"使用当前目录": "Use current folder",
|
||||
"关闭工作目录配置": "Close workspace configuration",
|
||||
"发送消息时开始创建。": " Creation starts when you send the message.",
|
||||
"选择分支会立即切换,并影响共享当前目录的其他会话。": "Switching branches takes effect immediately and affects other sessions sharing this folder.",
|
||||
@@ -78,7 +75,6 @@ export const EN_COMPOSER: Record<string, string> = {
|
||||
"选择起始分支": "Choose source branch",
|
||||
"搜索分支": "Search branches",
|
||||
"搜索分支…": "Search branches…",
|
||||
"当前目录": "Current folder",
|
||||
"查看完整路径": "Show full path",
|
||||
"复制完整路径": "Copy full path",
|
||||
"复制操作编号": "Copy operation ID",
|
||||
@@ -108,10 +104,16 @@ export const EN_COMPOSER: Record<string, string> = {
|
||||
"停下": "Stop",
|
||||
"发送": "Send",
|
||||
"插话": "Steer",
|
||||
"排队发送": "Queue",
|
||||
"点按排队 · 回车换行": "Tap Queue · Enter for a new line",
|
||||
"{key} 排队 · Enter 换行": "{key} to queue · Enter for a new line",
|
||||
"Enter 排队 · Shift+Enter 换行": "Enter to queue · Shift+Enter for a new line",
|
||||
"正在停止…": "Stopping…",
|
||||
"正在发送…": "Sending…",
|
||||
"描述任务、问题或要改的内容…": "Describe a task, question, or change…",
|
||||
"描述任务…": "Describe a task…",
|
||||
"继续输入以排队后续修改": "Keep typing to queue further changes",
|
||||
"继续输入…": "Keep typing…",
|
||||
"任务输入": "Task input",
|
||||
"命令与技能": "Commands and skills",
|
||||
"正在加载命令与技能…": "Loading commands and skills…",
|
||||
|
||||
@@ -13,6 +13,10 @@ export const EN_KERNEL: Record<string, string> = {
|
||||
"该路径指向网络上的另一台机器,不在当前工作区内。文件工具只按路径写法拒绝它,不会去访问;工作区本身在网络共享上时,其下的路径可用": "That path names another machine on the network and is outside this workspace. File tools refuse it by its spelling and never look it up; paths below a workspace that is itself on a network share are allowed",
|
||||
"内置浏览器不会打开指向网络上另一台机器的路径,只按路径写法拒绝,不会去访问": "The built-in browser does not open a path that names another machine on the network; it is refused by its spelling and never looked up",
|
||||
"无法读取目标文件:它在你配置的禁读列表里。": "Cannot read the target file: it is on the forbidden-read list you configured.",
|
||||
"这条命令把一个宿主无法确认不改动工作区的步骤和验证检查写在了一起,已拦截,没有运行。请把它们拆成两次调用:先改动,再单独验证。": "This command puts a step the host cannot confirm leaves the workspace alone together with a verification check, so it was blocked and did not run. Split it into two calls: change first, then verify on its own.",
|
||||
"这条命令里的内联解释器不是最后一段,后面的步骤会决定整条命令的退出状态,解释器里的失败会被当成成功,已拦截,没有运行。请把它放在最后,或单独运行。": "The inline interpreter in this command is not its last segment, so later steps decide the exit status and a failure inside the interpreter would read as success. It was blocked and did not run. Put it last, or run it on its own.",
|
||||
"同一批调用里前面的修改失败或被拦截了,所以这一步没有执行。请先处理那处修改,再重试。": "An earlier change in the same batch of calls failed or was blocked, so this step did not run. Deal with that change first, then retry.",
|
||||
"同一批调用里前面有一次调用在运行前就被停止了,这次检查测不出你要的状态,所以没有执行。请先重跑被停止的调用,或单独运行这次检查。": "An earlier call in the same batch was stopped before it ran, so this check would not measure the state you asked for and did not run. Re-run the stopped call first, or run this check on its own.",
|
||||
"无法读取目标文件:它不在这个会话允许读取的文件夹里。": "Cannot read the target file: it is outside the folders this session may read.",
|
||||
"读视频需要本机装有 ffmpeg(含 ffprobe)并能在系统路径里找到。装好后再试一次。": "Reading a video needs ffmpeg (with ffprobe) installed and on the system path. Install it and try again.",
|
||||
"不认识这种视频格式。支持 MP4/MOV、MKV/WebM、AVI、FLV、ASF、MPEG 和 Ogg 这些常见封装。": "This video format is not recognised. MP4/MOV, MKV/WebM, AVI, FLV, ASF, MPEG and Ogg containers are supported.",
|
||||
@@ -50,6 +54,11 @@ export const EN_KERNEL: Record<string, string> = {
|
||||
|
||||
"参数不符合该工具的约定,本次调用没有执行;请按工具说明补全或更正参数后重试": "The arguments break this tool's contract, so the call did not run; complete or correct them per the tool description and retry",
|
||||
"另一个会话持有所需的写入范围。本次操作未执行;请结束当前轮次,待该范围释放后再重试": "Another session holds the requested write claim. This operation did not run; end the current turn and retry after the claim is released",
|
||||
"找不到该隔离结果,它可能已被应用或丢弃。未执行任何变更": "The isolated result no longer exists; it may have been applied or discarded. Nothing was changed",
|
||||
"本会话未启用工作树隔离([agent] worktree_isolation),本次任务未开始": "Worktree isolation is not enabled in this session ([agent] worktree_isolation), so the task did not start",
|
||||
"工作树隔离需要一个已有提交的 Git 工作区,本次任务未开始": "Worktree isolation needs a Git workspace with a commit, so the task did not start",
|
||||
"工作区在任务开始后改动了相同文件,未应用任何内容。请协调后重新应用或丢弃该结果": "The workspace changed the same files after the task began, so nothing was applied. Reconcile, then apply again or discard the result",
|
||||
"此次隔离任务包含不支持的参数,未开始执行": "This isolated task combines arguments that are not supported, so it did not start",
|
||||
"搜索模型「{model}」的格式应为 服务/模型": "Search model “{model}” must be written as service/model",
|
||||
"搜索模型「{model}」所在的连接还没有添加:先添加这个连接,再选它": "Search model “{model}”'s connection has not been added yet: add that connection first, then pick it",
|
||||
"搜索模型「{model}」已不在配置里": "Search model “{model}” is no longer configured",
|
||||
@@ -270,6 +279,11 @@ export const EN_KERNEL: Record<string, string> = {
|
||||
"不支持该格式,请改用图片(PNG、JPEG、WebP、AVIF、GIF)或视频(MP4、WebM)":
|
||||
"That format is not supported; use an image (PNG, JPEG, WebP, AVIF, GIF) or a video (MP4, WebM)",
|
||||
"图片内容为空": "The image is empty",
|
||||
"场景包过大,请压缩至 {limit} MB 以内": "That scene package is too large — bring it under {limit} MB",
|
||||
"无法读取这个 Wallpaper Engine 场景": "This Wallpaper Engine scene could not be read",
|
||||
"只支持 Scene 类型的 Wallpaper Engine 壁纸": "Only Scene wallpapers from Wallpaper Engine are supported",
|
||||
"这个场景里没有可用的主图": "This scene has no backdrop to show",
|
||||
"不支持该音频格式": "That audio format is not supported",
|
||||
"压缩阈值未能保存:{detail}": "The compaction threshold was not saved: {detail}",
|
||||
"本次请求未包含阈值,未做任何修改": "The request carried no threshold, so nothing changed",
|
||||
"{host} 上的内核没有应答,连接可能断了": "The kernel on {host} is not answering — the connection may be down",
|
||||
@@ -467,6 +481,8 @@ export const EN_KERNEL: Record<string, string> = {
|
||||
"打不开这个会话": "Could not open that conversation",
|
||||
"该路径位于会话目录之外": "That path is outside the conversations directory",
|
||||
"没有这个会话": "No such conversation",
|
||||
"这个会话的历史超过了安全读取上限,没有打开;文件完好未改动": "This conversation's history is over the safe read limit, so it was not opened; the file is intact and unchanged",
|
||||
"没能取出这个会话最近的内容": "Could not take the most recent part of this conversation",
|
||||
"该地址不接受此种请求方式": "That address does not accept this request method",
|
||||
"请求体必须是 application/json": "The request body must be application/json",
|
||||
"这台服务器未开放权限编辑": "This server does not open permission editing",
|
||||
|
||||
@@ -2,6 +2,23 @@
|
||||
// alone — one catalogue is still the model, and this file is read as its
|
||||
// continuation: same keying by Chinese source text, same grouping by screen.
|
||||
export const EN_SETTINGS: Record<string, string> = {
|
||||
"全局显示 / 隐藏快捷键": "Global show / hide shortcut",
|
||||
"默认关闭。仅在 Studio 运行时生效;窗口聚焦时隐藏,否则显示并聚焦。不会启动已退出的应用,也不更改窗口内快捷键。": "Off by default. While Studio is running, hide its focused window or show and focus it otherwise. Does not launch a stopped app or change in-app shortcuts.",
|
||||
"快捷键组合": "Shortcut binding",
|
||||
"点击录制后按下修饰键与按键组合,或直接编辑快捷键。Esc 取消录制;清除会立即关闭快捷键。Wayland 或系统保留组合可能不受支持。": "Click Record and press a modifier and key combination, or edit the shortcut directly. Esc cancels recording; Clear immediately disables the shortcut. Wayland or OS-reserved bindings may not be supported.",
|
||||
"请按修饰键与按键组合…": "Press a modifier and key combination…",
|
||||
"录制快捷键": "Record shortcut",
|
||||
"应用快捷键": "Apply shortcut",
|
||||
"清除快捷键": "Clear shortcut",
|
||||
"全局快捷键已关闭": "Global shortcut disabled",
|
||||
"已注册:{value}": "Registered: {value}",
|
||||
"快捷键格式无效,请使用修饰键与按键组合,例如 CommandOrControl+Shift+Space。": "Invalid accelerator. Use a modifier and key combination, e.g. CommandOrControl+Shift+Space.",
|
||||
"系统未能注册此快捷键:可能已被占用、未获授权,或当前桌面不支持。": "The OS could not register this shortcut: it may be occupied, denied permission, or unsupported by this desktop.",
|
||||
"当前环境不支持全局快捷键。": "Global shortcuts are not supported in this environment.",
|
||||
"快捷键未能保存;原设置保持不变。": "Could not save the shortcut; the previous setting is unchanged.",
|
||||
"Studio 正在退出,未更改快捷键。": "Studio is quitting; the shortcut was not changed.",
|
||||
"无法读取或更改 shell 快捷键状态。请重新打开设置后再试。": "Could not read or change shell shortcut status. Reopen settings to try again.",
|
||||
"下列按键在窗口中生效;标着 ⌘ 的地方,Windows 与 Linux 上是 Ctrl。全局显示 / 隐藏快捷键在上方单独配置,仅桌面 shell 支持。": "These keys work inside the window; ⌘ means Ctrl on Windows and Linux. Configure global show / hide separately above, in the desktop shell only.",
|
||||
"加载中…": "Loading…",
|
||||
"AI 文件交付卡片": "AI file delivery cards",
|
||||
"启用 AI 文件交付卡片": "Enable AI file delivery cards",
|
||||
@@ -85,6 +102,11 @@ export const EN_SETTINGS: Record<string, string> = {
|
||||
"开启(15 分钟后)": "On (after 15 minutes)",
|
||||
"保持打开": "Keep open",
|
||||
"发送快捷键": "Send shortcut",
|
||||
"运行中发送消息": "Messages sent while running",
|
||||
"选择运行中发送消息的默认方式。仅保存在此设备,不改变已排队的消息。": "Choose the default for messages sent during a run. Saved on this device only; messages already queued are unchanged.",
|
||||
"下个工具边界插话": "Steer at the next tool boundary",
|
||||
"整轮结束后发送": "Send after the whole run",
|
||||
"可在待发送队列中选择“立即发送”来停止当前回复并开始新一轮。暂停、编辑和取回仍然可用。": "Use Send now in the queue to stop the current response and start a new turn. Hold, edit, and take back remain available.",
|
||||
"用于任务输入、待发送消息编辑与历史消息重发。输入法确认候选词时不会发送。": "Applies to task input, editing queued messages, and resending past messages. Confirming an input-method candidate never sends.",
|
||||
"Enter 发送": "Enter to send",
|
||||
"{key} 发送": "{key} to send",
|
||||
@@ -127,6 +149,7 @@ export const EN_SETTINGS: Record<string, string> = {
|
||||
"项目配置覆盖了这一项;此处保存的是全局设置。": "The project config overrides this; what you set here is saved globally.",
|
||||
"尚无可用搜索模型": "No search models available",
|
||||
"{model}(不可用)": "{model} (unavailable)",
|
||||
"{model}(未声明读图)": "{model} (not declared to read images)",
|
||||
"模型用途": "Model uses",
|
||||
"用途": "Use",
|
||||
"使用模型": "Model",
|
||||
@@ -167,7 +190,6 @@ export const EN_SETTINGS: Record<string, string> = {
|
||||
"搜索设置…": "Search settings…",
|
||||
"搜索设置": "Search settings",
|
||||
"没有匹配的设置": "No matching setting",
|
||||
"设置项:{n}": "Settings: {n}",
|
||||
"立即生效": "In effect now",
|
||||
"当前会话": "This session",
|
||||
"当前模型": "This model",
|
||||
@@ -756,6 +778,8 @@ export const EN_SETTINGS: Record<string, string> = {
|
||||
"摘要请求用了 {in} 输入、{out} 输出 token,缓存命中 {hit}。": "The summary request used {in} input and {out} output tokens, {hit} from cache.",
|
||||
"无需压缩:{why}": "Nothing to compact: {why}",
|
||||
"压缩失败:{why}": "Compaction failed: {why}",
|
||||
"你有 {n} 条较早的消息过长,没能完整保留,现在只通过摘要保留。在消息开头加上 [[keep]] 可以让它原样保留。": "{n} earlier message(s) of yours were too large to keep whole and now survive only through the summary. Prefix a message with [[keep]] to hold it verbatim.",
|
||||
"你用 [[keep]] 标记的消息中,有 {n} 条超出了 [[keep]] 的容量,最早的几条现在只通过摘要保留。": "{n} message(s) you marked [[keep]] went past the room [[keep]] has, so the oldest now survive only through the summary.",
|
||||
"子代理「{task}」在等 {by} 先结束:{why}": "Sub-agent \"{task}\" is waiting for {by} to finish: {why}",
|
||||
"另一个子代理": "another sub-agent",
|
||||
"两者都没声明 write_paths,各自按整个工作区处理": "neither declared write_paths, so each is treated as writing the whole workspace",
|
||||
@@ -1003,6 +1027,16 @@ export const EN_SETTINGS: Record<string, string> = {
|
||||
"关闭窗口后在托盘中继续运行": "Keep running in the tray when the window is closed",
|
||||
"关闭窗口不会中断会话和后台任务;从托盘菜单退出才会完全关闭程序": "Closing the window does not stop sessions or background tasks; use Quit in the tray menu to exit completely",
|
||||
"本轮执行": "This turn",
|
||||
"批准与规则": "Approval and rules",
|
||||
"沙箱与执行": "Sandbox and execution",
|
||||
"联网与浏览器": "Web and browser",
|
||||
"子代理与协作": "Sub-agents and collaboration",
|
||||
"外观与主题": "Appearance and theme",
|
||||
"会话显示": "Conversation display",
|
||||
"导航与侧栏": "Navigation and sidebar",
|
||||
"窗口与性能": "Window and performance",
|
||||
"位于项目文件夹内": "Inside a project folder",
|
||||
"位于项目文件夹 {path} 内,出于安全不从这里启动": "Inside the project folder {path}, so it is not started from here for safety",
|
||||
"运行环境": "Where it runs",
|
||||
"记录与用量": "What it remembers",
|
||||
"这台机器": "This machine",
|
||||
@@ -1090,6 +1124,7 @@ export const EN_SETTINGS: Record<string, string> = {
|
||||
"单轮生成上限;留空使用内核的模型默认值。": "The per-turn generation limit; leave blank to use the runtime's model default.",
|
||||
"高级连接选项": "Advanced connection options",
|
||||
"从常用服务开始": "Start from a common service",
|
||||
"按厂商筛选": "Filter by vendor",
|
||||
"留空就用预设自带的名称;重名时内核会自动编号。": "Leave empty to use the preset's own name; the kernel numbers a duplicate.",
|
||||
"模型目录、上下文窗口和思考设置由内核按这个服务的预设一并创建。": "The kernel creates the model catalogue, context window and thinking settings for this service from its preset.",
|
||||
"没有匹配的服务": "No matching service",
|
||||
@@ -1121,6 +1156,11 @@ export const EN_SETTINGS: Record<string, string> = {
|
||||
"思考自动": "Thinking automatic",
|
||||
"会话折叠": "Transcript folding",
|
||||
"会话中各部分默认展开还是收起。失败的步骤与正在运行的操作可以单独处理;已手动展开或收起的块保持原样。": "Whether each part of a conversation starts open or folded. Failed steps and work still running can be handled on their own; a block you have already opened or closed stays as it is.",
|
||||
"收起时显示计划进度": "Show collapsed plan progress",
|
||||
"在收起的计划卡片下方显示进度条,随已完成步骤更新。默认关闭,仅保存在本机。": "Show a progress bar below the collapsed plan card, updated as steps complete. Off by default; saved only on this machine.",
|
||||
"输入框提示": "Composer hint",
|
||||
"在空白任务输入框中显示任务和排队提示。默认显示,仅保存在本机,不影响输入和发送。": "Show task and queue guidance in the empty composer. On by default; saved only on this machine. Does not affect typing or sending.",
|
||||
"计划进度": "Plan progress",
|
||||
"折叠预设": "Folding preset",
|
||||
"精简": "Compact",
|
||||
"详尽": "Detailed",
|
||||
@@ -1133,7 +1173,9 @@ export const EN_SETTINGS: Record<string, string> = {
|
||||
"超出一屏的命令输出与文件内容": "Command output and file contents longer than a screen",
|
||||
"压缩简报": "Compaction brief",
|
||||
"上下文压缩后,模型接下来依据的那份简报": "The brief the model works from after context is compacted",
|
||||
"收起并预览首句": "Folded, with first sentence",
|
||||
"收起时预览思考开头": "Preview the beginning when collapsed",
|
||||
"在思考标题下方显示一行开头文字;思考进行中也显示,展开后隐藏。": "Show one line of opening text below the thinking header, including while thinking is in progress. Hidden when expanded.",
|
||||
"先检查配置,再决定改哪里。": "Check the configuration first, then decide what to change.",
|
||||
"进行中展开": "Open while running",
|
||||
"失败时展开": "Open on failure",
|
||||
"改动时展开": "Open on file change",
|
||||
@@ -1349,6 +1391,10 @@ export const EN_SETTINGS: Record<string, string> = {
|
||||
"图标栏": "Icon rail",
|
||||
"导航入口": "Navigation entries",
|
||||
"放大设置窗口": "Enlarge the settings window",
|
||||
"隐藏共同前缀": "Hide the shared prefix",
|
||||
"{name} 的模型名前缀": "Model name prefix of {name}",
|
||||
"路由类服务给每个模型加的同一段前缀(如 group/)在显示时省略,只改显示;模型 ID 不变,自己填的别名优先,保存后生效。": "The same prefix a routing service puts on every model (such as group/) is left out of how they are drawn. Display only: model ids do not change, an alias you typed wins, and it takes effect on save.",
|
||||
"缩小设置窗口": "Shrink the settings window",
|
||||
"图标栏和侧栏里直达各页面的入口:可以隐藏,也可以调整先后。会话、反馈、账号和设置始终保留。": "The shortcuts to pages in the icon rail and the sidebar: hide any of them or change their order. Sessions, feedback, account and settings are always kept.",
|
||||
"还原排列": "Reset arrangement",
|
||||
"上移:{name}": "Move up: {name}",
|
||||
|
||||
@@ -7,6 +7,7 @@ export const EN_TOOL_BLURBS: Record<string, string> = {
|
||||
"用起止文本锚点删除文件里的一整段内容,适合大块删除。": "Delete a contiguous range of a file between two text anchors, for large removals.",
|
||||
"按名字从 Go 源文件里删除一个函数、方法或类型等符号。": "Delete a named function, method, type or other symbol from a Go source file.",
|
||||
"把任务交给你另一个项目里的会话:指定项目并写明要做什么;对方没有等待中的会话时会在那里新开一个。": "Hand a task to a session in another of your projects: name the project and say what to do; a session is opened there if none is waiting.",
|
||||
"请你把一批文件夹加入项目列表:卡片逐条列出路径,你批准后才会加入并标记为可信,之后才能向它们派发任务。": "Ask you to add a batch of folders to your project list: the card lists every path, and they are added and marked trusted only once you approve, so tasks can then be dispatched to them.",
|
||||
"搜索并阅读内置在本版本里的官方文档。": "Search and read the official documentation built into this version.",
|
||||
"在隔离的子代理里做只读的代码库调查,只返回提炼后的结论。": "Investigate the codebase read-only in an isolated sub-agent and return only a distilled answer.",
|
||||
"把 2–64 个子代理任务按依赖关系一起派出,返回摘要和取回完整结果的引用。": "Dispatch 2–64 sub-agent tasks as a dependency graph and return previews plus references for the full results.",
|
||||
@@ -46,4 +47,9 @@ export const EN_TOOL_BLURBS: Record<string, string> = {
|
||||
"派出一个子代理处理一项专项子任务,只返回它的最终答案。": "Start a sub-agent for a focused sub-task and return only its final answer.",
|
||||
"获取一个网址并返回文本内容;HTML 会被精简为可读文本。": "Fetch a URL and return its text; HTML is reduced to readable text.",
|
||||
"搜索网页获取最新信息,返回摘要和来源。": "Search the web for current information and return a summary with sources.",
|
||||
"向更强的模型求助:它读完到目前为止的对话后,用方案、诊断或审查回答你的问题;它自己不执行任何东西。": "Ask a stronger model for advice: it reads the conversation so far and answers with a plan, a diagnosis or a review; it runs nothing itself.",
|
||||
"把一个明确的编码任务并行做 2–4 遍,每遍在独立的 git 工作树里;再由评审比较,只把最好的一份写回工作区。": "Run one well-specified coding task 2–4 times in parallel, each in its own git worktree, then have a judge compare them and write only the best back into the workspace.",
|
||||
"把已有的最终文件以可点击的卡片展示给你,包括命令行生成的文档;路径须在工作区或已授权的文件夹内,打开的是当前文件而不是快照,也不保证内容正确。": "Present existing final files to you as clickable cards, including shell-created documents; paths must be in the workspace or authorized folders, it opens the current files rather than snapshots, and it does not certify correctness.",
|
||||
"读取已连接的 MCP 服务器自己列出的一份资源(文档、记录、文件);服务器没列出的地址会被拒绝,返回的是服务器的数据,不是你的指令。": "Read one resource (a document, a record, a file) that a connected MCP server lists itself; an address the server does not list is refused, and what comes back is the server's data, not your instructions.",
|
||||
"运行一小段 Starlark(类 Python)脚本来调用多个工具,几步相互依赖的操作只需一次往返,只返回脚本打印的内容;每次调用都和模型直接调用一样受检查和审批。": "Run a short Starlark (Python-like) script that calls several tools, so dependent steps take one round trip and only what the script prints comes back; every call is checked and approved as if the model made it directly.",
|
||||
};
|
||||
@@ -31,6 +31,14 @@ export const EN_USAGE: Record<string, string> = {
|
||||
"美元 USD": "US dollar (USD)",
|
||||
"无法读取币种设置。": "Could not read the currency setting.",
|
||||
"用量与成本": "Usage & cost",
|
||||
"上传量": "Upload volume",
|
||||
"发给模型服务商的请求内容大小(不含连接本身的开销,也不含下载)。每次请求都会带上完整的对话,所以它取决于上下文有多长、请求有多少次,缓存命中只省费用、不省流量。":
|
||||
"The size of the request bodies sent to model providers (without the connection's own overhead, and without downloads). Every request carries the whole conversation, so it depends on how long the context is and how many requests are made; cache hits save cost, not bandwidth.",
|
||||
"还没有记录:从这个版本起开始统计。": "Nothing recorded yet: counting starts with this version.",
|
||||
"今天上传": "Uploaded today",
|
||||
"近 {n} 天上传": "Uploaded in the last {n} days",
|
||||
"{size},{n} 次请求,平均每次 {avg}": "{size} in {n} requests, {avg} each on average",
|
||||
"{size}({share})": "{size} ({share})",
|
||||
"本机记录的 token 用量与花费,仅保存在这台机器上,不会上传。命中缓存的输入按缓存价计费,因此命中率直接影响费用。":
|
||||
"Token usage and cost recorded on this machine only; nothing is uploaded. Input that hits the cache is billed at the cache rate, so the hit rate drives the bill.",
|
||||
"正在读取记录…": "Reading the records…",
|
||||
@@ -62,6 +70,7 @@ export const EN_USAGE: Record<string, string> = {
|
||||
"花费": "Spend",
|
||||
"含估算": "includes estimates",
|
||||
"未定价调用未计入": "unpriced calls not included",
|
||||
"其中 {n} 笔未定价": "{n} calls unpriced",
|
||||
"{day} 起 · {n} 天有记录": "from {day} · {n} days recorded",
|
||||
"这段时间 {tok} tokens 里有 {rate}% 命中前缀缓存 —— 命中的部分按缓存价计费,比未命中便宜一个量级。": "{rate}% of these {tok} tokens hit the prefix cache — a hit bills at the cache rate, an order of magnitude under a miss.",
|
||||
"命中 {a} · 未命中 {b}": "{a} hit · {b} missed",
|
||||
|
||||
@@ -6,6 +6,13 @@ export const EN_WINDOW: Record<string, string> = {
|
||||
"打开项目": "Open project",
|
||||
"项目列表的更多操作": "More project list actions",
|
||||
"项目与会话": "Projects and conversations",
|
||||
"最近的会话": "Recent sessions",
|
||||
"按最近活动排列,包含已完成的会话。选择后定位到所属项目。": "Ordered by last activity, including finished sessions. Select one to locate its project.",
|
||||
"搜索最近会话 / 项目": "Search recent sessions or projects",
|
||||
"此运行环境不支持最近会话,请更新 Reasonix": "This environment does not support recent sessions. Update Reasonix.",
|
||||
"正在读取会话…": "Reading sessions…",
|
||||
"最近": "Recent",
|
||||
"第 {n} 页": "Page {n}",
|
||||
"选择本机文件夹": "Choose a local folder",
|
||||
"打开第一个项目": "Open your first project",
|
||||
"展开所有项目": "Expand all projects",
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { boot, STORAGE } from "./index";
|
||||
import { say } from "./kernel";
|
||||
|
||||
afterEach(() => { localStorage.setItem(STORAGE, "zh"); boot(); });
|
||||
|
||||
// The refusals internal/runtime/isolation and the isolated task tool emit; a
|
||||
// tool card renders say(code), so an unworded one shows no reason at all.
|
||||
const ISOLATION_REFUSALS = ["isolation.unknown_id", "isolation.unavailable", "isolation.not_git", "isolation.apply_conflict", "isolation.unsupported_arg"];
|
||||
|
||||
describe("isolation refusals", () => {
|
||||
for (const language of ["zh", "en"]) {
|
||||
it(`are worded in ${language}`, () => {
|
||||
localStorage.setItem(STORAGE, language);
|
||||
boot();
|
||||
for (const code of ISOLATION_REFUSALS) expect(say({ code }, ""), code).not.toBe("");
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -65,6 +65,11 @@ const SAID: Record<string, string> = {
|
||||
"shell.command_line_too_long": "命令超过主机长度限制;请拆分命令或从文件读取长文本",
|
||||
"tool.arguments_invalid": "参数不符合该工具的约定,本次调用没有执行;请按工具说明补全或更正参数后重试",
|
||||
"workspace.write_conflict": "另一个会话持有所需的写入范围。本次操作未执行;请结束当前轮次,待该范围释放后再重试",
|
||||
"isolation.unknown_id": "找不到该隔离结果,它可能已被应用或丢弃。未执行任何变更",
|
||||
"isolation.unavailable": "本会话未启用工作树隔离([agent] worktree_isolation),本次任务未开始",
|
||||
"isolation.not_git": "工作树隔离需要一个已有提交的 Git 工作区,本次任务未开始",
|
||||
"isolation.apply_conflict": "工作区在任务开始后改动了相同文件,未应用任何内容。请协调后重新应用或丢弃该结果",
|
||||
"isolation.unsupported_arg": "此次隔离任务包含不支持的参数,未开始执行",
|
||||
"session.archive_invalid": "会话文件无效,请选择完整的原生会话导出文件。",
|
||||
"session.archive_unsupported": "不支持此会话文件版本,请使用兼容版本导出。",
|
||||
"session.archive_too_large": "会话文件超过安全导入限制(大小或记录数)。",
|
||||
@@ -118,6 +123,10 @@ const SAID: Record<string, string> = {
|
||||
"workspace.root_not_granted": "这个文件夹没有授权给当前工作区",
|
||||
"workspace.target_access_unconfigured": "未配置文件访问策略,无法执行此文件操作。",
|
||||
"workspace.read_forbidden": "无法读取目标文件:它在你配置的禁读列表里。",
|
||||
"shell.mixed_mutation_verification": "这条命令把一个宿主无法确认不改动工作区的步骤和验证检查写在了一起,已拦截,没有运行。请把它们拆成两次调用:先改动,再单独验证。",
|
||||
"shell.inline_interpreter_not_last": "这条命令里的内联解释器不是最后一段,后面的步骤会决定整条命令的退出状态,解释器里的失败会被当成成功,已拦截,没有运行。请把它放在最后,或单独运行。",
|
||||
"batch.skipped_after_failed_change": "同一批调用里前面的修改失败或被拦截了,所以这一步没有执行。请先处理那处修改,再重试。",
|
||||
"batch.check_after_stopped_call": "同一批调用里前面有一次调用在运行前就被停止了,这次检查测不出你要的状态,所以没有执行。请先重跑被停止的调用,或单独运行这次检查。",
|
||||
"workspace.read_outside_scope": "无法读取目标文件:它不在这个会话允许读取的文件夹里。",
|
||||
"video.ffmpeg_missing": "读视频需要本机装有 ffmpeg(含 ffprobe)并能在系统路径里找到。装好后再试一次。",
|
||||
"video.unsupported_container": "不认识这种视频格式。支持 MP4/MOV、MKV/WebM、AVI、FLV、ASF、MPEG 和 Ogg 这些常见封装。",
|
||||
@@ -165,6 +174,8 @@ const SAID: Record<string, string> = {
|
||||
"session.open_failed": "打不开这个会话",
|
||||
"session.outside_dir": "该路径位于会话目录之外",
|
||||
"session.unknown": "没有这个会话",
|
||||
"session.replay_limit": "这个会话的历史超过了安全读取上限,没有打开;文件完好未改动",
|
||||
"session.tail_failed": "没能取出这个会话最近的内容",
|
||||
"request.method_not_allowed": "该地址不接受此种请求方式",
|
||||
"request.bad_content_type": "请求体必须是 application/json",
|
||||
"permissions.editing_disabled": "这台服务器未开放权限编辑",
|
||||
@@ -448,6 +459,11 @@ const SAID: Record<string, string> = {
|
||||
// ── 壁纸 ─────────────────────────────────────────────────────────
|
||||
"wallpaper.unsupported_type": "不支持该格式,请改用图片(PNG、JPEG、WebP、AVIF、GIF)或视频(MP4、WebM)",
|
||||
"wallpaper.empty": "图片内容为空",
|
||||
"wallpaper.scene_too_large": "场景包过大,请压缩至 {limit} MB 以内",
|
||||
"wallpaper.scene_invalid": "无法读取这个 Wallpaper Engine 场景",
|
||||
"wallpaper.scene_unsupported": "只支持 Scene 类型的 Wallpaper Engine 壁纸",
|
||||
"wallpaper.scene_no_image": "这个场景里没有可用的主图",
|
||||
"wallpaper.audio_unsupported": "不支持该音频格式",
|
||||
|
||||
// ── 来不及了 ──────────────────────────────────────
|
||||
"steer.already_applied": "该条已发送给模型,无法撤回",
|
||||
|
||||
@@ -17,6 +17,8 @@ const PAYLOAD: Record<string, string | undefined> = {
|
||||
compaction_telemetry: JSON.stringify({ mode: "summarized", source_tokens: 122525, projection_tokens: 61192, input_tokens: 40710, output_tokens: 8186, cache_hit_tokens: 256, request_count: 1 }),
|
||||
context_composition: JSON.stringify({ total: 561061, at: 419430, active: 400000, parts: [{ name: "tool_output", tokens: 500000 }] }),
|
||||
subagent_waiting: JSON.stringify({ task: "fix the typo", by: "rewrite the docs", whole: true }),
|
||||
user_turns_dropped: JSON.stringify({ count: 5, tokens: 1578, budget: 8000 }),
|
||||
kept_turns_folded: JSON.stringify({ count: 2, tokens: 900, budget: 4000 }),
|
||||
display_currency: "USD",
|
||||
compact_declined: undefined,
|
||||
compact_failed: "summary_failed",
|
||||
@@ -70,6 +72,8 @@ describe("notice wording", () => {
|
||||
["job_stalled", '{"id":"j","quietSeconds":"900"}'],
|
||||
["job_stalled", '{"id":"j","quietSeconds":0}'],
|
||||
["compaction_telemetry", '{"mode":"summarized"}'],
|
||||
["user_turns_dropped", '{"count":0}'],
|
||||
["kept_turns_folded", "not json"],
|
||||
["compaction_telemetry", '{"mode":"","source_tokens":1,"projection_tokens":1}'],
|
||||
["compaction_telemetry", '{"mode":"summarized","source_tokens":"122525","projection_tokens":1}'],
|
||||
["compact_failed", "no_such_reason"],
|
||||
|
||||
@@ -7,6 +7,7 @@ import { EXTENSION_SKIPPED, extensionSkippedVars } from "./extension_skipped";
|
||||
import { INBOX_RECOVERED, inboxRecoveredVars } from "./inbox_recovered";
|
||||
import { JOB_FAILED, JOB_FINISHED, JOB_STARTED, JOB_KILLED, JOB_STALLED, jobErrorText, jobNoticeVars, jobStalledVars } from "./job_notice";
|
||||
import { NOTICE_TEXT } from "./notices";
|
||||
import { KEPT_TURNS_FOLDED, USER_TURNS_DROPPED, turnsDroppedVars } from "./turns_dropped";
|
||||
import { PERMISSION_RULES_DORMANT, permissionRulesDormantVars } from "./permission_rules_dormant";
|
||||
|
||||
export type NoticeVars = Record<string, string | number>;
|
||||
@@ -30,6 +31,8 @@ const VARS: Record<string, (detail: string | undefined) => NoticeVars | undefine
|
||||
[COMPACTION_TELEMETRY]: compactionNoticeVars,
|
||||
[CONTEXT_COMPOSITION]: contextCompositionVars,
|
||||
[SUBAGENT_WAITING]: subagentWaitingVars,
|
||||
[USER_TURNS_DROPPED]: turnsDroppedVars,
|
||||
[KEPT_TURNS_FOLDED]: turnsDroppedVars,
|
||||
display_currency: (detail) => ({ mode: detail || "auto" }),
|
||||
compact_declined: compactReason("compact_declined"),
|
||||
compact_failed: compactReason("compact_failed"),
|
||||
|
||||
@@ -51,6 +51,8 @@ export const NOTICE_TEXT: Record<string, string> = {
|
||||
compact_declined: "无需压缩:{why}",
|
||||
compact_failed: "压缩失败:{why}",
|
||||
context_composition: "上下文约 {total},已超过压缩阈值 {at},没有更多可折叠的内容。占用最多:{top}。{active}",
|
||||
user_turns_dropped: "你有 {n} 条较早的消息过长,没能完整保留,现在只通过摘要保留。在消息开头加上 [[keep]] 可以让它原样保留。",
|
||||
kept_turns_folded: "你用 [[keep]] 标记的消息中,有 {n} 条超出了 [[keep]] 的容量,最早的几条现在只通过摘要保留。",
|
||||
subagent_waiting: "子代理「{task}」在等 {by} 先结束:{why}",
|
||||
compact_held: "自动压缩暂缓,上次尝试没有完成:{why}",
|
||||
permission_rules_dormant: "有 {n} 条权限规则没有对应的工具,匹配不到任何调用,因此起不到限制作用(如「{list}」里的 {rule});到「设置 → 权限」里删除或改写,命令要写成 Bash(命令:*)",
|
||||
|
||||