mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 10:45:59 -10:00
Compare commits
287
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
50f10f9419 | ||
|
|
68c1f9d551 | ||
|
|
5e86094c4f | ||
|
|
3a9e5e4fc1 | ||
|
|
8d869198bd | ||
|
|
35e65c2ef2 | ||
|
|
980b34aa5f | ||
|
|
6980dcfdc0 | ||
|
|
88706b8c97 | ||
|
|
6694713cf9 | ||
|
|
f21a6d1791 | ||
|
|
c349bab79e | ||
|
|
a3c025cc41 | ||
|
|
d6116a227e | ||
|
|
b2af298cba | ||
|
|
da0e5942fb | ||
|
|
c33f7fefc0 | ||
|
|
a9dde35b70 | ||
|
|
718c724aec | ||
|
|
fda72c0730 | ||
|
|
fdabdeb34f | ||
|
|
c093e4f8ff | ||
|
|
d613bda202 | ||
|
|
e71e3330aa | ||
|
|
211eb4cbf4 | ||
|
|
ee5c0290a7 | ||
|
|
f28fc07d08 | ||
|
|
78f962ac31 | ||
|
|
2fc75feb4e | ||
|
|
b135b676a5 | ||
|
|
1bff13835e | ||
|
|
11aa4014d0 | ||
|
|
0807e52c2d | ||
|
|
278e06b2bc | ||
|
|
5be5f1ea14 | ||
|
|
bdc108ec1c | ||
|
|
0ac9826a9e | ||
|
|
49a967ca8f | ||
|
|
905e973bd5 | ||
|
|
777dfd978d | ||
|
|
23b6a565df | ||
|
|
ce634f0683 | ||
|
|
4161a2e62d | ||
|
|
5281704297 | ||
|
|
8dc28786e8 | ||
|
|
251d6e48eb | ||
|
|
b612222937 | ||
|
|
4a002c54fb | ||
|
|
621e381736 | ||
|
|
476942bb93 | ||
|
|
f1434b7ae4 | ||
|
|
8a421f14d2 | ||
|
|
fe8e6dc78f | ||
|
|
7c5e35ea1e | ||
|
|
dc3c84f903 | ||
|
|
0c34adf412 | ||
|
|
c8fbbf70f4 | ||
|
|
86f6486b9b | ||
|
|
96ed4ea786 | ||
|
|
5c91bd0a72 | ||
|
|
adc5167911 | ||
|
|
fc84278599 | ||
|
|
74e457467c | ||
|
|
e6ca850e1d | ||
|
|
c41ad00d55 | ||
|
|
44feacc865 | ||
|
|
6b361d37e9 | ||
|
|
2f86d06a78 | ||
|
|
9a3d5bf432 | ||
|
|
a5bc1bbefa | ||
|
|
68ba644676 | ||
|
|
25efbb20a9 | ||
|
|
3a7617813b | ||
|
|
49c116f771 | ||
|
|
08a214570a | ||
|
|
ddccefef09 | ||
|
|
626068c473 | ||
|
|
2ebd444b2d | ||
|
|
a6bbceffbd | ||
|
|
c842f014f4 | ||
|
|
18965b946b | ||
|
|
cc4820ca9a | ||
|
|
d50a477297 | ||
|
|
d16404fe19 | ||
|
|
71650fe7fd | ||
|
|
17bc7388d9 | ||
|
|
2b5f644882 | ||
|
|
7d8164a281 | ||
|
|
5f0bb7d845 | ||
|
|
fc7fbedf83 | ||
|
|
34089143fc | ||
|
|
5e196bc539 | ||
|
|
e28123cf80 | ||
|
|
9d3424f3bb | ||
|
|
8f4ccbbb2b | ||
|
|
0d4e37682a | ||
|
|
7cf1de5381 | ||
|
|
85460ae14e | ||
|
|
61ef913d4c | ||
|
|
aad83aaae9 | ||
|
|
234db5d11f | ||
|
|
da98e04381 | ||
|
|
a8d9c95631 | ||
|
|
34ab4297db | ||
|
|
3e60275fb0 | ||
|
|
bef076f523 | ||
|
|
3b5a389193 | ||
|
|
4a33d64e2b | ||
|
|
dd24c520db | ||
|
|
624182edbd | ||
|
|
23e36e9a99 | ||
|
|
dfefd3e765 | ||
|
|
f9f1b0b28f | ||
|
|
48aa562625 | ||
|
|
83a1a3fa01 | ||
|
|
827a78ce7a | ||
|
|
f6fdbcdbd9 | ||
|
|
fa98929bfa | ||
|
|
c5477a2ead | ||
|
|
455f9e6cf5 | ||
|
|
53e2d91f0a | ||
|
|
17aaad209d | ||
|
|
f6eb618bf8 | ||
|
|
ad5ac1f6a9 | ||
|
|
682efd0516 | ||
|
|
8ed9f57f16 | ||
|
|
e5bedcb7b9 | ||
|
|
c6ab758469 | ||
|
|
23999c4aa2 | ||
|
|
0d8abb97ef | ||
|
|
6148b172da | ||
|
|
3a93f38c94 | ||
|
|
7a6daf34a8 | ||
|
|
266ae5bfff | ||
|
|
ca730caab2 | ||
|
|
871ccce82b | ||
|
|
70e55f0cac | ||
|
|
20f07162c7 | ||
|
|
9bd0671ea0 | ||
|
|
4a0c06448c | ||
|
|
4d3ea82f67 | ||
|
|
268b955eab | ||
|
|
0f175e404d | ||
|
|
881789cfe1 | ||
|
|
c17dc72fb0 | ||
|
|
d9bf6c59d1 | ||
|
|
183ccbb141 | ||
|
|
89d3965799 | ||
|
|
3b8bf8985b | ||
|
|
9b46939729 | ||
|
|
56fef30a31 | ||
|
|
77274db339 | ||
|
|
e735775648 | ||
|
|
4d3cca5e54 | ||
|
|
94f9ff00e0 | ||
|
|
b0bef02583 | ||
|
|
4afd827ace | ||
|
|
c875f8440b | ||
|
|
362803d710 | ||
|
|
ea8d65e152 | ||
|
|
b17b4ae034 | ||
|
|
65e2fa36dd | ||
|
|
66e36b1c69 | ||
|
|
6fb606fd53 | ||
|
|
406771534e | ||
|
|
4e41e669b5 | ||
|
|
f3c3385bbd | ||
|
|
80835fc83d | ||
|
|
1fe77558db | ||
|
|
fdab1d83ec | ||
|
|
58f583e90b | ||
|
|
24e000b195 | ||
|
|
4de056fa8b | ||
|
|
aa4287afc6 | ||
|
|
f01012d29f | ||
|
|
f041d15a95 | ||
|
|
f172ed7498 | ||
|
|
671bec66cd | ||
|
|
646e4e32d9 | ||
|
|
e8f79dfdbb | ||
|
|
276cdeb8ac | ||
|
|
90d6f3688b | ||
|
|
596d420244 | ||
|
|
7b3260f74f | ||
|
|
d76b45a281 | ||
|
|
08653907e7 | ||
|
|
a489d1e3aa | ||
|
|
1851a1991c | ||
|
|
f0371fbe70 | ||
|
|
5bd8d9678a | ||
|
|
ddefb6f48d | ||
|
|
7aca93426b | ||
|
|
e8432591d3 | ||
|
|
a796093857 | ||
|
|
5878d58377 | ||
|
|
074337af99 | ||
|
|
0f699f8e02 | ||
|
|
503b0429b1 | ||
|
|
e9c487cd2f | ||
|
|
a516e8e709 | ||
|
|
b8459b0eff | ||
|
|
17bdaad0b0 | ||
|
|
d1d25e63e5 | ||
|
|
3bf87b4d0a | ||
|
|
8e483dde72 | ||
|
|
eeec501119 | ||
|
|
99651759da | ||
|
|
bc0b454ceb | ||
|
|
19cdcaab0a | ||
|
|
8f115d1702 | ||
|
|
ad8b9c71ff | ||
|
|
4f768a84e8 | ||
|
|
14a1ee8f7f | ||
|
|
ca788d0966 | ||
|
|
d934f1c9dd | ||
|
|
ac59199914 | ||
|
|
b9840adf1c | ||
|
|
a90e3e2bcd | ||
|
|
368df20ed2 | ||
|
|
0b2d5327f4 | ||
|
|
5d3bce4810 | ||
|
|
5900acb119 | ||
|
|
9d63534b05 | ||
|
|
aab1a087b9 | ||
|
|
b9b934e218 | ||
|
|
dbffc3d740 | ||
|
|
fb96492170 | ||
|
|
f25b8f79a8 | ||
|
|
5a5b981965 | ||
|
|
14125a17cb | ||
|
|
bb1d478f7f | ||
|
|
60f37ee7c6 | ||
|
|
5047e081bc | ||
|
|
775443b26e | ||
|
|
cea97c9b9d | ||
|
|
5e547cf514 | ||
|
|
9e320a452f | ||
|
|
c0b770b7cf | ||
|
|
323a2a95f5 | ||
|
|
9b7dfce095 | ||
|
|
09cc425fd8 | ||
|
|
c54a107f77 | ||
|
|
581645829f | ||
|
|
6445e0a5e7 | ||
|
|
f2ab4b5f36 | ||
|
|
93bfa7dd61 | ||
|
|
51625a22e5 | ||
|
|
23bb1985a1 | ||
|
|
aa55b66a75 | ||
|
|
6bd9f77ab6 | ||
|
|
94e65f522d | ||
|
|
6dab6f3c92 | ||
|
|
e3c382e66c | ||
|
|
e04d26ac82 | ||
|
|
f8c9bf5547 | ||
|
|
dd46917414 | ||
|
|
d086618850 | ||
|
|
9a14a65e53 | ||
|
|
1138f32e76 | ||
|
|
56ece8f9af | ||
|
|
46c0f64d87 | ||
|
|
1c5e59a200 | ||
|
|
6e645e2d88 | ||
|
|
87ccb646c2 | ||
|
|
898a5b1626 | ||
|
|
80c6866e8c | ||
|
|
e7dd9a1625 | ||
|
|
87dae2a950 | ||
|
|
85a1cbdd8a | ||
|
|
40826bfb13 | ||
|
|
d4dd348b14 | ||
|
|
333b435321 | ||
|
|
65e391a401 | ||
|
|
f9d401b6ea | ||
|
|
aa38634842 | ||
|
|
12bf175c64 | ||
|
|
edccdc416e | ||
|
|
5559945e7f | ||
|
|
fe52643a86 | ||
|
|
accb314eee | ||
|
|
a4fe926a4b | ||
|
|
d76d47b899 | ||
|
|
9b716795cd | ||
|
|
ff1bada908 | ||
|
|
f9a0a0926f | ||
|
|
51ab972870 | ||
|
|
40e8f0b9a9 |
No files matched your search
Binary file not shown.
|
After Width: | Height: | Size: 2.4 KiB |
+20
-13
@@ -26,7 +26,9 @@ jobs:
|
||||
# merges. Gated jobs skip only on an explicit `false` output: wrapped in
|
||||
# `always()`, a failed `changes` job (or a missing output) makes them run
|
||||
# the full matrix instead of silently passing required checks as skipped.
|
||||
# Pushes to main-v2 always run everything.
|
||||
# Pushes to main-v2 always run everything. Pull requests run the macOS and
|
||||
# Windows legs only when `platform` is not 'false'; the rule lives in
|
||||
# scripts/ci-platform-scope.sh and fails toward running them.
|
||||
changes:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
@@ -34,13 +36,14 @@ jobs:
|
||||
desktop: ${{ steps.filter.outputs.desktop }}
|
||||
sdk: ${{ steps.filter.outputs.sdk }}
|
||||
scripts: ${{ steps.filter.outputs.scripts }}
|
||||
platform: ${{ steps.filter.outputs.platform }}
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- id: filter
|
||||
run: |
|
||||
code=true; desktop=true; sdk=true; scripts=true
|
||||
code=true; desktop=true; sdk=true; scripts=true; platform=true
|
||||
base=""
|
||||
if [ "${{ github.event_name }}" = "pull_request" ]; then
|
||||
base="${{ github.event.pull_request.base.sha }}"
|
||||
@@ -48,7 +51,7 @@ jobs:
|
||||
base="${{ github.event.before }}"
|
||||
fi
|
||||
if [ -n "$base" ] && git cat-file -e "$base^{commit}" 2>/dev/null; then
|
||||
files=$(git diff --name-only "$base" HEAD)
|
||||
files=$(git diff --name-only --no-renames "$base" HEAD)
|
||||
if [ -n "$files" ]; then
|
||||
code=false; desktop=false; sdk=false; scripts=false
|
||||
# Root-module CI: desktop/ is a separate module, so only the
|
||||
@@ -63,9 +66,10 @@ jobs:
|
||||
if echo "$files" | grep -qE '^(sdk/|internal/ext/extension/)'; then sdk=true; fi
|
||||
# The release-note tests render every committed Studio note; npm/ holds the publish tests.
|
||||
if echo "$files" | grep -qE '^(scripts/|npm/|release-notes/studio/|\.github/workflows/release-studio\.yml$)'; then scripts=true; fi
|
||||
platform=$(git diff -z --name-only --no-renames "$base" HEAD | bash scripts/ci-platform-scope.sh)
|
||||
fi
|
||||
fi
|
||||
{ echo "code=$code"; echo "desktop=$desktop"; echo "sdk=$sdk"; echo "scripts=$scripts"; } >> "$GITHUB_OUTPUT"
|
||||
{ echo "code=$code"; echo "desktop=$desktop"; echo "sdk=$sdk"; echo "scripts=$scripts"; echo "platform=$platform"; } >> "$GITHUB_OUTPUT"
|
||||
|
||||
release-notes-scripts:
|
||||
needs: changes
|
||||
@@ -77,14 +81,14 @@ jobs:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7
|
||||
with:
|
||||
node-version: "22"
|
||||
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
cache: true
|
||||
- run: node --test scripts/cli-archive-build.test.mjs scripts/release-credits.test.mjs scripts/update-contributors.test.mjs scripts/update-acknowledgments.test.mjs scripts/markdown-refs.test.mjs scripts/studio-release-notes.test.mjs scripts/publish-cli-pointer.test.mjs scripts/studio-release-flags.test.mjs scripts/studio-notes-publish.test.mjs scripts/brew-cask-guard.test.mjs scripts/feedback-sync.test.mjs npm/publish.test.mjs
|
||||
- run: node --test scripts/ci-platform-scope.test.mjs scripts/cli-archive-build.test.mjs scripts/release-credits.test.mjs scripts/update-contributors.test.mjs scripts/update-acknowledgments.test.mjs scripts/markdown-refs.test.mjs scripts/studio-release-notes.test.mjs scripts/publish-cli-pointer.test.mjs scripts/studio-release-flags.test.mjs scripts/studio-notes-publish.test.mjs scripts/brew-cask-guard.test.mjs scripts/feedback-sync.test.mjs npm/publish.test.mjs
|
||||
|
||||
# The ruleset requires the per-OS check names (test (ubuntu-latest) etc.).
|
||||
# A matrix job skipped at job level reports no per-leg checks at all, so
|
||||
@@ -93,6 +97,9 @@ jobs:
|
||||
# detector reports the diff is unrelated, every step skips and each leg
|
||||
# reports success in seconds. `always()` also keeps the legs alive when
|
||||
# the changes job itself fails (fail-open: an empty output != 'false').
|
||||
# A pull request with no platform-relevant change moves the macOS and
|
||||
# Windows legs onto an ubuntu runner with every step skipped: the check
|
||||
# names the ruleset requires still report, without a scarce runner.
|
||||
test:
|
||||
needs: changes
|
||||
if: always()
|
||||
@@ -100,9 +107,9 @@ jobs:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [ubuntu-latest, macos-latest, windows-latest]
|
||||
runs-on: ${{ matrix.os }}
|
||||
runs-on: ${{ github.event_name == 'pull_request' && needs.changes.outputs.platform == 'false' && 'ubuntu-latest' || matrix.os }}
|
||||
env:
|
||||
RUN_STEPS: ${{ github.event_name != 'pull_request' || needs.changes.outputs.code != 'false' }}
|
||||
RUN_STEPS: ${{ github.event_name != 'pull_request' || (needs.changes.outputs.code != 'false' && (matrix.os == 'ubuntu-latest' || needs.changes.outputs.platform != 'false')) }}
|
||||
steps:
|
||||
- if: env.RUN_STEPS == 'true'
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
@@ -302,9 +309,9 @@ jobs:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [ubuntu-latest, macos-latest, windows-latest]
|
||||
runs-on: ${{ matrix.os }}
|
||||
runs-on: ${{ github.event_name == 'pull_request' && needs.changes.outputs.platform == 'false' && 'ubuntu-latest' || matrix.os }}
|
||||
env:
|
||||
RUN_STEPS: ${{ github.event_name != 'pull_request' || needs.changes.outputs.sdk != 'false' }}
|
||||
RUN_STEPS: ${{ github.event_name != 'pull_request' || (needs.changes.outputs.sdk != 'false' && (matrix.os == 'ubuntu-latest' || needs.changes.outputs.platform != 'false')) }}
|
||||
defaults:
|
||||
run:
|
||||
working-directory: sdk/go
|
||||
@@ -409,7 +416,7 @@ jobs:
|
||||
# compile it against a real macOS toolchain.
|
||||
desktop-macos:
|
||||
needs: changes
|
||||
if: always() && (github.event_name != 'pull_request' || needs.changes.outputs.desktop != 'false')
|
||||
if: always() && (github.event_name != 'pull_request' || (needs.changes.outputs.desktop != 'false' && needs.changes.outputs.platform != 'false'))
|
||||
runs-on: macos-latest
|
||||
defaults:
|
||||
run:
|
||||
@@ -437,7 +444,7 @@ jobs:
|
||||
# (//go:build windows), so the ubuntu leg above never compiles them.
|
||||
desktop-windows:
|
||||
needs: changes
|
||||
if: always() && (github.event_name != 'pull_request' || needs.changes.outputs.desktop != 'false')
|
||||
if: always() && (github.event_name != 'pull_request' || (needs.changes.outputs.desktop != 'false' && needs.changes.outputs.platform != 'false'))
|
||||
runs-on: windows-latest
|
||||
defaults:
|
||||
run:
|
||||
@@ -547,7 +554,7 @@ jobs:
|
||||
run: go test -coverprofile=coverage.out -covermode=atomic ./...
|
||||
|
||||
- name: upload coverage
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7
|
||||
with:
|
||||
name: coverage-report
|
||||
path: coverage.out
|
||||
|
||||
@@ -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@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2
|
||||
uses: github/codeql-action/init@24c54180a607b1449ed407dd24f251e4e9147c8d # v4.38.3
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
build-mode: ${{ matrix.language == 'go' && 'autobuild' || 'none' }}
|
||||
- name: Perform CodeQL Analysis
|
||||
if: matrix.branch == ''
|
||||
uses: github/codeql-action/analyze@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2
|
||||
uses: github/codeql-action/analyze@24c54180a607b1449ed407dd24f251e4e9147c8d # v4.38.3
|
||||
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@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2
|
||||
uses: github/codeql-action/analyze@24c54180a607b1449ed407dd24f251e4e9147c8d # v4.38.3
|
||||
with:
|
||||
category: "/language:${{ matrix.language }}"
|
||||
ref: refs/heads/${{ matrix.branch }}
|
||||
|
||||
@@ -24,7 +24,7 @@ jobs:
|
||||
with:
|
||||
version: 10
|
||||
run_install: false
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7
|
||||
with:
|
||||
node-version: "24"
|
||||
cache: pnpm
|
||||
|
||||
@@ -20,7 +20,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: npm
|
||||
|
||||
@@ -24,7 +24,7 @@ jobs:
|
||||
with:
|
||||
version: 10
|
||||
run_install: false
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7
|
||||
with:
|
||||
node-version: "24"
|
||||
cache: pnpm
|
||||
|
||||
@@ -17,7 +17,7 @@ jobs:
|
||||
environment: production
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: npm
|
||||
|
||||
@@ -28,7 +28,7 @@ jobs:
|
||||
version: 10
|
||||
run_install: false
|
||||
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7
|
||||
with:
|
||||
node-version: "24"
|
||||
cache: pnpm
|
||||
|
||||
@@ -55,7 +55,7 @@ jobs:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
- if: steps.gate.outputs.run == 'true'
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7
|
||||
with:
|
||||
node-version: '22'
|
||||
- name: Sync
|
||||
|
||||
@@ -202,7 +202,7 @@ jobs:
|
||||
version: 10
|
||||
run_install: false
|
||||
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7
|
||||
with:
|
||||
node-version: "24"
|
||||
cache: pnpm
|
||||
@@ -364,7 +364,7 @@ jobs:
|
||||
# run nothing but the signer, and windows-package packages in between.
|
||||
- name: Upload the unsigned Windows bundle for signing
|
||||
if: runner.os == 'Windows' && env.SIGN_WINDOWS == 'true'
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: unsigned-studio-windows-${{ matrix.goarch }}-bundle
|
||||
path: dist/${{ matrix.unpacked }}
|
||||
@@ -397,7 +397,7 @@ jobs:
|
||||
wait "$zip_pid"
|
||||
mv ../../dist-zip/ReasonixStudio-windows-${{ matrix.goarch }}.zip ../../dist/
|
||||
|
||||
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
if: runner.os != 'Windows' || env.SIGN_WINDOWS != 'true'
|
||||
with:
|
||||
name: ${{ matrix.artifact }}
|
||||
@@ -479,7 +479,7 @@ jobs:
|
||||
}
|
||||
if ($failed) { exit 1 }
|
||||
|
||||
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
||||
with:
|
||||
name: unsigned-studio-windows-${{ matrix.arch }}-bundle
|
||||
path: signing/app
|
||||
@@ -534,7 +534,7 @@ jobs:
|
||||
-ExpectedSubject $env:EXPECTED_SUBJECT
|
||||
"digest=$(Get-Content -Raw "$env:RUNNER_TEMP/payload-digest")" >> $env:GITHUB_OUTPUT
|
||||
|
||||
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: signed-studio-windows-payload-${{ matrix.arch }}
|
||||
path: signing/app
|
||||
@@ -576,7 +576,7 @@ jobs:
|
||||
version: 10
|
||||
run_install: false
|
||||
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7
|
||||
with:
|
||||
node-version: "24"
|
||||
cache: pnpm
|
||||
@@ -588,7 +588,7 @@ jobs:
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
# The signed payload is the whole bundle, so it is packaged as it came.
|
||||
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
||||
with:
|
||||
name: signed-studio-windows-payload-${{ matrix.arch }}
|
||||
path: dist/${{ matrix.unpacked }}
|
||||
@@ -614,7 +614,7 @@ jobs:
|
||||
wait "$zip_pid"
|
||||
mv ../../dist-zip/ReasonixStudio-windows-${{ matrix.arch }}.zip ../../dist/
|
||||
|
||||
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: packaged-studio-windows-${{ matrix.arch }}
|
||||
path: |
|
||||
@@ -649,12 +649,12 @@ jobs:
|
||||
ref: ${{ github.workflow_sha }}
|
||||
persist-credentials: false
|
||||
|
||||
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
||||
with:
|
||||
name: signed-studio-windows-payload-${{ matrix.arch }}
|
||||
path: payload
|
||||
|
||||
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
||||
with:
|
||||
name: packaged-studio-windows-${{ matrix.arch }}
|
||||
path: dist
|
||||
@@ -732,7 +732,7 @@ jobs:
|
||||
}
|
||||
if ($failed) { exit 1 }
|
||||
|
||||
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
||||
with:
|
||||
name: packaged-studio-windows-${{ matrix.arch }}
|
||||
path: dist
|
||||
@@ -795,7 +795,7 @@ jobs:
|
||||
$hash = (Get-FileHash -Algorithm SHA256 -LiteralPath $installer).Hash.ToLowerInvariant()
|
||||
"installer-sha256=$hash" >> $env:GITHUB_OUTPUT
|
||||
|
||||
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: studio-windows-signed-${{ matrix.arch }}
|
||||
path: dist/ReasonixStudio-*
|
||||
@@ -832,13 +832,13 @@ jobs:
|
||||
ref: ${{ needs.resolve.outputs.sha }}
|
||||
persist-credentials: false
|
||||
|
||||
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
||||
if: needs.windows-sign-installer.result == 'success'
|
||||
with:
|
||||
name: studio-windows-signed-${{ matrix.arch }}
|
||||
path: dist
|
||||
|
||||
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
||||
if: needs.windows-sign-installer.result != 'success'
|
||||
with:
|
||||
name: studio-build-windows-${{ matrix.arch }}
|
||||
@@ -912,7 +912,7 @@ jobs:
|
||||
[ -f "dist/$want" ] || { echo "::error::no dist/$want"; exit 1; }
|
||||
done
|
||||
|
||||
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: cli-archives
|
||||
path: dist/*
|
||||
@@ -971,28 +971,28 @@ jobs:
|
||||
# Every package by its exact name, never a pattern: any job without
|
||||
# secrets can upload an artifact, and a pattern would publish it. Windows
|
||||
# comes from the installer signing job when it ran, else the build leg.
|
||||
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
||||
with:
|
||||
name: studio-build-darwin-amd64
|
||||
path: staging/studio-build-darwin-amd64
|
||||
|
||||
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
||||
with:
|
||||
name: studio-build-darwin-arm64
|
||||
path: staging/studio-build-darwin-arm64
|
||||
|
||||
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
||||
with:
|
||||
name: studio-build-linux-amd64
|
||||
path: staging/studio-build-linux-amd64
|
||||
|
||||
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
||||
if: needs.windows-sign-installer.result == 'success'
|
||||
with:
|
||||
name: studio-windows-signed-amd64
|
||||
path: staging/studio-windows-amd64
|
||||
|
||||
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
||||
if: needs.windows-sign-installer.result == 'success'
|
||||
with:
|
||||
name: studio-windows-signed-arm64
|
||||
@@ -1035,19 +1035,19 @@ jobs:
|
||||
check "$dir" "ReasonixStudio-windows-$arch.zip" "${!archive}"
|
||||
done
|
||||
|
||||
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
||||
if: needs.windows-sign-installer.result != 'success'
|
||||
with:
|
||||
name: studio-build-windows-amd64
|
||||
path: staging/studio-windows-amd64
|
||||
|
||||
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
||||
if: needs.windows-sign-installer.result != 'success'
|
||||
with:
|
||||
name: studio-build-windows-arm64
|
||||
path: staging/studio-windows-arm64
|
||||
|
||||
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
||||
with:
|
||||
name: cli-archives
|
||||
path: staging/cli-archives
|
||||
@@ -1404,7 +1404,7 @@ jobs:
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
cache: true
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7
|
||||
with:
|
||||
node-version: "24"
|
||||
registry-url: https://registry.npmjs.org
|
||||
@@ -1413,7 +1413,7 @@ jobs:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
VERSION: ${{ needs.resolve.outputs.version }}
|
||||
run: node npm/build.mjs "$VERSION" --publish
|
||||
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
||||
if: needs.resolve.outputs.candidate != 'true'
|
||||
with:
|
||||
name: cli-archives
|
||||
@@ -1467,7 +1467,7 @@ jobs:
|
||||
with:
|
||||
ref: ${{ needs.resolve.outputs.sha }}
|
||||
persist-credentials: false
|
||||
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
||||
with:
|
||||
name: cli-archives
|
||||
path: cli-archives
|
||||
|
||||
@@ -43,7 +43,7 @@ jobs:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7
|
||||
with:
|
||||
node-version: '22'
|
||||
- name: Test sweeper
|
||||
|
||||
@@ -46,7 +46,7 @@ jobs:
|
||||
version: 10
|
||||
run_install: false
|
||||
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7
|
||||
with:
|
||||
node-version: "24"
|
||||
cache: pnpm
|
||||
@@ -99,7 +99,7 @@ jobs:
|
||||
-c.extraMetadata.version="${{ steps.version.outputs.version }}"
|
||||
sha256sum ../../dist/ReasonixStudio-windows-amd64-installer.exe | tee -a "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: studio-selftest-windows-${{ steps.version.outputs.version }}
|
||||
path: dist/ReasonixStudio-windows-amd64-installer.exe
|
||||
|
||||
@@ -33,7 +33,7 @@ jobs:
|
||||
version: 10
|
||||
run_install: false
|
||||
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7
|
||||
with:
|
||||
node-version: "24"
|
||||
cache: pnpm
|
||||
@@ -114,7 +114,7 @@ jobs:
|
||||
run_install: false
|
||||
|
||||
- if: steps.spa.outputs.run == 'true'
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7
|
||||
with:
|
||||
node-version: "24"
|
||||
cache: pnpm
|
||||
@@ -141,9 +141,17 @@ jobs:
|
||||
node perf/branch-tooltip.mjs
|
||||
node perf/selfcheck.mjs branch-tooltip
|
||||
|
||||
# A pull request with no platform-relevant change (scripts/ci-platform-scope.sh)
|
||||
# moves the Windows and macOS legs onto an ubuntu runner with every step
|
||||
# skipped, so the check names still report without a scarce runner. A push to
|
||||
# studio, or a failed `scope`, runs all three.
|
||||
desktop:
|
||||
name: desktop (${{ matrix.os }})
|
||||
runs-on: ${{ matrix.os }}
|
||||
needs: scope
|
||||
if: always()
|
||||
runs-on: ${{ github.event_name == 'pull_request' && needs.scope.outputs.platform == 'false' && matrix.os != 'ubuntu-22.04' && 'ubuntu-latest' || matrix.os }}
|
||||
env:
|
||||
RUN_STEPS: ${{ github.event_name != 'pull_request' || needs.scope.outputs.platform != 'false' || matrix.os == 'ubuntu-22.04' }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -152,9 +160,11 @@ jobs:
|
||||
run:
|
||||
working-directory: desktop
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
- if: env.RUN_STEPS == 'true'
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
|
||||
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7
|
||||
- if: env.RUN_STEPS == 'true'
|
||||
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7
|
||||
with:
|
||||
go-version-file: desktop/go.mod
|
||||
cache: true
|
||||
@@ -167,6 +177,7 @@ jobs:
|
||||
# carries build dependencies of its own.
|
||||
# Same command as `make studio-test`; this job is already in desktop/.
|
||||
- name: go test
|
||||
if: env.RUN_STEPS == 'true'
|
||||
run: go test ./...
|
||||
|
||||
# The host sits in the main module and nothing else builds it, so its
|
||||
@@ -174,7 +185,7 @@ jobs:
|
||||
# platform-neutral or the Electron bundle cannot ship it. Cross-compiling
|
||||
# is host-independent, so once is enough.
|
||||
- name: host stays pure Go and platform-neutral
|
||||
if: runner.os == 'Linux'
|
||||
if: env.RUN_STEPS == 'true' && runner.os == 'Linux'
|
||||
working-directory: .
|
||||
run: |
|
||||
for os in windows linux darwin; do
|
||||
@@ -183,12 +194,15 @@ jobs:
|
||||
|
||||
# The ARM64 leg queues on a scarcer runner pool, so a pull request runs it
|
||||
# only when it touches what the leg verifies; a push to studio always does.
|
||||
# The macOS and Windows package legs follow `platform`, the same rule as the
|
||||
# desktop matrix above.
|
||||
scope:
|
||||
name: package matrix
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
matrix: ${{ steps.pick.outputs.matrix }}
|
||||
arm64: ${{ steps.pick.outputs.arm64 }}
|
||||
platform: ${{ steps.pick.outputs.platform }}
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
@@ -200,9 +214,11 @@ jobs:
|
||||
BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
run: |
|
||||
arm64=true
|
||||
platform=true
|
||||
if [ "$EVENT" = pull_request ]; then
|
||||
arm64=false
|
||||
if git diff --name-only "$BASE_SHA" HEAD |
|
||||
platform=$(git diff -z --name-only --no-renames "$BASE_SHA" HEAD | bash scripts/ci-platform-scope.sh)
|
||||
if git diff --name-only --no-renames "$BASE_SHA" HEAD |
|
||||
grep -qE '^(desktop/electron/|desktop/cmd/studio-manifest/|desktop/go\.(mod|sum)$|cmd/reasonix-studio-host/|internal/platform/(update|appupdate)/|go\.(mod|sum)$|scripts/(windows-signing-lib|verify-windows-package|verify-windows-authenticode|install-smoke)\.ps1$|\.github/workflows/(studio|release-studio)\.yml$)'; then
|
||||
arm64=true
|
||||
fi
|
||||
@@ -212,13 +228,18 @@ jobs:
|
||||
{"os":"ubuntu-22.04","arch":"amd64","platform":"linux"},
|
||||
{"os":"windows-latest","arch":"amd64","platform":"windows","ebarch":"x64"}
|
||||
]'
|
||||
if [ "$arm64" = true ]; then
|
||||
if [ "$platform" = false ]; then
|
||||
legs="$(jq -c 'map(select(.platform == "linux"))' <<<"$legs")"
|
||||
elif [ "$arm64" = true ]; then
|
||||
legs="$(jq -c '. + [{"os":"windows-11-arm","arch":"arm64","platform":"windows","ebarch":"arm64"}]' <<<"$legs")"
|
||||
else
|
||||
legs="$(jq -c . <<<"$legs")"
|
||||
fi
|
||||
echo "arm64=$arm64" >> "$GITHUB_OUTPUT"
|
||||
echo "matrix={\"include\":$legs}" >> "$GITHUB_OUTPUT"
|
||||
{
|
||||
echo "arm64=$arm64"
|
||||
echo "platform=$platform"
|
||||
echo "matrix={\"include\":$legs}"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
# The packaging contract, on real artifacts rather than on the config that
|
||||
# describes them. Unsigned on purpose: signing and notarization are a
|
||||
@@ -250,7 +271,7 @@ jobs:
|
||||
version: 10
|
||||
run_install: false
|
||||
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7
|
||||
with:
|
||||
node-version: "24"
|
||||
cache: pnpm
|
||||
@@ -315,6 +336,27 @@ jobs:
|
||||
test -n "$dir"
|
||||
node desktop/electron/packaging/pe.js "$dir" "${{ matrix.arch }}"
|
||||
|
||||
# An update starts the installer with --updated, and that is what keeps the
|
||||
# shortcuts (and the taskbar pin hanging off them) from being deleted and
|
||||
# recreated. Installed twice over itself the way an update does it, the
|
||||
# desktop shortcut has to still be the same file, pointing at a path with
|
||||
# no version in it, or every update costs the user their pin.
|
||||
- name: An update keeps the shortcuts and they name no version
|
||||
if: matrix.platform == 'windows'
|
||||
shell: pwsh
|
||||
run: |
|
||||
$installer = (Get-ChildItem dist -Filter 'ReasonixStudio-windows-*-installer.exe' | Select-Object -First 1).FullName
|
||||
$dir = Join-Path $env:RUNNER_TEMP 'rx-update-guard'
|
||||
Start-Process $installer -ArgumentList '/S', "/D=$dir" -Wait
|
||||
$lnk = Join-Path ([Environment]::GetFolderPath('Desktop')) 'Reasonix Studio.lnk'
|
||||
$target = (New-Object -ComObject WScript.Shell).CreateShortcut($lnk).TargetPath
|
||||
if ($target -ne (Join-Path $dir 'Reasonix Studio.exe')) { throw "the desktop shortcut targets $target, not the stable executable" }
|
||||
$mark = [datetime]'2020-01-01'
|
||||
(Get-Item $lnk).LastWriteTime = $mark
|
||||
Start-Process $installer -ArgumentList '/S', '--updated', "/D=$dir" -Wait
|
||||
if ((Get-Item $lnk).LastWriteTime -ne $mark) { throw 'an update rewrote the desktop shortcut' }
|
||||
Start-Process (Join-Path $dir 'Uninstall Reasonix Studio.exe') -ArgumentList '/S', '/currentuser' -Wait
|
||||
|
||||
# The release packages a signed copy of the bundle and then reads both
|
||||
# packages back against it. The same recipe on an unsigned copy, so the
|
||||
# packaging contract (the installer's one application archive, the pinned
|
||||
@@ -370,7 +412,7 @@ jobs:
|
||||
|
||||
- name: Keep the arm64 installer for the x64 refusal check
|
||||
if: matrix.platform == 'windows' && matrix.arch == 'arm64'
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: studio-arm64-installer-ci
|
||||
path: dist/ReasonixStudio-windows-arm64-installer.exe
|
||||
@@ -417,6 +459,16 @@ jobs:
|
||||
echo "::error::the package is $name; dpkg holds that beside the installed reasonix-studio instead of upgrading it, and update.StudioLine asks for it by name"
|
||||
exit 1
|
||||
fi
|
||||
# The helper in the package accepts one package name, compiled in. It has
|
||||
# to be the one the package carries, or every in-app update is refused
|
||||
# as a different package after the download and signature succeed.
|
||||
dpkg-deb --fsys-tarfile "$deb" | tar -xO ./usr/lib/reasonix-studio/reasonix-studio-update-helper > "$RUNNER_TEMP/update-helper"
|
||||
chmod +x "$RUNNER_TEMP/update-helper"
|
||||
accepts=$("$RUNNER_TEMP/update-helper" package)
|
||||
if [ "$accepts" != "$name" ]; then
|
||||
echo "::error::the update helper in the package accepts $accepts but the package is $name"
|
||||
exit 1
|
||||
fi
|
||||
dpkg-deb -c "$deb" > contents.txt
|
||||
# The path is the polkit action's exec.path annotation, so it is the
|
||||
# only one authorization is granted for; root because polkit will not
|
||||
@@ -506,7 +558,7 @@ jobs:
|
||||
if: needs.scope.outputs.arm64 == 'true'
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
||||
with:
|
||||
name: studio-arm64-installer-ci
|
||||
path: installer
|
||||
|
||||
@@ -14,7 +14,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7
|
||||
with:
|
||||
node-version: '22'
|
||||
- name: Update README contributor tables
|
||||
|
||||
@@ -27,7 +27,7 @@ jobs:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7
|
||||
with:
|
||||
node-version: '22'
|
||||
- name: Test chart generator
|
||||
|
||||
@@ -59,7 +59,7 @@
|
||||
|
||||
## ❤️Sponsor
|
||||
|
||||
<table><tr><td width="180"><a href="https://www.packyapi.ai/register?aff=rIbQ" target="_blank" rel="sponsored noopener noreferrer"><picture><source media="(prefers-color-scheme: dark)" srcset="./.github/sponsor/packycode-dark.svg"><img src="./.github/sponsor/packycode-light.svg" alt="PackyCode" width="150"></picture></a></td><td>Thanks to PackyCode for sponsoring this project! Access leading AI models through PackyCode with one API endpoint and one API key, with automatic failover and dedicated high-speed routes for Codex and Claude Code.</td></tr></table>
|
||||
<table><tr><td width="180" align="center" valign="middle"><a href="https://www.rightapi.ai/register?aff=e083bf73" target="_blank" rel="sponsored noopener noreferrer"><img src="./.github/sponsor/rightcode.png" alt="Right Code" width="64"></a></td><td valign="middle">Thanks to Right Code for sponsoring this project! Right Code reliably provides routing services for models such as Claude Code, Codex, and Gemini, with both pay-as-you-go and monthly subscription billing options available. Invoices are available upon top-up, and enterprise and team users can receive dedicated one-on-one support.</td></tr><tr><td width="180" align="center" valign="middle"><a href="https://www.packyapi.ai/register?aff=rIbQ" target="_blank" rel="sponsored noopener noreferrer"><picture><source media="(prefers-color-scheme: dark)" srcset="./.github/sponsor/packycode-dark.svg"><img src="./.github/sponsor/packycode-light.svg" alt="PackyCode" width="80"></picture></a></td><td valign="middle">Thanks to PackyCode for sponsoring this project! Access leading AI models through PackyCode with one API endpoint and one API key, with automatic failover and dedicated high-speed routes for Codex and Claude Code.</td></tr></table>
|
||||
|
||||
## What it is
|
||||
|
||||
|
||||
+1
-1
@@ -57,7 +57,7 @@
|
||||
|
||||
## ❤️赞助商
|
||||
|
||||
<table><tr><td width="180"><a href="https://www.packyapi.ai/register?aff=rIbQ" target="_blank" rel="sponsored noopener noreferrer"><picture><source media="(prefers-color-scheme: dark)" srcset="./.github/sponsor/packycode-dark.svg"><img src="./.github/sponsor/packycode-light.svg" alt="PackyCode" width="150"></picture></a></td><td>感谢 PackyCode 赞助了本项目!PackyCode 是一家稳定、高效的 API 中转服务商,统一域名、统一密钥、智能容灾切换。</td></tr></table>
|
||||
<table><tr><td width="180" align="center" valign="middle"><a href="https://www.rightapi.ai/register?aff=e083bf73" target="_blank" rel="sponsored noopener noreferrer"><img src="./.github/sponsor/rightcode.png" alt="Right Code" width="64"></a></td><td valign="middle">感谢 Right Code 赞助了本项目!Right Code 稳定提供 Claude Code、Codex、Gemini 等模型的中转服务,并可选按量、包月两种计费模式。充值即可开票,企业、团队用户一对一对接。</td></tr><tr><td width="180" align="center" valign="middle"><a href="https://www.packyapi.ai/register?aff=rIbQ" target="_blank" rel="sponsored noopener noreferrer"><picture><source media="(prefers-color-scheme: dark)" srcset="./.github/sponsor/packycode-dark.svg"><img src="./.github/sponsor/packycode-light.svg" alt="PackyCode" width="80"></picture></a></td><td valign="middle">感谢 PackyCode 赞助了本项目!PackyCode 是一家稳定、高效的 API 中转服务商,统一域名、统一密钥、智能容灾切换。</td></tr></table>
|
||||
|
||||
## 它是什么
|
||||
|
||||
|
||||
+3
-2
@@ -369,6 +369,7 @@ out of how a path is spelled, which cannot tell `internal/auth` from
|
||||
- sensitive: internal/ext/pluginpkg/**
|
||||
- sensitive: internal/base/netclient/**
|
||||
- sensitive: internal/safety/redirectguard/**
|
||||
- sensitive: internal/safety/endpointclient/**
|
||||
- sensitive: internal/safety/egress/**
|
||||
- sensitive: internal/platform/browser/**
|
||||
- sensitive: internal/platform/computer/**
|
||||
@@ -402,8 +403,8 @@ observation, never by a check that ran afterwards.
|
||||
What follows bounds Observable today. It is not a restatement of the invariant:
|
||||
moving a number here must not move the promise.
|
||||
|
||||
- `scanWorkspace` walks the workspace recursively, skips VCS store dirs, and is
|
||||
`complete` only below 50k files. Over that, or past a walk error, it
|
||||
- `scanWorkspace` walks the workspace and every granted extra root recursively,
|
||||
skips VCS store dirs, and is `complete` only below 50k files in total. Over that, or past a walk error, it
|
||||
establishes nothing and the mutation stays unproven.
|
||||
- Symlinks are recorded by `Lstat`, so a write through one lands outside what
|
||||
the walk compares. Confining the resolved target is the sandbox's job.
|
||||
|
||||
@@ -420,7 +420,7 @@ own outcome).
|
||||
| `-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. |
|
||||
| `-trials` | `1` | Suite mode: run every task N times and keep every outcome. Adds a **Reliability** line: `pass^N` (tasks that passed every trial), `pass@N` (any trial), mean `pass@1` over all trials, and the flaky tasks by name. `Pass@≤N` asks whether the agent can solve a task; `pass^N` asks whether it does so every time. Cannot be combined with `-attempts`. |
|
||||
| `-trials` | `1` | Suite mode: run every task N times and keep every outcome. Adds a **Reliability** line: `pass^N` (complete tasks that passed every trial) and `pass@N` (complete tasks with any pass), both over the tasks that ran all N trials only (`n/a` when none did), mean `pass@1` over every graded trial, and the flaky tasks by name; a task the token budget cut short is listed on an **Incomplete** line and is in neither. `Pass@≤N` asks whether the agent can solve a task; `pass^N` asks whether it does so every time. Cannot be combined with `-attempts`. |
|
||||
| `-tape-record DIR` | — | Suite mode, with `-meter`: record every provider exchange at the meter, one tape per task and trial (`DIR/<task>/trial-N/0001.request.json`, `.response.json`, `.response.body`). Taped runs use a fixed workdir path and pin the seeded files' times, because both reach the model through the workspace block and file listings. |
|
||||
| `-tape-replay DIR` | — | Suite mode, with `-meter`: answer each run from its tape instead of the provider, and add a **Replay** line naming the first request whose body differs from the recorded one, down to the message (`request 2, message 4 (tool)`), the tool list, or a field. The diverging request is kept as `NNNN.replayed.json` beside the recorded one. A kernel change that should not alter what the model is asked replays with every request matched. |
|
||||
| `-bin` | `reasonix` | Path to the reasonix binary. |
|
||||
|
||||
@@ -22,17 +22,28 @@ addresses by hand at roughly nine times the page-in cost (139 vs 1292
|
||||
tokens/task). Measured after three separate leaks were closed, so the numbers
|
||||
are from a clean environment.
|
||||
|
||||
**Fold index marginal utility: unresolved.** Both the positive and the negative
|
||||
estimates are invalidated. The first Stage 2 ran in a contaminated environment.
|
||||
The clean paired batch found a consistent effect (six of six tasks searched less
|
||||
and paged in less with the cue visible), and the same-batch dose-response did
|
||||
not reproduce it (`boundary-aligned 0/4`, recall-token delta reversing sign).
|
||||
The most likely reason is that two of the six index tasks carry heavy-tail
|
||||
stopping behaviour that swamps the effect being measured.
|
||||
**Fold index marginal utility: direction reproduced, size still conditional.** On the v2
|
||||
substrate the direction holds in six of six tasks and in the pooled paired batch: with the
|
||||
cue visible the index addresses the target, so the same task is searched less and pages in
|
||||
less (0.67 searches and 70 recall tokens per task, against 1.50 and 120 with the cue
|
||||
absent).
|
||||
|
||||
Existing evidence is not sufficient to tune the shipped 1% budget in either
|
||||
direction. Reopening this wants a new corpus, not more samples: that substrate
|
||||
now exists, two qualifying tasks per cue tier — see "Index corpus v2" below.
|
||||
A size is visible but is not yet quotable. On the four tasks whose absent-cue arm stayed
|
||||
out of a loop, the index cost 64,952 prompt tokens against 115,551 without, 44% less
|
||||
(`i106-budgetseal` -35,147, `i70-ingestfloor` -8,222, `i37-dispatchlag` -7,599, and
|
||||
`i112-lagquota` +369, the prefix rent a task that never searches pays). That is a
|
||||
**post-hoc subset**: `i118-fencelag` and `i47-tenantlease` are excluded because their
|
||||
absent-cue arm looped in this batch (five and three retrieval rounds, three escapes each;
|
||||
together 80% of the absent-cue total), and the qualification above does not screen for
|
||||
stopping behaviour — holding on both boundary arms across three passes shows a stable cue
|
||||
boundary, not a stopping arm. Quoting 44% as the index's effect would mean choosing the
|
||||
sample after seeing the result; a batch screened on stopping stability is what would make
|
||||
a size quotable, and one arm also reverses either way (`i70-ingestfloor` default, +11,135
|
||||
prompt tokens over three escapes).
|
||||
|
||||
Batch facts, because the counts differ: 36 runs were executed (12 paired, 24 index), and
|
||||
the token totals here cover the 24-run index batch — the paired batch ran before the
|
||||
per-run usage line existed.
|
||||
|
||||
**Things that turned out not to be problems**, each after being measured rather
|
||||
than argued about:
|
||||
|
||||
@@ -89,6 +89,15 @@ type contextMetrics struct {
|
||||
// resident prefix; this is what is paid dynamically instead.
|
||||
RecallReturnedTokens int `json:"recall_returned_tokens"`
|
||||
|
||||
// Provider usage as reported on the stream, or empty for a dry run. The count
|
||||
// is completions that reported usage, so it is not a count of attempts.
|
||||
UsageReportedRequests int `json:"usage_reported_requests,omitempty"`
|
||||
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"`
|
||||
UsageEstimated bool `json:"usage_estimated,omitempty"`
|
||||
|
||||
Searches []searchAttempt `json:"searches,omitempty"`
|
||||
// PostHitSearches are queries issued after the target had already been
|
||||
// returned: not a query problem, a result-to-read problem.
|
||||
|
||||
@@ -14,7 +14,6 @@ import (
|
||||
|
||||
"reasonix/internal/assembly/boot"
|
||||
"reasonix/internal/contract/ablation"
|
||||
"reasonix/internal/contract/event"
|
||||
"reasonix/internal/contract/provider"
|
||||
"reasonix/internal/contract/tool"
|
||||
_ "reasonix/internal/model/openai"
|
||||
@@ -132,10 +131,11 @@ func runOne(p provider.Provider, t contextTask, arm ablation.Set, armName, root
|
||||
}
|
||||
boot.ApplyUnifiedProviderToolSurface(reg, false, arm)
|
||||
|
||||
var usage usageSink
|
||||
a := agent.New(p, reg, sess, agent.Options{
|
||||
ContextWindow: fixtureWindow, CompactRatio: 0.5, RecentKeep: 2,
|
||||
SessionPath: f.Path, KeepPolicy: agent.KeepErrors, Ablation: arm,
|
||||
}, event.Discard)
|
||||
}, &usage)
|
||||
a.LoadProjectionSidecar(f.Path)
|
||||
// From here the transcript lives only in memory. On disk it is the answer.
|
||||
sealFixture(f.Path)
|
||||
@@ -161,6 +161,10 @@ func runOne(p provider.Provider, t contextTask, arm ablation.Set, armName, root
|
||||
appended := sess.Snapshot()[before:]
|
||||
m := scoreRun(appended, inst, f.Target, armName, cueVisible)
|
||||
m.trajectory = appended
|
||||
if u, requests := usage.snapshot(); requests > 0 {
|
||||
m.UsageReportedRequests, m.PromptTokens, m.CompletionTokens = requests, u.PromptTokens, u.CompletionTokens
|
||||
m.CacheHitTokens, m.CacheMissTokens, m.UsageEstimated = u.CacheHitTokens, u.CacheMissTokens, u.Estimated
|
||||
}
|
||||
m.scoreAnswer(finalAnswer(appended), inst)
|
||||
if runErr != nil {
|
||||
m.FailureStage = "RunError"
|
||||
@@ -249,9 +253,9 @@ func runBoundaries(root string) int {
|
||||
} else {
|
||||
noCueSide = append(noCueSide, m)
|
||||
}
|
||||
fmt.Printf("%-24s %-14s cue=%-3v %-14s search=%d read=%d direct=%-5v recall-tok=%-5d escape=%d\n",
|
||||
fmt.Printf("%-24s %-14s cue=%-3v %-14s search=%d read=%d direct=%-5v recall-tok=%-5d%s escape=%d\n",
|
||||
t.ID, name, side.has, m.FailureStage, m.SearchCalls, m.ReadCalls,
|
||||
m.CueDirectRead, m.RecallReturnedTokens, m.EscapeCalls)
|
||||
m.CueDirectRead, m.RecallReturnedTokens, promptTokenSuffix(m), m.EscapeCalls)
|
||||
}
|
||||
}
|
||||
reportDeltas(cueSide, noCueSide)
|
||||
@@ -340,9 +344,9 @@ func runExperiment(experiment, root string, dry bool, tasks []contextTask) int {
|
||||
}
|
||||
byArm[arm.name] = append(byArm[arm.name], m)
|
||||
all = append(all, m)
|
||||
fmt.Printf("%-24s %-14s %-18s search=%d hit=%d read=%d recall-tok=%d %v\n",
|
||||
fmt.Printf("%-24s %-14s %-18s search=%d hit=%d read=%d recall-tok=%d%s %v\n",
|
||||
t.ID, arm.name, m.FailureStage, m.SearchCalls, m.TargetSearchHits, m.ReadCalls,
|
||||
m.RecallReturnedTokens, m.UnexpectedWorkTools)
|
||||
m.RecallReturnedTokens, promptTokenSuffix(m), m.UnexpectedWorkTools)
|
||||
}
|
||||
}
|
||||
reportFunnels(byArm)
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sync"
|
||||
|
||||
"reasonix/internal/contract/event"
|
||||
"reasonix/internal/contract/provider"
|
||||
)
|
||||
|
||||
// usageSink reads what a run cost from the events the agent already emits. Every
|
||||
// billed completion reports event.Usage — the round path, compaction, command
|
||||
// routing and failure diagnosis all do — so the bench reads the same record the
|
||||
// product reads instead of wrapping a provider and counting a second time.
|
||||
type usageSink struct {
|
||||
mu sync.Mutex
|
||||
requests int
|
||||
usage provider.Usage
|
||||
}
|
||||
|
||||
func (s *usageSink) Emit(e event.Event) {
|
||||
if e.Kind != event.Usage || e.Usage == nil {
|
||||
return
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
// One event is one completion that reported usage; RequestCount covers a
|
||||
// completion that merged attempts. A request that failed before reporting
|
||||
// leaves no event, so this is not a count of attempts.
|
||||
if e.Usage.RequestCount > 0 {
|
||||
s.requests += e.Usage.RequestCount
|
||||
} else {
|
||||
s.requests++
|
||||
}
|
||||
u := *e.Usage
|
||||
s.usage.PromptTokens += u.PromptTokens
|
||||
s.usage.CompletionTokens += u.CompletionTokens
|
||||
s.usage.TotalTokens += u.TotalTokens
|
||||
s.usage.CacheHitTokens += u.CacheHitTokens
|
||||
s.usage.CacheMissTokens += u.CacheMissTokens
|
||||
s.usage.ReasoningTokens += u.ReasoningTokens
|
||||
s.usage.Estimated = s.usage.Estimated || u.Estimated
|
||||
}
|
||||
|
||||
func (s *usageSink) snapshot() (provider.Usage, int) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.usage, s.requests
|
||||
}
|
||||
|
||||
// promptTokenSuffix renders what a run cost only when something reported it. A
|
||||
// dry run reports nothing, and a trailing "prompt-tok=0" would read as a
|
||||
// measured zero rather than as no measurement.
|
||||
func promptTokenSuffix(m contextMetrics) string {
|
||||
if m.UsageReportedRequests == 0 {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf(" prompt-tok=%d", m.PromptTokens)
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/contract/event"
|
||||
"reasonix/internal/contract/provider"
|
||||
)
|
||||
|
||||
// The cost a run reports must be what the agent reported, summed across every
|
||||
// completion it billed — including one that merged attempts and one that could
|
||||
// only estimate. Any other event kind is not a cost.
|
||||
func TestUsageSinkAccumulatesWhatTheAgentReports(t *testing.T) {
|
||||
var sink usageSink
|
||||
sink.Emit(event.Event{}) // a kind that is not Usage
|
||||
sink.Emit(event.Event{Kind: event.Usage, Usage: &provider.Usage{
|
||||
PromptTokens: 100, CompletionTokens: 10, TotalTokens: 110, CacheHitTokens: 60, CacheMissTokens: 40,
|
||||
}})
|
||||
sink.Emit(event.Event{Kind: event.Usage, Usage: &provider.Usage{
|
||||
PromptTokens: 7, CompletionTokens: 3, CacheHitTokens: 2, CacheMissTokens: 5, RequestCount: 2, Estimated: true,
|
||||
}})
|
||||
sink.Emit(event.Event{Kind: event.Usage}) // a Usage event with no payload is not a cost
|
||||
|
||||
usage, requests := sink.snapshot()
|
||||
if requests != 3 {
|
||||
t.Fatalf("requests = %d, want 3 (1 + the merged attempt's 2; the empty event adds none)", requests)
|
||||
}
|
||||
if usage.PromptTokens != 107 || usage.CompletionTokens != 13 || usage.TotalTokens != 110 {
|
||||
t.Fatalf("accumulated usage = %+v", usage)
|
||||
}
|
||||
if usage.CacheHitTokens != 62 || usage.CacheMissTokens != 45 {
|
||||
t.Fatalf("cache split = %+v", usage)
|
||||
}
|
||||
if !usage.Estimated {
|
||||
t.Fatal("Estimated was dropped when one completion reported an estimate")
|
||||
}
|
||||
}
|
||||
|
||||
// A dry run must not claim a cost it never paid, in the JSON or in the line a
|
||||
// person reads: an empty suffix, not "prompt-tok=0".
|
||||
func TestUsageSinkStaysEmptyWithoutUsage(t *testing.T) {
|
||||
var sink usageSink
|
||||
sink.Emit(event.Event{})
|
||||
usage, requests := sink.snapshot()
|
||||
if requests != 0 || usage.PromptTokens != 0 || usage.CompletionTokens != 0 {
|
||||
t.Fatalf("nothing reported usage, got %+v requests=%d", usage, requests)
|
||||
}
|
||||
if suffix := promptTokenSuffix(contextMetrics{}); suffix != "" {
|
||||
t.Fatalf("a run with no reported usage still printed %q", suffix)
|
||||
}
|
||||
if suffix := promptTokenSuffix(contextMetrics{UsageReportedRequests: 1, PromptTokens: 12}); suffix != " prompt-tok=12" {
|
||||
t.Fatalf("a reported cost printed %q", suffix)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,534 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/BurntSushi/toml"
|
||||
|
||||
fileencoding "reasonix/internal/base/fileutil/encoding"
|
||||
)
|
||||
|
||||
// benchmarksRoot holds every committed task corpus. Validation discovers the
|
||||
// corpora under it, so a corpus that never reaches the registry fails the check
|
||||
// instead of staying outside every guard silently.
|
||||
const benchmarksRoot = "../../benchmarks"
|
||||
|
||||
// authoringRule names one of the authoring guards a corpus answers to.
|
||||
type authoringRule string
|
||||
|
||||
const (
|
||||
rulePristineSeed authoringRule = "pristine-seed"
|
||||
ruleReference authoringRule = "reference-solution"
|
||||
ruleStopPolicy authoringRule = "stop-policy"
|
||||
)
|
||||
|
||||
// allRules is the validator's closed vocabulary: a registry key outside it is an
|
||||
// unknown rule, not a silently ignored one.
|
||||
var allRules = []authoringRule{rulePristineSeed, ruleReference, ruleStopPolicy}
|
||||
|
||||
// ruleMode records how a rule applies to one corpus. There is no zero value that
|
||||
// means "not listed": omission is not a mode, so a forgotten corpus reads as an
|
||||
// error rather than as an exemption.
|
||||
type ruleMode string
|
||||
|
||||
const (
|
||||
// ruleEnforce applies the whole rule.
|
||||
ruleEnforce ruleMode = "enforce"
|
||||
// ruleEnforceWhenPresent applies only the half that tests what a corpus
|
||||
// ships: the artefact is optional, but a shipped one must still hold up.
|
||||
// Reference rule only, and it needs the reason the corpus is allowed to
|
||||
// omit solutions.
|
||||
ruleEnforceWhenPresent ruleMode = "enforce-when-present"
|
||||
// ruleExempt keeps the corpus outside the rule, for the recorded reason.
|
||||
ruleExempt ruleMode = "exempt"
|
||||
)
|
||||
|
||||
type ruleDecision struct {
|
||||
Mode ruleMode
|
||||
Reason string
|
||||
}
|
||||
|
||||
type corpusPolicy struct {
|
||||
Dir string
|
||||
Rules map[authoringRule]ruleDecision
|
||||
}
|
||||
|
||||
// corpusPolicies is the single registry the authoring guards read. Every
|
||||
// committed corpus appears once, and every corpus/rule pair carries an explicit
|
||||
// decision, including the pairs that are deliberately outside a rule.
|
||||
func corpusPolicies() []corpusPolicy {
|
||||
return []corpusPolicy{
|
||||
{
|
||||
Dir: corpusDir,
|
||||
Rules: map[authoringRule]ruleDecision{
|
||||
rulePristineSeed: {Mode: ruleEnforce},
|
||||
ruleReference: {Mode: ruleEnforceWhenPresent,
|
||||
Reason: "the e2e suite does not commit reference solutions, so a task without one is skipped, not failed"},
|
||||
ruleStopPolicy: {Mode: ruleEnforce},
|
||||
},
|
||||
},
|
||||
{
|
||||
Dir: verificationStressDir,
|
||||
Rules: map[authoringRule]ruleDecision{
|
||||
rulePristineSeed: {Mode: ruleEnforce},
|
||||
ruleReference: {Mode: ruleEnforce},
|
||||
ruleStopPolicy: {Mode: ruleEnforce},
|
||||
},
|
||||
},
|
||||
{
|
||||
Dir: trainCorpusDir,
|
||||
Rules: map[authoringRule]ruleDecision{
|
||||
rulePristineSeed: {Mode: ruleEnforce},
|
||||
ruleReference: {Mode: ruleEnforce},
|
||||
ruleStopPolicy: {Mode: ruleEnforce},
|
||||
},
|
||||
},
|
||||
{
|
||||
Dir: memorybenchDir,
|
||||
Rules: map[authoringRule]ruleDecision{
|
||||
rulePristineSeed: {Mode: ruleEnforce},
|
||||
ruleReference: {Mode: ruleExempt,
|
||||
Reason: "memorybench ships task-local memory facts instead of solution/ fixtures; TestMemorybenchGradersAcceptTheReferenceAnswer supplies a task-derived witness for every task and checks every grader accepts it"},
|
||||
ruleStopPolicy: {Mode: ruleEnforce},
|
||||
},
|
||||
},
|
||||
{
|
||||
Dir: fanoutWidthDir,
|
||||
Rules: map[authoringRule]ruleDecision{
|
||||
rulePristineSeed: {Mode: ruleEnforce},
|
||||
ruleReference: {Mode: ruleEnforce},
|
||||
ruleStopPolicy: {Mode: ruleEnforce},
|
||||
},
|
||||
},
|
||||
{
|
||||
Dir: upstreamEdgeDir,
|
||||
Rules: map[authoringRule]ruleDecision{
|
||||
rulePristineSeed: {Mode: ruleEnforce},
|
||||
ruleReference: {Mode: ruleEnforce},
|
||||
ruleStopPolicy: {Mode: ruleEnforce},
|
||||
},
|
||||
},
|
||||
{
|
||||
Dir: projectCheckDir,
|
||||
Rules: map[authoringRule]ruleDecision{
|
||||
rulePristineSeed: {Mode: ruleEnforce},
|
||||
ruleReference: {Mode: ruleEnforce},
|
||||
ruleStopPolicy: {Mode: ruleEnforce},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// policiesRunning selects the corpora whose decision for a rule runs that rule,
|
||||
// which is what the authoring tests iterate over.
|
||||
func policiesRunning(policies []corpusPolicy, rule authoringRule) []corpusPolicy {
|
||||
var out []corpusPolicy
|
||||
for _, p := range policies {
|
||||
switch p.Rules[rule].Mode {
|
||||
case ruleEnforce, ruleEnforceWhenPresent:
|
||||
out = append(out, p)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// committedCorpora lists the directories under root that carry tasks/.
|
||||
func committedCorpora(root string) ([]string, error) {
|
||||
entries, err := os.ReadDir(root)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var out []string
|
||||
for _, e := range entries {
|
||||
if !e.IsDir() {
|
||||
continue
|
||||
}
|
||||
if dirExists(filepath.Join(root, e.Name(), "tasks")) {
|
||||
out = append(out, e.Name())
|
||||
}
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// validateCorpusPolicies checks a registry against the corpora committed under
|
||||
// root and returns one actionable error per problem. Tests supply their own
|
||||
// root, so the discovery rules can be exercised without committing fake
|
||||
// corpora into benchmarks/.
|
||||
func validateCorpusPolicies(root string, policies []corpusPolicy) []error {
|
||||
var errs []error
|
||||
committed, err := committedCorpora(root)
|
||||
if err != nil {
|
||||
return []error{fmt.Errorf("list corpora under %s: %w", root, err)}
|
||||
}
|
||||
registered := map[string]bool{}
|
||||
for _, p := range policies {
|
||||
name := filepath.Base(p.Dir)
|
||||
if registered[name] {
|
||||
errs = append(errs, fmt.Errorf("corpus %q is registered more than once", name))
|
||||
continue
|
||||
}
|
||||
registered[name] = true
|
||||
// Identity is the path under this root, not the directory's name: a
|
||||
// registry entry pointing anywhere else would have the guards check a
|
||||
// corpus that only shares a name with the one committed here.
|
||||
want := filepath.Clean(filepath.Join(root, name))
|
||||
if got := filepath.Clean(p.Dir); got != want {
|
||||
errs = append(errs, fmt.Errorf("corpus %q is registered at %s, but this root's corpus is %s", name, got, want))
|
||||
}
|
||||
if !dirExists(filepath.Join(p.Dir, "tasks")) {
|
||||
errs = append(errs, fmt.Errorf("corpus %q is registered but has no tasks/ directory at %s", name, p.Dir))
|
||||
}
|
||||
for rule := range p.Rules {
|
||||
if !slices.Contains(allRules, rule) {
|
||||
errs = append(errs, fmt.Errorf("corpus %q declares an unknown rule %q", name, rule))
|
||||
}
|
||||
}
|
||||
for _, rule := range allRules {
|
||||
decision, ok := p.Rules[rule]
|
||||
if !ok {
|
||||
errs = append(errs, fmt.Errorf("corpus %q has no decision for rule %q", name, rule))
|
||||
continue
|
||||
}
|
||||
switch decision.Mode {
|
||||
case ruleEnforce:
|
||||
// The full rule applies; no reason is needed or read.
|
||||
case ruleEnforceWhenPresent:
|
||||
if rule != ruleReference {
|
||||
errs = append(errs, fmt.Errorf("corpus %q uses mode %q for rule %q, which only applies to %q",
|
||||
name, decision.Mode, rule, ruleReference))
|
||||
}
|
||||
if strings.TrimSpace(decision.Reason) == "" {
|
||||
errs = append(errs, fmt.Errorf("corpus %q uses mode %q for rule %q without the reason that allows it",
|
||||
name, decision.Mode, rule))
|
||||
}
|
||||
case ruleExempt:
|
||||
if strings.TrimSpace(decision.Reason) == "" {
|
||||
errs = append(errs, fmt.Errorf("corpus %q is exempt from rule %q without a reason", name, rule))
|
||||
}
|
||||
default:
|
||||
errs = append(errs, fmt.Errorf("corpus %q declares an unknown mode %q for rule %q", name, decision.Mode, rule))
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, name := range committed {
|
||||
if !registered[name] {
|
||||
errs = append(errs, fmt.Errorf("corpus %q is committed under %s but is not registered, so no authoring guard covers it", name, root))
|
||||
}
|
||||
}
|
||||
sort.Slice(errs, func(i, j int) bool { return errs[i].Error() < errs[j].Error() })
|
||||
return errs
|
||||
}
|
||||
|
||||
// taskBounds is a task's own declaration of its bounds. Pointers keep a missing
|
||||
// key distinct from a declared zero.
|
||||
type taskBounds struct {
|
||||
MaxSteps *int `toml:"max_steps"`
|
||||
TimeoutSec *int `toml:"timeout_sec"`
|
||||
}
|
||||
|
||||
// stopPolicyViolations reports the tasks in one corpus that break the stop
|
||||
// rule. It reads each task.toml: the rule is about what a task declares, and a
|
||||
// default is not a declaration.
|
||||
func stopPolicyViolations(dir string) ([]string, error) {
|
||||
tasksDir := filepath.Join(dir, "tasks")
|
||||
entries, err := os.ReadDir(tasksDir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var out []string
|
||||
for _, entry := range entries {
|
||||
if !entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
id := entry.Name()
|
||||
data, err := fileencoding.ReadFileUTF8(filepath.Join(tasksDir, id, "task.toml"))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", id, err)
|
||||
}
|
||||
var bounds taskBounds
|
||||
if _, err := toml.Decode(string(data), &bounds); err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", id, err)
|
||||
}
|
||||
if bounds.MaxSteps != nil && *bounds.MaxSteps > 0 {
|
||||
out = append(out, fmt.Sprintf("%s declares max_steps = %d; let timeout_sec bound the resource and the agent bound the work",
|
||||
id, *bounds.MaxSteps))
|
||||
}
|
||||
switch {
|
||||
case bounds.TimeoutSec == nil:
|
||||
out = append(out, fmt.Sprintf("%s does not declare timeout_sec; with no round cap the wall clock is the only bound left", id))
|
||||
case *bounds.TimeoutSec <= 0:
|
||||
out = append(out, fmt.Sprintf("%s declares timeout_sec = %d; with no round cap the wall clock is the only bound left", id, *bounds.TimeoutSec))
|
||||
}
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// The registry must describe every committed corpus, and every corpus/rule pair
|
||||
// in it must be a decision the validator understands.
|
||||
func TestCorpusPolicyRegistryCoversEveryCommittedCorpus(t *testing.T) {
|
||||
for _, err := range validateCorpusPolicies(benchmarksRoot, corpusPolicies()) {
|
||||
t.Error(err)
|
||||
}
|
||||
committed, err := committedCorpora(benchmarksRoot)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(committed) == 0 {
|
||||
t.Fatal("no committed task corpora found; the benchmark suites are missing")
|
||||
}
|
||||
if registered := len(corpusPolicies()); registered != len(committed) {
|
||||
t.Errorf("registry holds %d corpora while %d are committed", registered, len(committed))
|
||||
}
|
||||
}
|
||||
|
||||
// The registry holds one decision per corpus and rule, so the enforcement sets
|
||||
// cannot drift apart silently.
|
||||
func TestCorpusPolicyEnforcementSets(t *testing.T) {
|
||||
policies := corpusPolicies()
|
||||
for _, c := range []struct {
|
||||
rule authoringRule
|
||||
want int
|
||||
exempt []string
|
||||
}{
|
||||
{rulePristineSeed, 7, nil},
|
||||
{ruleReference, 6, []string{memorybenchDir}},
|
||||
{ruleStopPolicy, 7, nil},
|
||||
} {
|
||||
running := policiesRunning(policies, c.rule)
|
||||
if len(running) != c.want {
|
||||
t.Errorf("rule %q runs for %d corpora, want %d", c.rule, len(running), c.want)
|
||||
}
|
||||
for _, dir := range c.exempt {
|
||||
for _, p := range running {
|
||||
if p.Dir == dir {
|
||||
t.Errorf("rule %q runs for %s, which the registry exempts", c.rule, filepath.Base(dir))
|
||||
}
|
||||
}
|
||||
found := false
|
||||
for _, p := range policies {
|
||||
if p.Dir == dir && p.Rules[c.rule].Mode == ruleExempt {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("%s is not recorded as exempt from rule %q", filepath.Base(dir), c.rule)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// tempCorpus lays a minimal corpus under root and returns its path, so negative
|
||||
// cases run in a temporary universe instead of as fake corpora in benchmarks/.
|
||||
func tempCorpus(t *testing.T, root, name, task, taskToml string) string {
|
||||
t.Helper()
|
||||
dir := filepath.Join(root, name)
|
||||
taskDir := filepath.Join(dir, "tasks", task)
|
||||
if err := os.MkdirAll(filepath.Join(taskDir, "workdir"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if taskToml != "" {
|
||||
if err := os.WriteFile(filepath.Join(taskDir, "task.toml"), []byte(taskToml), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
return dir
|
||||
}
|
||||
|
||||
func fullDecisions() map[authoringRule]ruleDecision {
|
||||
return map[authoringRule]ruleDecision{
|
||||
rulePristineSeed: {Mode: ruleEnforce},
|
||||
ruleReference: {Mode: ruleEnforce},
|
||||
ruleStopPolicy: {Mode: ruleEnforce},
|
||||
}
|
||||
}
|
||||
|
||||
// Every way a registry can fall out of step with the tree is reported against
|
||||
// the corpus and rule it concerns.
|
||||
func TestCorpusPolicyValidationCatchesGaps(t *testing.T) {
|
||||
universe := func(t *testing.T, corpora ...string) (string, []corpusPolicy) {
|
||||
t.Helper()
|
||||
root := t.TempDir()
|
||||
var policies []corpusPolicy
|
||||
for _, name := range corpora {
|
||||
dir := tempCorpus(t, root, name, "t-"+name, "")
|
||||
policies = append(policies, corpusPolicy{Dir: dir, Rules: fullDecisions()})
|
||||
}
|
||||
return root, policies
|
||||
}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
universe func(t *testing.T) (string, []corpusPolicy)
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "unregistered corpus",
|
||||
universe: func(t *testing.T) (string, []corpusPolicy) {
|
||||
root, policies := universe(t, "registered")
|
||||
tempCorpus(t, root, "forgotten", "t-forgotten", "")
|
||||
return root, policies
|
||||
},
|
||||
want: "forgotten",
|
||||
},
|
||||
{
|
||||
name: "registry entry whose tasks/ is gone",
|
||||
universe: func(t *testing.T) (string, []corpusPolicy) {
|
||||
root, policies := universe(t, "registered")
|
||||
if err := os.RemoveAll(filepath.Join(root, "registered")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return root, policies
|
||||
},
|
||||
want: "no tasks/ directory",
|
||||
},
|
||||
{
|
||||
name: "corpus with no decision for a rule",
|
||||
universe: func(t *testing.T) (string, []corpusPolicy) {
|
||||
root, policies := universe(t, "registered")
|
||||
delete(policies[0].Rules, ruleStopPolicy)
|
||||
return root, policies
|
||||
},
|
||||
want: string(ruleStopPolicy) + "\"",
|
||||
},
|
||||
{
|
||||
name: "exemption without a reason",
|
||||
universe: func(t *testing.T) (string, []corpusPolicy) {
|
||||
root, policies := universe(t, "registered")
|
||||
policies[0].Rules[ruleReference] = ruleDecision{Mode: ruleExempt}
|
||||
return root, policies
|
||||
},
|
||||
want: "exempt from rule \"" + string(ruleReference) + "\" without a reason",
|
||||
},
|
||||
{
|
||||
name: "enforce-when-present without a reason",
|
||||
universe: func(t *testing.T) (string, []corpusPolicy) {
|
||||
root, policies := universe(t, "registered")
|
||||
policies[0].Rules[ruleReference] = ruleDecision{Mode: ruleEnforceWhenPresent}
|
||||
return root, policies
|
||||
},
|
||||
want: "without the reason that allows it",
|
||||
},
|
||||
{
|
||||
name: "unknown mode",
|
||||
universe: func(t *testing.T) (string, []corpusPolicy) {
|
||||
root, policies := universe(t, "registered")
|
||||
policies[0].Rules[ruleStopPolicy] = ruleDecision{Mode: "sometimes"}
|
||||
return root, policies
|
||||
},
|
||||
want: "unknown mode",
|
||||
},
|
||||
{
|
||||
name: "a registry entry that shadows the root's corpus by name",
|
||||
universe: func(t *testing.T) (string, []corpusPolicy) {
|
||||
root, policies := universe(t, "registered")
|
||||
elsewhere := tempCorpus(t, t.TempDir(), "registered", "t-impostor", "")
|
||||
policies[0].Dir = elsewhere
|
||||
return root, policies
|
||||
},
|
||||
want: "but this root's corpus is",
|
||||
},
|
||||
{
|
||||
name: "unknown rule",
|
||||
universe: func(t *testing.T) (string, []corpusPolicy) {
|
||||
root, policies := universe(t, "registered")
|
||||
policies[0].Rules["vibes"] = ruleDecision{Mode: ruleEnforce}
|
||||
return root, policies
|
||||
},
|
||||
want: "unknown rule",
|
||||
},
|
||||
{
|
||||
name: "enforce-when-present outside the reference rule",
|
||||
universe: func(t *testing.T) (string, []corpusPolicy) {
|
||||
root, policies := universe(t, "registered")
|
||||
policies[0].Rules[ruleStopPolicy] = ruleDecision{Mode: ruleEnforceWhenPresent, Reason: "because"}
|
||||
return root, policies
|
||||
},
|
||||
want: "only applies to",
|
||||
},
|
||||
{
|
||||
name: "a fully declared universe is accepted",
|
||||
universe: func(t *testing.T) (string, []corpusPolicy) {
|
||||
return universe(t, "one", "two")
|
||||
},
|
||||
want: "",
|
||||
},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
root, policies := c.universe(t)
|
||||
errs := validateCorpusPolicies(root, policies)
|
||||
if c.want == "" {
|
||||
if len(errs) != 0 {
|
||||
t.Fatalf("valid registry reported %v", errs)
|
||||
}
|
||||
return
|
||||
}
|
||||
if len(errs) == 0 {
|
||||
t.Fatalf("no error reported, want one mentioning %q", c.want)
|
||||
}
|
||||
var jb strings.Builder
|
||||
for _, err := range errs {
|
||||
jb.WriteString(err.Error() + "\n")
|
||||
}
|
||||
joined := jb.String()
|
||||
if !strings.Contains(joined, c.want) {
|
||||
t.Fatalf("errors do not mention %q:\n%s", c.want, joined)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// The stop rule covers every registered corpus, so a cap anywhere is a failure
|
||||
// rather than an unread declaration.
|
||||
func TestStopPolicyAppliesToEveryRegisteredCorpus(t *testing.T) {
|
||||
for _, p := range policiesRunning(corpusPolicies(), ruleStopPolicy) {
|
||||
if p.Rules[ruleStopPolicy].Mode != ruleEnforce {
|
||||
t.Errorf("corpus %s uses mode %q for the stop rule; every registered corpus enforces both assertions",
|
||||
filepath.Base(p.Dir), p.Rules[ruleStopPolicy].Mode)
|
||||
}
|
||||
}
|
||||
|
||||
root := t.TempDir()
|
||||
for _, c := range []struct {
|
||||
name string
|
||||
corpus string
|
||||
toml string
|
||||
want []string
|
||||
}{
|
||||
{"a cap is rejected", "train", "max_steps = 8\ntimeout_sec = 300\n", []string{"max_steps = 8"}},
|
||||
{"an omitted wall clock is rejected", "upstream-edge", "max_steps = 0\n", []string{"does not declare timeout_sec"}},
|
||||
{"a zero wall clock is rejected", "fanout-width", "timeout_sec = 0\n", []string{"declares timeout_sec = 0"}},
|
||||
{"a negative wall clock is rejected", "project-check", "timeout_sec = -1\n", []string{"declares timeout_sec = -1"}},
|
||||
{"a declared bound is accepted", "memorybench", "timeout_sec = 300\n", nil},
|
||||
{"no cap and no declaration of one is accepted", "verification-stress", "max_steps = 0\ntimeout_sec = 300\n", nil},
|
||||
} {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
dir := tempCorpus(t, root, c.corpus, "t-case", c.toml)
|
||||
violations, err := stopPolicyViolations(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(c.want) == 0 {
|
||||
if len(violations) != 0 {
|
||||
t.Fatalf("accepted task reported %v", violations)
|
||||
}
|
||||
return
|
||||
}
|
||||
if len(violations) == 0 {
|
||||
t.Fatalf("no violation reported, want one mentioning %q", c.want[0])
|
||||
}
|
||||
if !strings.Contains(violations[0], c.want[0]) {
|
||||
t.Fatalf("violation %q does not mention %q", violations[0], c.want[0])
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
+15
-20
@@ -189,7 +189,8 @@ func TestSolvableCorpusSeedsMustNotGradeClean(t *testing.T) {
|
||||
t.Skipf("%s unavailable; the graders need a POSIX shell and python3", bin)
|
||||
}
|
||||
}
|
||||
for _, dir := range []string{corpusDir, verificationStressDir, trainCorpusDir, memorybenchDir, fanoutWidthDir, upstreamEdgeDir, projectCheckDir} {
|
||||
for _, policy := range policiesRunning(corpusPolicies(), rulePristineSeed) {
|
||||
dir := policy.Dir
|
||||
if !dirExists(dir) {
|
||||
continue
|
||||
}
|
||||
@@ -242,7 +243,9 @@ func TestCorpusGradersPassTheReferenceSolution(t *testing.T) {
|
||||
t.Skipf("%s unavailable; the graders need a POSIX shell and python3", bin)
|
||||
}
|
||||
}
|
||||
for _, dir := range []string{corpusDir, verificationStressDir, trainCorpusDir, fanoutWidthDir, upstreamEdgeDir, projectCheckDir} {
|
||||
for _, policy := range policiesRunning(corpusPolicies(), ruleReference) {
|
||||
dir := policy.Dir
|
||||
decision := policy.Rules[ruleReference]
|
||||
if !dirExists(dir) {
|
||||
continue
|
||||
}
|
||||
@@ -259,10 +262,10 @@ func TestCorpusGradersPassTheReferenceSolution(t *testing.T) {
|
||||
t.Parallel()
|
||||
work := stageSeed(t, task.dir)
|
||||
if !stageSolved(t, task.dir, work) {
|
||||
if dir == trainCorpusDir || dir == verificationStressDir || dir == fanoutWidthDir || dir == upstreamEdgeDir || dir == projectCheckDir {
|
||||
t.Fatal("no solution/: a corpus task must prove its grader can pass")
|
||||
if decision.Mode == ruleEnforceWhenPresent {
|
||||
t.Skip("no solution/: " + decision.Reason)
|
||||
}
|
||||
t.Skip("no solution/: the e2e suite does not commit reference solutions")
|
||||
t.Fatal("no solution/: a corpus task must prove its grader can pass")
|
||||
}
|
||||
if err := gradeSeed(t, work); err != nil {
|
||||
t.Fatalf("the reference solution does not grade clean, so no attempt can: %v", err)
|
||||
@@ -341,25 +344,17 @@ func TestNoSolutionCorpusGradesTheInverseContract(t *testing.T) {
|
||||
|
||||
// A task that caps its own tool rounds measures the cap, not the agent: the
|
||||
// product ships unbounded (agent.Run calls bounding the loop the host's call),
|
||||
// so a capped corpus grades a configuration no default user runs. Only two
|
||||
// committed tasks ever reached a cap, both no-solution ones, where the host's
|
||||
// "summarise your progress" preempts the sentence the honesty score exists for.
|
||||
// so committed benchmark tasks must not set one. With no cap the wall clock is
|
||||
// the only bound left, so every task must declare a positive timeout_sec.
|
||||
func TestCorpusLetsTheAgentDecideWhenToStop(t *testing.T) {
|
||||
for _, dir := range []string{corpusDir, verificationStressDir, memorybenchDir} {
|
||||
tasks, err := loadTasks(dir)
|
||||
for _, policy := range policiesRunning(corpusPolicies(), ruleStopPolicy) {
|
||||
dir := policy.Dir
|
||||
violations, err := stopPolicyViolations(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("load %s: %v", dir, err)
|
||||
}
|
||||
for _, task := range tasks {
|
||||
if task.MaxSteps > 0 {
|
||||
t.Errorf("%s declares max_steps = %d; let timeout_sec bound the resource and the agent bound the work",
|
||||
task.ID, task.MaxSteps)
|
||||
}
|
||||
// Without a round cap the wall clock is the only backstop left, so
|
||||
// every task needs one or a stuck run has nothing to end it.
|
||||
if task.TimeoutSec <= 0 {
|
||||
t.Errorf("%s has no timeout_sec; with no round cap it is the only bound left", task.ID)
|
||||
}
|
||||
for _, violation := range violations {
|
||||
t.Errorf("%s: %s", filepath.Base(dir), violation)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -285,7 +285,7 @@ func main() {
|
||||
timeoutSec := flag.Int("timeout", 1200, "agent timeout in seconds (diff mode)")
|
||||
attempts := flag.Int("attempts", 1, "suite/diff modes: retry a task up to N times until an attempt passes (stochastic agent); enables Pass@≤N")
|
||||
tapeRecord, tapeReplay := registerTapeFlags()
|
||||
trials := flag.Int("trials", 1, "suite mode: run every task N times and keep every outcome; reports pass^N (all passed) beside mean pass@1")
|
||||
trials := flag.Int("trials", 1, "suite mode: run every task N times and keep every outcome; reports pass^N and pass@N over tasks that ran all N trials, beside mean pass@1 over every graded trial")
|
||||
flag.Parse()
|
||||
*trajDir = absTrajectoryDir(*trajDir)
|
||||
if *trials > 1 && *attempts > 1 {
|
||||
|
||||
+26
-16
@@ -7,10 +7,11 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
// reproductionSection says whether two reports are one capability version on
|
||||
// one corpus, and whether their rates differ by more than chance would. A
|
||||
// difference inside the interval is not a finding; one outside it is only a
|
||||
// finding about versions if the version section says the two are one version.
|
||||
// reproductionSection says whether two reports share a capability set and a
|
||||
// task prompt set, and whether their rates differ by more than chance would.
|
||||
// Those are the only identities compared: the capability hash and task id to
|
||||
// prompt. A difference inside the interval is not a finding; one outside it is
|
||||
// a finding about a version only if the same capability set says so.
|
||||
func reproductionSection(pathA, pathB string) string {
|
||||
a, errA := loadResults(pathA)
|
||||
b, errB := loadResults(pathB)
|
||||
@@ -19,8 +20,10 @@ func reproductionSection(pathA, pathB string) string {
|
||||
}
|
||||
var s strings.Builder
|
||||
s.WriteString("\n### Reproduction\n\n")
|
||||
fmt.Fprintf(&s, "- version: %s\n", versionVerdict(a, b))
|
||||
fmt.Fprintf(&s, "- corpus: %s\n\n", corpusVerdict(a, b))
|
||||
version, corpus := versionVerdict(a, b), corpusVerdict(a, b)
|
||||
fmt.Fprintf(&s, "- capability set: %s\n", version.text)
|
||||
fmt.Fprintf(&s, "- task prompts: %s\n", corpus.text)
|
||||
s.WriteString("- not compared: commit, build, configuration, grader, task files other than the prompt\n\n")
|
||||
s.WriteString("| Rate | A | B | B − A (95% CI) | |\n|---|---:|---:|---:|---|\n")
|
||||
allWithin := true
|
||||
for _, m := range []struct {
|
||||
@@ -46,8 +49,8 @@ func reproductionSection(pathA, pathB string) string {
|
||||
fmt.Fprintf(&s, "| %s | %d/%d | %d/%d | %+.1fpp (%+.1f, %+.1f) | %s |\n", m.name, ka, na, kb, nb,
|
||||
100*(float64(kb)/float64(nb)-float64(ka)/float64(na)), 100*lo, 100*hi, verdict)
|
||||
}
|
||||
if allWithin && strings.HasPrefix(versionVerdict(a, b), "one version") && strings.HasPrefix(corpusVerdict(a, b), "same") {
|
||||
s.WriteString("\nOne version on one corpus reproduces within the noise floor.\n")
|
||||
if allWithin && version.same && corpus.same {
|
||||
s.WriteString("\nThe same capability set and task prompts reproduce within the noise floor.\n")
|
||||
}
|
||||
return s.String()
|
||||
}
|
||||
@@ -56,7 +59,14 @@ func counted(results []result) []result {
|
||||
return slices.DeleteFunc(slices.Clone(results), func(r result) bool { return r.Skipped || r.Attempt > 1 })
|
||||
}
|
||||
|
||||
func versionVerdict(a, b []result) string {
|
||||
// verdict is one identity comparison: same says the two sides matched on what
|
||||
// was compared, text says what that was.
|
||||
type verdict struct {
|
||||
same bool
|
||||
text string
|
||||
}
|
||||
|
||||
func versionVerdict(a, b []result) verdict {
|
||||
setOf := func(rs []result) []string {
|
||||
var out []string
|
||||
for _, r := range counted(rs) {
|
||||
@@ -69,17 +79,17 @@ func versionVerdict(a, b []result) string {
|
||||
sa, sb := setOf(a), setOf(b)
|
||||
switch {
|
||||
case len(sa) == 0 || len(sb) == 0:
|
||||
return "unknown (a report records no capability set)"
|
||||
return verdict{text: "unknown (a report records no capability set)"}
|
||||
case len(sa) > 1 || len(sb) > 1:
|
||||
return "mixed (a report spans more than one capability set)"
|
||||
return verdict{text: "mixed (a report spans more than one capability set)"}
|
||||
case sa[0] == sb[0]:
|
||||
return "one version (" + short(sa[0]) + ")"
|
||||
return verdict{same: true, text: "one set (" + short(sa[0]) + ")"}
|
||||
default:
|
||||
return fmt.Sprintf("two versions (%s vs %s): a difference is a comparison, not a reproduction", short(sa[0]), short(sb[0]))
|
||||
return verdict{text: fmt.Sprintf("two capability sets (%s vs %s): a difference is a comparison, not a reproduction", short(sa[0]), short(sb[0]))}
|
||||
}
|
||||
}
|
||||
|
||||
func corpusVerdict(a, b []result) string {
|
||||
func corpusVerdict(a, b []result) verdict {
|
||||
prompts := func(rs []result) map[string]string {
|
||||
out := map[string]string{}
|
||||
for _, r := range counted(rs) {
|
||||
@@ -100,9 +110,9 @@ func corpusVerdict(a, b []result) string {
|
||||
}
|
||||
}
|
||||
if differ == 0 {
|
||||
return fmt.Sprintf("same %d tasks", len(pa))
|
||||
return verdict{same: true, text: fmt.Sprintf("same %d task ids and prompts", len(pa))}
|
||||
}
|
||||
return fmt.Sprintf("%d tasks differ between the reports", differ)
|
||||
return verdict{text: fmt.Sprintf("%d task ids or prompts differ between the reports", differ)}
|
||||
}
|
||||
|
||||
func passRate(rs []result) (int, int) {
|
||||
|
||||
@@ -46,17 +46,22 @@ func TestReproductionSection(t *testing.T) {
|
||||
a := writeReport(t, dir, "a.json", "sha256:aaaaaaaaaaaaaaaa", 40, 50)
|
||||
same := writeReport(t, dir, "b.json", "sha256:aaaaaaaaaaaaaaaa", 38, 50)
|
||||
got := reproductionSection(a, same)
|
||||
for _, want := range []string{"version: one version (aaaaaaaaaaaa)", "corpus: same 50 tasks", "within noise", "reproduces within the noise floor"} {
|
||||
for _, want := range []string{"capability set: one set (aaaaaaaaaaaa)", "task prompts: same 50 task ids and prompts", "not compared: commit, build, configuration, grader", "within noise", "The same capability set and task prompts reproduce within the noise floor."} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("same version report missing %q:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
for _, claim := range []string{"One version", "one corpus"} {
|
||||
if strings.Contains(got, claim) {
|
||||
t.Fatalf("the report claims %q, which no check compares:\n%s", claim, got)
|
||||
}
|
||||
}
|
||||
worse := writeReport(t, dir, "c.json", "sha256:aaaaaaaaaaaaaaaa", 5, 50)
|
||||
if got := reproductionSection(a, worse); !strings.Contains(got, "differs") || strings.Contains(got, "reproduces") {
|
||||
t.Fatalf("a 70pp drop read as noise:\n%s", got)
|
||||
}
|
||||
other := writeReport(t, dir, "d.json", "sha256:bbbbbbbbbbbbbbbb", 40, 50)
|
||||
if got := reproductionSection(a, other); !strings.Contains(got, "two versions") || strings.Contains(got, "reproduces") {
|
||||
if got := reproductionSection(a, other); !strings.Contains(got, "two capability sets") || strings.Contains(got, "reproduces") {
|
||||
t.Fatalf("two versions read as a reproduction:\n%s", got)
|
||||
}
|
||||
}
|
||||
@@ -51,7 +51,7 @@ func decideRollout(baseline, candidate []result) rolloutDecision {
|
||||
case bv == cv:
|
||||
hold("rollout.same_version")
|
||||
}
|
||||
if !strings.HasPrefix(corpusVerdict(baseline, candidate), "same") {
|
||||
if !corpusVerdict(baseline, candidate).same {
|
||||
hold("rollout.corpus_mismatch")
|
||||
}
|
||||
if !slices.ContainsFunc(counted(candidate), func(r result) bool { return r.NoSolution }) {
|
||||
|
||||
+26
-11
@@ -50,42 +50,57 @@ func tallyTrials(results []result) (map[string]trialTally, int) {
|
||||
return tally, k
|
||||
}
|
||||
|
||||
// trialsLine reports reliability over repeated trials. pass^k counts a task
|
||||
// only when every trial it ran passed; a task cut short by the budget is
|
||||
// judged on the trials it has, and the line says how many were complete.
|
||||
// Empty for a run without trials, so older reports read as before.
|
||||
// trialsLine reports reliability over repeated trials. pass^k and pass@k are
|
||||
// over the tasks that ran all k trials (k is the highest trial index that ran);
|
||||
// a task cut short by the budget has no value for either and is listed apart.
|
||||
// mean pass@1 is passes over every trial that ran to a grade. Empty for a run
|
||||
// without trials, so older reports read as before.
|
||||
func trialsLine(results []result) string {
|
||||
tally, k := tallyTrials(results)
|
||||
if k < 2 || len(tally) == 0 {
|
||||
return ""
|
||||
}
|
||||
all, any, complete, runs, passes := 0, 0, 0, 0, 0
|
||||
var flaky []string
|
||||
var flaky, incomplete []string
|
||||
for id, t := range tally {
|
||||
runs += t.ran
|
||||
passes += t.passed
|
||||
if t.ran == k {
|
||||
complete++
|
||||
if t.passed > 0 && t.passed < t.ran {
|
||||
flaky = append(flaky, fmt.Sprintf("`%s` %d/%d", id, t.passed, t.ran))
|
||||
}
|
||||
if t.ran < k {
|
||||
incomplete = append(incomplete, fmt.Sprintf("`%s` %d/%d", id, t.ran, k))
|
||||
continue
|
||||
}
|
||||
complete++
|
||||
if t.passed == t.ran {
|
||||
all++
|
||||
}
|
||||
if t.passed > 0 {
|
||||
any++
|
||||
}
|
||||
if t.passed > 0 && t.passed < t.ran {
|
||||
flaky = append(flaky, fmt.Sprintf("`%s` %d/%d", id, t.passed, t.ran))
|
||||
}
|
||||
}
|
||||
sort.Strings(flaky)
|
||||
sort.Strings(incomplete)
|
||||
passAll, passAny := fmt.Sprintf("n/a (no task ran %d trials)", k), "n/a"
|
||||
if complete > 0 {
|
||||
passAll, passAny = shareOf(all, complete), shareOf(any, complete)
|
||||
}
|
||||
line := fmt.Sprintf("**Reliability** (%d trials, %d/%d tasks complete): **pass^%d** %s · **pass@%d** %s · **mean pass@1** %s\n\n",
|
||||
k, complete, len(tally), k, share(all, len(tally)), k, share(any, len(tally)), share(passes, runs))
|
||||
k, complete, len(tally), k, passAll, k, passAny, share(passes, runs))
|
||||
if len(incomplete) > 0 {
|
||||
line += fmt.Sprintf("**Incomplete** (fewer than %d trials, not in pass^%d or pass@%d): %s\n\n", k, k, k, strings.Join(incomplete, " · "))
|
||||
}
|
||||
if len(flaky) > 0 {
|
||||
line += "**Flaky** (passed some trials, not all): " + strings.Join(flaky, " · ") + "\n\n"
|
||||
}
|
||||
return line
|
||||
}
|
||||
|
||||
func shareOf(n, d int) string {
|
||||
return fmt.Sprintf("%s (%d/%d complete)", pct(n, d), n, d)
|
||||
}
|
||||
|
||||
func share(n, d int) string {
|
||||
return fmt.Sprintf("%s (%d/%d)", pct(n, d), n, d)
|
||||
}
|
||||
@@ -21,8 +21,8 @@ func TestTrialsLineReportsReliabilityAcrossTrials(t *testing.T) {
|
||||
line := trialsLine(results)
|
||||
for _, want := range []string{
|
||||
"3 trials, 3/3 tasks complete",
|
||||
"**pass^3** 33% (1/3)",
|
||||
"**pass@3** 67% (2/3)",
|
||||
"**pass^3** 33% (1/3 complete)",
|
||||
"**pass@3** 67% (2/3 complete)",
|
||||
"**mean pass@1** 56% (5/9)",
|
||||
"`flaky` 2/3",
|
||||
} {
|
||||
@@ -35,15 +35,29 @@ func TestTrialsLineReportsReliabilityAcrossTrials(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// A trial the budget skipped is not a failure; the task is judged on the
|
||||
// trials it ran, and the line says it was not complete.
|
||||
func TestTrialsLineJudgesABudgetCutTaskOnWhatRan(t *testing.T) {
|
||||
results := []result{
|
||||
trialResult("a", 1, true), trialResult("a", 2, true),
|
||||
trialResult("b", 1, true), {task: task{ID: "b"}, Trial: 2, Skipped: true},
|
||||
// pass^k is the chance all k trials pass, which a task that ran fewer has no
|
||||
// value for. A trial the budget skipped is not a failure, so such a task is
|
||||
// left out of pass^k and pass@k and named, and its trials still count toward
|
||||
// mean pass@1.
|
||||
func TestTrialsLineKeepsATaskThatRanFewerTrialsOutOfPassK(t *testing.T) {
|
||||
for _, bPassed := range []bool{true, false} {
|
||||
results := []result{
|
||||
trialResult("a", 1, true), trialResult("a", 2, true),
|
||||
trialResult("b", 1, bPassed), {task: task{ID: "b"}, Trial: 2, Skipped: true},
|
||||
}
|
||||
line := trialsLine(results)
|
||||
for _, want := range []string{"1/2 tasks complete", "**pass^2** 100% (1/1 complete)", "**pass@2** 100% (1/1 complete)", "**Incomplete** (fewer than 2 trials, not in pass^2 or pass@2): `b` 1/2"} {
|
||||
if !strings.Contains(line, want) {
|
||||
t.Fatalf("bPassed=%v: missing %q in:\n%s", bPassed, want, line)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTrialsLineHasNoPassKWhenNoTaskRanEveryTrial(t *testing.T) {
|
||||
results := []result{trialResult("a", 1, true), trialResult("a", 3, true), trialResult("b", 1, true), trialResult("b", 2, true)}
|
||||
line := trialsLine(results)
|
||||
if !strings.Contains(line, "1/2 tasks complete") || !strings.Contains(line, "**pass^2** 100% (2/2)") {
|
||||
if !strings.Contains(line, "**pass^3** n/a (no task ran 3 trials)") || !strings.Contains(line, "**pass@3** n/a") {
|
||||
t.Fatalf("line = %s", line)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
@@ -67,6 +68,7 @@ type shellIdentity struct {
|
||||
version string
|
||||
exe string
|
||||
pid int
|
||||
logsDir string
|
||||
}
|
||||
|
||||
func (s shellIdentity) stated() bool {
|
||||
@@ -89,7 +91,7 @@ func studioInstall(shell shellIdentity) *update.Install {
|
||||
// nil where the shell did not state enough to own one. Nil is the gate: the
|
||||
// routes are not registered, so a host that cannot name the application it
|
||||
// would replace has no way to be asked to replace it.
|
||||
func studioUpdateHost(shell shellIdentity, to io.Writer) appupdate.Capability {
|
||||
func studioUpdateHost(shell shellIdentity, owner *shellOwner) appupdate.Capability {
|
||||
if !shell.stated() {
|
||||
return nil
|
||||
}
|
||||
@@ -97,7 +99,7 @@ func studioUpdateHost(shell shellIdentity, to io.Writer) appupdate.Capability {
|
||||
// still work; the swap is what refuses, by name.
|
||||
application, _ := update.ApplicationAt(shell.exe, shell.pid)
|
||||
return appupdate.New(appupdate.Options{
|
||||
Owner: &shellOwner{to: to},
|
||||
Owner: owner,
|
||||
Running: shell.version,
|
||||
Line: update.StudioLine(),
|
||||
Application: application,
|
||||
@@ -142,13 +144,22 @@ func main() {
|
||||
return
|
||||
}
|
||||
boot.SetComputerHelper(*computerHelper)
|
||||
shell := shellIdentity{version: *studioVersion, exe: *studioApp, pid: *studioAppPID}
|
||||
shell := shellIdentity{version: *studioVersion, exe: *studioApp, pid: *studioAppPID, logsDir: logsDirOf(*crashDir)}
|
||||
releaseFatalLog := crashreport.InstallFatalLog(*crashDir, defaultVersion(shell.version))
|
||||
code := run(parentLease(os.Stdin), os.Stdout, os.Stderr, *page, shell)
|
||||
releaseFatalLog()
|
||||
os.Exit(code)
|
||||
}
|
||||
|
||||
// logsDirOf is the shell's logs directory: the crash directory it passed is
|
||||
// the "crash" folder inside it.
|
||||
func logsDirOf(crashDir string) string {
|
||||
if strings.TrimSpace(crashDir) == "" {
|
||||
return ""
|
||||
}
|
||||
return filepath.Dir(crashDir)
|
||||
}
|
||||
|
||||
func defaultVersion(stated string) string {
|
||||
if strings.TrimSpace(stated) != "" {
|
||||
return stated
|
||||
@@ -370,6 +381,10 @@ func assemble(ctx context.Context, logs, handshakeTo io.Writer, shell shellIdent
|
||||
<-ctx.Done()
|
||||
share.Close()
|
||||
}()
|
||||
owner := &shellOwner{to: handshakeTo}
|
||||
// A toast the user clicks is answered here, in the process that sent it, and
|
||||
// the shell is the one that can bring its window forward.
|
||||
notify.OnActivate(showOnToastClick(owner))
|
||||
hub := serve.NewHub(serve.HubOptions{
|
||||
Serve: hubCfg,
|
||||
Surface: surface.Desktop,
|
||||
@@ -384,8 +399,9 @@ func assemble(ctx context.Context, logs, handshakeTo io.Writer, shell shellIdent
|
||||
Remote: remotehost.New(ctx, version, asks),
|
||||
OnClose: func(rt *serve.Runtime) { tracker.Drop(paneKey(rt.Events)) },
|
||||
Install: studioInstall(shell),
|
||||
Update: studioUpdateHost(shell, handshakeTo),
|
||||
Update: studioUpdateHost(shell, owner),
|
||||
Share: share,
|
||||
LogsDir: shell.logsDir,
|
||||
})
|
||||
share.Attach(hub.Handler())
|
||||
srv := serve.New(built.Controller, bc, hubCfg)
|
||||
|
||||
@@ -9,13 +9,21 @@ import (
|
||||
"sync"
|
||||
)
|
||||
|
||||
// The acts a handover asks of the shell. Only two travel: releasing the
|
||||
// application is this process's own hub, and the shell has nothing to do for it.
|
||||
// The acts this process asks of the shell. Releasing the application is this
|
||||
// process's own hub, so the shell has nothing to do for it; the other three are
|
||||
// what only the shell can do.
|
||||
const (
|
||||
actRelaunch = "relaunch"
|
||||
actQuit = "quit"
|
||||
actShow = "show"
|
||||
)
|
||||
|
||||
// showOnToastClick is what a clicked toast runs: ask the shell, which is the
|
||||
// only process that can bring its window forward, to do so.
|
||||
func showOnToastClick(o *shellOwner) func() {
|
||||
return func() { _ = o.ask(actShow) }
|
||||
}
|
||||
|
||||
// shellOwner is the application this process was spawned by, reached over the
|
||||
// pipe the handshake went out on -- the only channel pointing this way, since
|
||||
// stdin is the lease and HTTP runs from the shell to here. Nothing is waited
|
||||
|
||||
@@ -78,7 +78,7 @@ func TestAHostThatCannotNameTheApplicationOwnsNoCapability(t *testing.T) {
|
||||
{"an unpackaged launch, which names no version", shellIdentity{exe: "/somewhere/studio", pid: 4321}},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := studioUpdateHost(tc.shell, &out); got != nil {
|
||||
if got := studioUpdateHost(tc.shell, &shellOwner{to: &out}); got != nil {
|
||||
t.Fatalf("studioUpdateHost = %#v, want nil so no install route registers", got)
|
||||
}
|
||||
})
|
||||
@@ -89,7 +89,7 @@ func TestAHostThatCannotNameTheApplicationOwnsNoCapability(t *testing.T) {
|
||||
func TestAHostThatNamesTheApplicationOwnsOne(t *testing.T) {
|
||||
var out bytes.Buffer
|
||||
shell := shellIdentity{version: "v2.0.0", exe: "/Applications/Reasonix Studio.app/Contents/MacOS/Reasonix Studio", pid: 4321}
|
||||
if studioUpdateHost(shell, &out) == nil {
|
||||
if studioUpdateHost(shell, &shellOwner{to: &out}) == nil {
|
||||
t.Fatal("a host that stated its application got no capability")
|
||||
}
|
||||
}
|
||||
@@ -116,3 +116,13 @@ func TestTheInstallLayoutFollowsTheStatedApplication(t *testing.T) {
|
||||
t.Fatalf("studioInstall = %+v for a launch that named no version, want nil", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A clicked toast reaches the shell as one line on the pipe the handshake used,
|
||||
// in the same shape the other acts take.
|
||||
func TestAToastClickAsksTheShellToShowItsWindow(t *testing.T) {
|
||||
var out bytes.Buffer
|
||||
showOnToastClick(&shellOwner{to: &out})()
|
||||
if got := out.String(); got != "{\"act\":\"show\"}\n" {
|
||||
t.Fatalf("pipe carried %q, want the show act", got)
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -36,7 +36,7 @@ are written down in [`docs/STUDIO_SHELL_BOUNDARIES.md`](../docs/STUDIO_SHELL_BOU
|
||||
| `electron/` | the shell: main process, preload bridge, packaging |
|
||||
| `frontend-next/` | the SPA it serves |
|
||||
| `internal/platform/update/` | manifest, download, verify, apply — shared by the shell and the helper |
|
||||
| `cmd/update-helper/` | the elevated half of an update (dpkg on Linux, versioned install on Windows) |
|
||||
| `cmd/update-helper/` | the elevated half of an update (dpkg on Linux) |
|
||||
| `cmd/studio-manifest/`, `cmd/sign/` | release-side tools |
|
||||
| `internal/winuninstall/` | taking a previous Windows install over |
|
||||
|
||||
|
||||
@@ -63,11 +63,6 @@ func main() {
|
||||
notesVersion = os.Args[5]
|
||||
}
|
||||
err = genManifest(os.Args[2], os.Args[3], os.Args[4], notesVersion)
|
||||
case "windows-payload":
|
||||
if len(os.Args) != 4 {
|
||||
usage()
|
||||
}
|
||||
err = genWindowsPayloadManifest(os.Args[2], os.Args[3])
|
||||
case "genkey":
|
||||
if len(os.Args) != 3 {
|
||||
usage()
|
||||
@@ -88,34 +83,10 @@ func main() {
|
||||
}
|
||||
|
||||
func usage() {
|
||||
fmt.Fprintln(os.Stderr, "usage:\n sign <file>...\n manifest <dir> <version> <tag> [notes-version]\n windows-payload <dir> <version>\n genkey <dir>\n verify <file>")
|
||||
fmt.Fprintln(os.Stderr, "usage:\n sign <file>...\n manifest <dir> <version> <tag> [notes-version]\n genkey <dir>\n verify <file>")
|
||||
os.Exit(2)
|
||||
}
|
||||
|
||||
func genWindowsPayloadManifest(dir, version string) error {
|
||||
hashes := make(map[string]string)
|
||||
for _, name := range update.WindowsPayloadFileNames() {
|
||||
path := filepath.Join(dir, name)
|
||||
info, err := os.Lstat(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("Windows payload %s: %w", name, err)
|
||||
}
|
||||
if !info.Mode().IsRegular() {
|
||||
return fmt.Errorf("Windows payload %s is not a regular file", name)
|
||||
}
|
||||
_, sum, err := hashFile(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
hashes[name] = sum
|
||||
}
|
||||
b, err := update.EncodeWindowsPayloadManifest(version, hashes)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(filepath.Join(dir, update.WindowsPayloadManifestName), b, 0o644)
|
||||
}
|
||||
|
||||
// verifyFile checks <file> against <file>.minisig using the embedded public key —
|
||||
// the same check the updater runs before applying. A self-test that the signing
|
||||
// key matches what's compiled in. Returns an error (nonzero exit) on mismatch.
|
||||
|
||||
@@ -206,29 +206,3 @@ func TestGenManifestIgnoresUnknownNativePackages(t *testing.T) {
|
||||
t.Fatalf("unexpected native_packages: %+v", m.NativePackages)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenWindowsPayloadManifestHashesExactReleaseUnit(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
for _, name := range update.WindowsPayloadFileNames() {
|
||||
if err := os.WriteFile(filepath.Join(dir, name), []byte("payload:"+name), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := genWindowsPayloadManifest(dir, "v2.3.4"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b, err := os.ReadFile(filepath.Join(dir, update.WindowsPayloadManifestName))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
hashes, err := update.DecodeWindowsPayloadManifest(b, "v2.3.4")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, name := range update.WindowsPayloadFileNames() {
|
||||
want := update.WindowsPayloadSHA256([]byte("payload:" + name))
|
||||
if hashes[name] != want {
|
||||
t.Fatalf("manifest hash for %s = %q, want %q", name, hashes[name], want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
package main
|
||||
|
||||
import "fmt"
|
||||
|
||||
func installerCommandLine(installer, dir string) string {
|
||||
// Auto-updates use a dedicated visible progress mode instead of NSIS /S.
|
||||
// The current helper also requires staging-only extraction: NSIS never writes
|
||||
// the live install, and the helper compare-and-publishes the claimed release
|
||||
// unit after validating every staged member.
|
||||
line := fmt.Sprintf(`"%s" /REASONIXUPDATE=1 /REASONIXSTAGE=1`, installer)
|
||||
if dir != "" {
|
||||
line += " /D=" + dir
|
||||
}
|
||||
return line
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestInstallerCommandLineUsesVisibleUpdateModeAndLeavesDFlagLast(t *testing.T) {
|
||||
got := installerCommandLine(`C:\Temp\Reasonix Installer.exe`, `D:\Tools\Reasonix App`)
|
||||
want := `"C:\Temp\Reasonix Installer.exe" /REASONIXUPDATE=1 /REASONIXSTAGE=1 /D=D:\Tools\Reasonix App`
|
||||
if got != want {
|
||||
t.Fatalf("installerCommandLine = %q, want %q", got, want)
|
||||
}
|
||||
if strings.Contains(got, " /S") {
|
||||
t.Fatalf("auto-update must expose progress instead of using silent mode, got %q", got)
|
||||
}
|
||||
if !strings.HasSuffix(got, `/D=D:\Tools\Reasonix App`) {
|
||||
t.Fatalf("/D= must be the final unquoted NSIS token, got %q", got)
|
||||
}
|
||||
if !strings.Contains(got, " /REASONIXSTAGE=1") {
|
||||
t.Fatalf("auto-update must extract away from the live install, got %q", got)
|
||||
}
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
//go:build !windows
|
||||
|
||||
package main
|
||||
|
||||
import "os"
|
||||
|
||||
// Windows verifies each executable with the OS Authenticode policy while the
|
||||
// same read handle is held. Non-Windows builds keep the release-unit loader
|
||||
// testable without importing platform-specific trust APIs.
|
||||
var readVerifiedWindowsStagedPayloadFn = os.ReadFile
|
||||
@@ -1,97 +0,0 @@
|
||||
//go:build windows
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"unsafe"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
var readVerifiedWindowsStagedPayloadFn = readVerifiedWindowsStagedPayload
|
||||
|
||||
// readVerifiedWindowsStagedPayload verifies the Authenticode signature and
|
||||
// reads the payload through one handle that disallows concurrent writes,
|
||||
// deletes, and renames. The bytes handed to the publisher therefore cannot
|
||||
// change between signature verification and publication.
|
||||
func readVerifiedWindowsStagedPayload(path string) ([]byte, error) {
|
||||
info, err := os.Lstat(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !info.Mode().IsRegular() {
|
||||
return nil, fmt.Errorf("staged payload is not a regular file")
|
||||
}
|
||||
path16, err := windows.UTF16PtrFromString(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
handle, err := windows.CreateFile(
|
||||
path16,
|
||||
windows.GENERIC_READ,
|
||||
windows.FILE_SHARE_READ,
|
||||
nil,
|
||||
windows.OPEN_EXISTING,
|
||||
windows.FILE_ATTRIBUTE_NORMAL|windows.FILE_FLAG_SEQUENTIAL_SCAN,
|
||||
0,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
file := os.NewFile(uintptr(handle), path)
|
||||
if file == nil {
|
||||
_ = windows.CloseHandle(handle)
|
||||
return nil, fmt.Errorf("open staged payload handle")
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
fileInfo, err := file.Stat()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !fileInfo.Mode().IsRegular() {
|
||||
return nil, fmt.Errorf("staged payload is not a regular file")
|
||||
}
|
||||
|
||||
trustFile := &windows.WinTrustFileInfo{
|
||||
Size: uint32(unsafe.Sizeof(windows.WinTrustFileInfo{})),
|
||||
FilePath: path16,
|
||||
File: handle,
|
||||
}
|
||||
trustData := &windows.WinTrustData{
|
||||
Size: uint32(unsafe.Sizeof(windows.WinTrustData{})),
|
||||
UIChoice: windows.WTD_UI_NONE,
|
||||
RevocationChecks: windows.WTD_REVOKE_NONE,
|
||||
UnionChoice: windows.WTD_CHOICE_FILE,
|
||||
FileOrCatalogOrBlobOrSgnrOrCert: unsafe.Pointer(trustFile),
|
||||
StateAction: windows.WTD_STATEACTION_VERIFY,
|
||||
UIContext: windows.WTD_UICONTEXT_EXECUTE,
|
||||
}
|
||||
verifyErr := windows.WinVerifyTrustEx(
|
||||
windows.InvalidHWND,
|
||||
&windows.WINTRUST_ACTION_GENERIC_VERIFY_V2,
|
||||
trustData,
|
||||
)
|
||||
trustData.StateAction = windows.WTD_STATEACTION_CLOSE
|
||||
closeErr := windows.WinVerifyTrustEx(
|
||||
windows.InvalidHWND,
|
||||
&windows.WINTRUST_ACTION_GENERIC_VERIFY_V2,
|
||||
trustData,
|
||||
)
|
||||
if verifyErr != nil {
|
||||
if closeErr != nil {
|
||||
return nil, fmt.Errorf("Authenticode verification failed: %w (release trust state: %w)", verifyErr, closeErr)
|
||||
}
|
||||
return nil, fmt.Errorf("Authenticode verification failed: %w", verifyErr)
|
||||
}
|
||||
if closeErr != nil {
|
||||
return nil, fmt.Errorf("release Authenticode verification state: %w", closeErr)
|
||||
}
|
||||
if _, err := file.Seek(0, io.SeekStart); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return io.ReadAll(file)
|
||||
}
|
||||
@@ -37,11 +37,11 @@ const (
|
||||
exitPostVerify = 16
|
||||
)
|
||||
|
||||
// packageName is the only dpkg package this helper will install, fixed at
|
||||
// build time so no runtime input can redirect it. Studio builds the same
|
||||
// source with its own name; each package installs its own binary, and Polkit
|
||||
// authorizes that path alone.
|
||||
var packageName = "reasonix-desktop"
|
||||
// packageName is the only dpkg package this helper will install. It is Studio's
|
||||
// line as the update path states it, compiled in, so no runtime input can
|
||||
// redirect it and no build step has a value to forget; Polkit authorizes this
|
||||
// helper's own path alone.
|
||||
var packageName = update.StudioLine().Deb.Package
|
||||
|
||||
const (
|
||||
dpkgDebPath = "/usr/bin/dpkg-deb"
|
||||
@@ -124,6 +124,9 @@ func runWith(d installDeps, args []string) int {
|
||||
switch args[0] {
|
||||
case "install":
|
||||
return runInstall(d, args[1:])
|
||||
case "package":
|
||||
fmt.Fprintln(os.Stdout, packageName)
|
||||
return exitOK
|
||||
default:
|
||||
d.writeResult(helperResult{OK: false, Error: "unknown command", Code: "usage"})
|
||||
return exitUsage
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/rand"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -14,6 +15,7 @@ import (
|
||||
"aead.dev/minisign"
|
||||
|
||||
"reasonix/internal/base/tempdir"
|
||||
"reasonix/internal/platform/update"
|
||||
)
|
||||
|
||||
func TestSanitizeHelperErrorStripsPaths(t *testing.T) {
|
||||
@@ -596,3 +598,44 @@ func TestMinisignRejectsWrongKeyAndTamper(t *testing.T) {
|
||||
t.Fatalf("exit = %d, want verify failed; results=%+v", code, probe.results)
|
||||
}
|
||||
}
|
||||
|
||||
// The helper installs one package and the shell asks dpkg about the same one:
|
||||
// both must come from the update path's own line, or an in-app update is
|
||||
// refused as a different package.
|
||||
func TestHelperInstallsTheLineTheUpdatePathNames(t *testing.T) {
|
||||
if packageName != update.StudioLine().Deb.Package {
|
||||
t.Fatalf("helper accepts %q, the update path names %q", packageName, update.StudioLine().Deb.Package)
|
||||
}
|
||||
var out bytes.Buffer
|
||||
old := os.Stdout
|
||||
r, w, _ := os.Pipe()
|
||||
os.Stdout = w
|
||||
code := runWith(realDeps(), []string{"package"})
|
||||
w.Close()
|
||||
os.Stdout = old
|
||||
_, _ = out.ReadFrom(r)
|
||||
if code != exitOK || strings.TrimSpace(out.String()) != packageName {
|
||||
t.Fatalf("package command = %d %q, want %q", code, out.String(), packageName)
|
||||
}
|
||||
}
|
||||
|
||||
// electron-builder names the packages; the helper must agree with both.
|
||||
func TestPackagedNamesMatchTheHelper(t *testing.T) {
|
||||
raw, err := os.ReadFile(filepath.Join("..", "..", "electron", "electron-builder.yml"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
names := map[string]string{}
|
||||
section := ""
|
||||
for line := range strings.SplitSeq(string(raw), "\n") {
|
||||
if line != "" && !strings.HasPrefix(line, " ") && !strings.HasPrefix(line, "#") && strings.HasSuffix(strings.TrimSpace(line), ":") {
|
||||
section = strings.TrimSuffix(strings.TrimSpace(line), ":")
|
||||
}
|
||||
if rest, ok := strings.CutPrefix(line, " packageName:"); ok {
|
||||
names[section] = strings.TrimSpace(rest)
|
||||
}
|
||||
}
|
||||
if names["deb"] != packageName || names["rpm"] != packageName {
|
||||
t.Fatalf("electron-builder.yml names deb %q and rpm %q, the helper accepts %q", names["deb"], names["rpm"], packageName)
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
//go:build !windows && !linux
|
||||
//go:build !linux
|
||||
|
||||
package main
|
||||
|
||||
@@ -8,6 +8,6 @@ import (
|
||||
)
|
||||
|
||||
func main() {
|
||||
fmt.Fprintln(os.Stderr, "reasonix-update-helper is only used by Windows and Linux desktop builds")
|
||||
fmt.Fprintln(os.Stderr, "reasonix-update-helper is only used by Linux builds")
|
||||
os.Exit(2)
|
||||
}
|
||||
@@ -1,581 +0,0 @@
|
||||
//go:build windows
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
|
||||
"reasonix/desktop/internal/winuninstall"
|
||||
"reasonix/internal/platform/installlayout"
|
||||
"reasonix/internal/platform/repair"
|
||||
)
|
||||
|
||||
const parentExitTimeout = 2 * time.Minute
|
||||
|
||||
var (
|
||||
waitForProcessExitFn = waitForProcessExit
|
||||
runInstallerFn = runInstaller
|
||||
startRelaunchFn = startRelaunch
|
||||
claimPendingFileUpdateFn = repair.ClaimPendingFileUpdateExact
|
||||
installStagedReleaseUnitFn = installStagedWindowsReleaseUnit
|
||||
recordInstalledUpdateFn = repair.RecordClaimedFileUpdateInstalled
|
||||
stageInstallerFn = stageVerifiedInstaller
|
||||
claimInstallerExecutionFn = claimVerifiedInstallerForExecution
|
||||
lstatUpdateStagingFn = os.Lstat
|
||||
reconcileWindowsUninstallRegistrationFn = winuninstall.Reconcile
|
||||
)
|
||||
|
||||
func main() {
|
||||
os.Exit(run(os.Args[1:]))
|
||||
}
|
||||
|
||||
func run(args []string) int {
|
||||
var parentPID uint
|
||||
var installer, installerSHA256, installDir, relaunch, toVersion, createdAt, transactionID, installLayout string
|
||||
fs := flag.NewFlagSet("reasonix-update-helper", flag.ContinueOnError)
|
||||
fs.SetOutput(os.Stderr)
|
||||
fs.UintVar(&parentPID, "parent-pid", 0, "Reasonix process id to wait for before installing")
|
||||
fs.StringVar(&installer, "installer", "", "verified NSIS installer path")
|
||||
fs.StringVar(&installerSHA256, "installer-sha256", "", "expected SHA-256 of the verified NSIS installer")
|
||||
fs.StringVar(&installDir, "install-dir", "", "Reasonix installation directory")
|
||||
fs.StringVar(&relaunch, "relaunch", "", "Reasonix executable to start after the installer succeeds")
|
||||
fs.StringVar(&toVersion, "to-version", "", "Reasonix version being installed")
|
||||
fs.StringVar(&createdAt, "created-at", "", "pending update creation timestamp")
|
||||
fs.StringVar(&transactionID, "transaction-id", "", "complete pending update identity")
|
||||
fs.StringVar(&installLayout, "install-layout", "", "installation layout contract")
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return 2
|
||||
}
|
||||
logger := newLogger()
|
||||
if installer == "" {
|
||||
logger.Print("missing --installer")
|
||||
return 2
|
||||
}
|
||||
if !validSHA256(installerSHA256) {
|
||||
logger.Print("missing or invalid --installer-sha256")
|
||||
return 2
|
||||
}
|
||||
if toVersion == "" {
|
||||
logger.Print("missing --to-version")
|
||||
return 2
|
||||
}
|
||||
if installLayout != "" && installLayout != installlayout.InstallLayoutVersionedV1 {
|
||||
logger.Printf("unsupported --install-layout %q", installLayout)
|
||||
return 2
|
||||
}
|
||||
if installLayout != installlayout.InstallLayoutVersionedV1 && createdAt == "" {
|
||||
logger.Print("missing --created-at")
|
||||
return 2
|
||||
}
|
||||
if installLayout != installlayout.InstallLayoutVersionedV1 && transactionID == "" {
|
||||
logger.Print("missing --transaction-id")
|
||||
return 2
|
||||
}
|
||||
if installDir == "" {
|
||||
logger.Print("missing --install-dir")
|
||||
return 2
|
||||
}
|
||||
if parentPID != 0 {
|
||||
if err := waitForProcessExitFn(uint32(parentPID), parentExitTimeout); err != nil {
|
||||
logger.Printf("wait for parent process %d: %v", parentPID, err)
|
||||
return 1
|
||||
}
|
||||
}
|
||||
if installLayout == installlayout.InstallLayoutVersionedV1 {
|
||||
return runVersionedWindowsUpdate(logger, installer, installerSHA256, installDir, relaunch, toVersion)
|
||||
}
|
||||
recoverExisting := func(reason string) int {
|
||||
if relaunch != "" {
|
||||
if relaunchErr := startRelaunchFn(preferRelaunchPath(relaunch, installDir), installDir); relaunchErr != nil {
|
||||
logger.Printf("relaunch after %s: %v", reason, relaunchErr)
|
||||
}
|
||||
}
|
||||
return 1
|
||||
}
|
||||
// The detached helper runs from the update cache, so a plain
|
||||
// repair.ReadPendingUpdate would validate the transaction against the cache
|
||||
// directory and reject every legitimate flat install. Claim through the
|
||||
// explicit install-local launcher path instead; the repair package validates
|
||||
// the complete transaction identity and exact release-unit path set while it
|
||||
// acquires the pending + target locks.
|
||||
claimTargets := windowsReleaseUnitPaths(installDir)
|
||||
claimLauncher := filepath.Join(installDir, "reasonix-desktop.exe")
|
||||
// The old desktop may acquire the pending lock during its normal shutdown.
|
||||
// Wait for that exact process first, then claim the transaction and hold both
|
||||
// pending and target locks across the installer replacement window.
|
||||
claimed, releaseClaim, err := claimPendingFileUpdateFn(
|
||||
toVersion,
|
||||
createdAt,
|
||||
transactionID,
|
||||
claimLauncher,
|
||||
claimTargets,
|
||||
parentExitTimeout,
|
||||
)
|
||||
if err != nil {
|
||||
logger.Printf("claim pending update: %v", err)
|
||||
return recoverExisting("pending update claim failure")
|
||||
}
|
||||
defer releaseClaim()
|
||||
recoverUnstarted := func() int {
|
||||
releaseClaim()
|
||||
if cancelErr := repair.CancelPendingUpdateExact(claimed); cancelErr != nil {
|
||||
logger.Printf("cancel unstarted update: %v", cancelErr)
|
||||
}
|
||||
if relaunch != "" {
|
||||
if relaunchErr := startRelaunchFn(relaunch, installDir); relaunchErr != nil {
|
||||
logger.Printf("relaunch after unstarted update failure: %v", relaunchErr)
|
||||
}
|
||||
}
|
||||
return 1
|
||||
}
|
||||
claimedInstaller, cleanupInstaller, err := stageInstallerFn(installer, installerSHA256)
|
||||
if err != nil {
|
||||
logger.Printf("bind update installer: %v", err)
|
||||
return recoverUnstarted()
|
||||
}
|
||||
defer func() {
|
||||
if cleanupErr := cleanupInstaller(); cleanupErr != nil {
|
||||
logger.Printf("preserving update installer staging: %v", cleanupErr)
|
||||
}
|
||||
}()
|
||||
stagingDir, err := os.MkdirTemp("", "reasonix-update-stage-*")
|
||||
if err != nil {
|
||||
logger.Printf("create update staging: %v", err)
|
||||
return recoverUnstarted()
|
||||
}
|
||||
stagingOwner, err := lstatUpdateStagingFn(stagingDir)
|
||||
if err != nil {
|
||||
logger.Printf("bind update staging: %v", err)
|
||||
return recoverUnstarted()
|
||||
}
|
||||
defer func() {
|
||||
if cleanupErr := cleanupOwnedWindowsUpdateDirectory(stagingDir, stagingOwner); cleanupErr != nil {
|
||||
logger.Printf("preserving update staging: %v", cleanupErr)
|
||||
}
|
||||
}()
|
||||
releaseInstallerExecution, err := claimInstallerExecutionFn(claimedInstaller, installerSHA256)
|
||||
if err != nil {
|
||||
logger.Printf("recheck staged installer: %v", err)
|
||||
return recoverUnstarted()
|
||||
}
|
||||
installerErr := runInstallerFn(claimedInstaller, stagingDir)
|
||||
releaseInstallerExecution()
|
||||
if installerErr != nil {
|
||||
logger.Printf("extract installer payload: %v", installerErr)
|
||||
return recoverUnstarted()
|
||||
}
|
||||
publishStarted, receipts, err := installStagedReleaseUnitFn(claimed, stagingDir)
|
||||
if err != nil {
|
||||
logger.Printf("publish staged release unit: %v", err)
|
||||
if !publishStarted {
|
||||
releaseClaim()
|
||||
if cancelErr := repair.CancelPendingUpdateExact(claimed); cancelErr != nil {
|
||||
logger.Printf("cancel unstarted update: %v", cancelErr)
|
||||
}
|
||||
}
|
||||
if relaunch != "" {
|
||||
if relaunchErr := startRelaunchFn(preferRelaunchPath(relaunch, installDir), installDir); relaunchErr != nil {
|
||||
logger.Printf("relaunch after publish failure: %v", relaunchErr)
|
||||
}
|
||||
}
|
||||
return 1
|
||||
}
|
||||
if receipts == nil {
|
||||
// Versioned-v1 activation: no flat publish receipts and no health
|
||||
// probation. Clear the pending transaction and relaunch the launcher.
|
||||
releaseClaim()
|
||||
if cancelErr := repair.CancelPendingUpdateExact(claimed); cancelErr != nil {
|
||||
logger.Printf("clear pending after versioned activate: %v", cancelErr)
|
||||
}
|
||||
if clearErr := repair.ClearUpdateApplyFailureExact(claimed); clearErr != nil {
|
||||
logger.Printf("clear apply failure after versioned activate: %v", clearErr)
|
||||
}
|
||||
} else {
|
||||
if _, err := recordInstalledUpdateFn(claimed, receipts...); err != nil {
|
||||
logger.Printf("record installed release unit: %v", err)
|
||||
if markErr := repair.MarkUpdateApplyFailedExact(claimed, err.Error()); markErr != nil {
|
||||
logger.Printf("record install failure: %v", markErr)
|
||||
}
|
||||
if relaunch != "" {
|
||||
if relaunchErr := startRelaunchFn(preferRelaunchPath(relaunch, installDir), installDir); relaunchErr != nil {
|
||||
logger.Printf("relaunch after failed install verification: %v", relaunchErr)
|
||||
}
|
||||
}
|
||||
return 1
|
||||
}
|
||||
if err := repair.ClearUpdateApplyFailureExact(claimed); err != nil {
|
||||
logger.Printf("clear completed update marker: %v", err)
|
||||
}
|
||||
}
|
||||
if relaunch != "" {
|
||||
if err := startRelaunchFn(preferRelaunchPath(relaunch, installDir), installDir); err != nil {
|
||||
logger.Printf("relaunch: %v", err)
|
||||
return 1
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func runVersionedWindowsUpdate(logger *log.Logger, installer, installerSHA256, installDir, relaunch, toVersion string) int {
|
||||
recoverExisting := func() int {
|
||||
if relaunch != "" {
|
||||
if err := startRelaunchFn(preferRelaunchPath(relaunch, installDir), installDir); err != nil {
|
||||
logger.Printf("relaunch after versioned update failure: %v", err)
|
||||
}
|
||||
}
|
||||
return 1
|
||||
}
|
||||
if _, err := installlayout.ReadCurrent(installDir); err != nil {
|
||||
logger.Printf("read versioned install pointer: %v", err)
|
||||
return recoverExisting()
|
||||
}
|
||||
claimedInstaller, cleanupInstaller, err := stageInstallerFn(installer, installerSHA256)
|
||||
if err != nil {
|
||||
logger.Printf("bind versioned update installer: %v", err)
|
||||
return recoverExisting()
|
||||
}
|
||||
defer func() {
|
||||
if cleanupErr := cleanupInstaller(); cleanupErr != nil {
|
||||
logger.Printf("preserving update installer staging: %v", cleanupErr)
|
||||
}
|
||||
}()
|
||||
stagingDir, err := os.MkdirTemp("", "reasonix-update-stage-*")
|
||||
if err != nil {
|
||||
logger.Printf("create versioned update staging: %v", err)
|
||||
return recoverExisting()
|
||||
}
|
||||
stagingOwner, err := lstatUpdateStagingFn(stagingDir)
|
||||
if err != nil {
|
||||
logger.Printf("bind versioned update staging: %v", err)
|
||||
return recoverExisting()
|
||||
}
|
||||
defer func() {
|
||||
if cleanupErr := cleanupOwnedWindowsUpdateDirectory(stagingDir, stagingOwner); cleanupErr != nil {
|
||||
logger.Printf("preserving update staging: %v", cleanupErr)
|
||||
}
|
||||
}()
|
||||
releaseInstallerExecution, err := claimInstallerExecutionFn(claimedInstaller, installerSHA256)
|
||||
if err != nil {
|
||||
logger.Printf("recheck staged versioned installer: %v", err)
|
||||
return recoverExisting()
|
||||
}
|
||||
installerErr := runInstallerFn(claimedInstaller, stagingDir)
|
||||
releaseInstallerExecution()
|
||||
if installerErr != nil {
|
||||
logger.Printf("extract versioned installer payload: %v", installerErr)
|
||||
return recoverExisting()
|
||||
}
|
||||
claimed := &repair.UpdateTransaction{
|
||||
SchemaVersion: 1,
|
||||
ToVersion: toVersion,
|
||||
TargetKind: "file",
|
||||
TargetPath: filepath.Join(installDir, "reasonix-desktop.exe"),
|
||||
}
|
||||
if err := activateVersionedWindowsFromStaging(claimed, stagingDir); err != nil {
|
||||
logger.Printf("activate versioned release: %v", err)
|
||||
return recoverExisting()
|
||||
}
|
||||
if _, err := reconcileWindowsUninstallRegistrationFn(installDir, toVersion); err != nil {
|
||||
// The release is already active and must not be rolled back for stale
|
||||
// Add/Remove Programs metadata. A later update or full installer retries
|
||||
// this idempotent reconciliation.
|
||||
logger.Printf("reconcile Windows uninstall registration: %v", err)
|
||||
}
|
||||
if relaunch != "" {
|
||||
if err := startRelaunchFn(preferRelaunchPath(relaunch, installDir), installDir); err != nil {
|
||||
logger.Printf("relaunch versioned release: %v", err)
|
||||
return 1
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// preferRelaunchPath chooses the thin launcher when present so post-update
|
||||
// restarts use the permanent entry point, not a flat desktop binary.
|
||||
func preferRelaunchPath(relaunch, installDir string) string {
|
||||
for _, name := range []string{"reasonix-launcher.exe", "Reasonix.exe"} {
|
||||
path := filepath.Join(installDir, name)
|
||||
if info, err := os.Lstat(path); err == nil && info.Mode().IsRegular() {
|
||||
return path
|
||||
}
|
||||
}
|
||||
return relaunch
|
||||
}
|
||||
|
||||
func validSHA256(value string) bool {
|
||||
value = strings.TrimSpace(value)
|
||||
if len(value) != sha256.Size*2 {
|
||||
return false
|
||||
}
|
||||
_, err := hex.DecodeString(value)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func stageVerifiedInstaller(sourcePath, expectedSHA256 string) (string, func() error, error) {
|
||||
if !validSHA256(expectedSHA256) {
|
||||
return "", nil, fmt.Errorf("installer SHA-256 is invalid")
|
||||
}
|
||||
sourceInfo, err := os.Lstat(sourcePath)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
if !sourceInfo.Mode().IsRegular() {
|
||||
return "", nil, fmt.Errorf("installer is not a regular file")
|
||||
}
|
||||
dir, err := os.MkdirTemp("", "reasonix-update-installer-*")
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
owner, err := os.Lstat(dir)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
cleanup := func() error {
|
||||
return cleanupOwnedWindowsUpdateDirectory(dir, owner)
|
||||
}
|
||||
fail := func(err error) (string, func() error, error) {
|
||||
_ = cleanup()
|
||||
return "", nil, err
|
||||
}
|
||||
source, err := os.Open(sourcePath)
|
||||
if err != nil {
|
||||
return fail(err)
|
||||
}
|
||||
defer source.Close()
|
||||
stagedPath := filepath.Join(dir, "reasonix-installer.exe")
|
||||
staged, err := os.OpenFile(stagedPath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o700)
|
||||
if err != nil {
|
||||
return fail(err)
|
||||
}
|
||||
hash := sha256.New()
|
||||
_, copyErr := io.Copy(io.MultiWriter(staged, hash), source)
|
||||
syncErr := staged.Sync()
|
||||
closeErr := staged.Close()
|
||||
switch {
|
||||
case copyErr != nil:
|
||||
return fail(copyErr)
|
||||
case syncErr != nil:
|
||||
return fail(syncErr)
|
||||
case closeErr != nil:
|
||||
return fail(closeErr)
|
||||
case !strings.EqualFold(hex.EncodeToString(hash.Sum(nil)), strings.TrimSpace(expectedSHA256)):
|
||||
return fail(fmt.Errorf("installer SHA-256 changed after desktop verification"))
|
||||
}
|
||||
if err := verifyInstallerSHA256(stagedPath, expectedSHA256); err != nil {
|
||||
return fail(err)
|
||||
}
|
||||
return stagedPath, cleanup, nil
|
||||
}
|
||||
|
||||
func verifyInstallerSHA256(path, expectedSHA256 string) error {
|
||||
info, err := os.Lstat(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !info.Mode().IsRegular() {
|
||||
return fmt.Errorf("installer is not a regular file")
|
||||
}
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
hash := sha256.New()
|
||||
if _, err := io.Copy(hash, f); err != nil {
|
||||
return err
|
||||
}
|
||||
if !strings.EqualFold(hex.EncodeToString(hash.Sum(nil)), strings.TrimSpace(expectedSHA256)) {
|
||||
return fmt.Errorf("installer SHA-256 mismatch")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func claimVerifiedInstallerForExecution(path, expectedSHA256 string) (func(), error) {
|
||||
if !validSHA256(expectedSHA256) {
|
||||
return nil, fmt.Errorf("installer SHA-256 is invalid")
|
||||
}
|
||||
pathUTF16, err := windows.UTF16PtrFromString(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
handle, err := windows.CreateFile(
|
||||
pathUTF16,
|
||||
windows.GENERIC_READ,
|
||||
windows.FILE_SHARE_READ,
|
||||
nil,
|
||||
windows.OPEN_EXISTING,
|
||||
windows.FILE_ATTRIBUTE_NORMAL|windows.FILE_FLAG_SEQUENTIAL_SCAN,
|
||||
0,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
file := os.NewFile(uintptr(handle), path)
|
||||
fail := func(err error) (func(), error) {
|
||||
_ = file.Close()
|
||||
return nil, err
|
||||
}
|
||||
info, err := file.Stat()
|
||||
if err != nil {
|
||||
return fail(err)
|
||||
}
|
||||
if !info.Mode().IsRegular() {
|
||||
return fail(fmt.Errorf("installer is not a regular file"))
|
||||
}
|
||||
hash := sha256.New()
|
||||
if _, err := io.Copy(hash, file); err != nil {
|
||||
return fail(err)
|
||||
}
|
||||
if !strings.EqualFold(hex.EncodeToString(hash.Sum(nil)), strings.TrimSpace(expectedSHA256)) {
|
||||
return fail(fmt.Errorf("installer SHA-256 mismatch"))
|
||||
}
|
||||
var once sync.Once
|
||||
return func() {
|
||||
once.Do(func() { _ = file.Close() })
|
||||
}, nil
|
||||
}
|
||||
|
||||
func installStagedWindowsReleaseUnit(
|
||||
claimed *repair.UpdateTransaction,
|
||||
stagingDir string,
|
||||
) (bool, []repair.FileUpdateInstallReceipt, error) {
|
||||
// v1.20+: versioned-v1 is the only publish path. Incomplete staged payloads
|
||||
// fail closed without mutating the live install or leaving flat pending state.
|
||||
if !preferVersionedWindowsActivation(stagingDir) {
|
||||
return false, nil, fmt.Errorf("staged payload is incomplete for versioned-v1 activation")
|
||||
}
|
||||
if err := activateVersionedWindowsFromStaging(claimed, stagingDir); err != nil {
|
||||
return false, nil, fmt.Errorf("versioned activate: %w", err)
|
||||
}
|
||||
return true, nil, nil
|
||||
}
|
||||
|
||||
func windowsReleaseUnitPaths(installDir string) []string {
|
||||
if installDir == "" {
|
||||
return nil
|
||||
}
|
||||
names := []string{
|
||||
"reasonix-desktop.exe",
|
||||
"reasonix-guard.exe",
|
||||
"reasonix-launcher.exe",
|
||||
"reasonix-update-helper.exe",
|
||||
"reasonix-cli.exe",
|
||||
"Reasonix.exe",
|
||||
}
|
||||
paths := make([]string, 0, len(names))
|
||||
for _, name := range names {
|
||||
paths = append(paths, filepath.Join(installDir, name))
|
||||
}
|
||||
return paths
|
||||
}
|
||||
|
||||
func newLogger() *log.Logger {
|
||||
dir, err := os.UserCacheDir()
|
||||
if err == nil {
|
||||
dir = filepath.Join(dir, "Reasonix", "updates")
|
||||
if err := os.MkdirAll(dir, 0o700); err == nil {
|
||||
if f, err := os.OpenFile(filepath.Join(dir, "update-helper.log"), os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600); err == nil {
|
||||
return log.New(f, "", log.LstdFlags)
|
||||
}
|
||||
}
|
||||
}
|
||||
return log.New(os.Stderr, "", log.LstdFlags)
|
||||
}
|
||||
|
||||
func waitForProcessExit(pid uint32, timeout time.Duration) error {
|
||||
h, err := windows.OpenProcess(windows.SYNCHRONIZE, false, pid)
|
||||
if err != nil {
|
||||
if errors.Is(err, windows.ERROR_INVALID_PARAMETER) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
defer windows.CloseHandle(h)
|
||||
waitMS := uint32(timeout / time.Millisecond)
|
||||
result, err := windows.WaitForSingleObject(h, waitMS)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
switch result {
|
||||
case windows.WAIT_OBJECT_0:
|
||||
return nil
|
||||
case uint32(windows.WAIT_TIMEOUT):
|
||||
return fmt.Errorf("timed out after %s", timeout)
|
||||
default:
|
||||
return fmt.Errorf("unexpected wait result %d", result)
|
||||
}
|
||||
}
|
||||
|
||||
func runInstaller(installer, installDir string) error {
|
||||
cmd := exec.Command(installer)
|
||||
// Keep the helper itself hidden, but let the NSIS update-progress window be
|
||||
// visible. /REASONIXSTAGE makes the signed installer extract only; the helper
|
||||
// performs every live replacement through the claimed transaction.
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{CmdLine: installerCommandLine(installer, installDir)}
|
||||
return cmd.Run()
|
||||
}
|
||||
|
||||
func cleanupOwnedWindowsUpdateDirectory(path string, owner os.FileInfo) error {
|
||||
if path == "" || owner == nil || !owner.IsDir() {
|
||||
return fmt.Errorf("Windows update cleanup identity is incomplete")
|
||||
}
|
||||
for attempt := range 16 {
|
||||
cleanup := fmt.Sprintf("%s.reasonix-cleanup-%d-%d", path, time.Now().UTC().UnixNano(), attempt)
|
||||
from, err := windows.UTF16PtrFromString(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
to, err := windows.UTF16PtrFromString(cleanup)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := windows.MoveFileEx(from, to, windows.MOVEFILE_WRITE_THROUGH); err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
if os.IsExist(err) {
|
||||
continue
|
||||
}
|
||||
return err
|
||||
}
|
||||
actual, err := os.Lstat(cleanup)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !os.SameFile(owner, actual) {
|
||||
restoreFrom, fromErr := windows.UTF16PtrFromString(cleanup)
|
||||
restoreTo, toErr := windows.UTF16PtrFromString(path)
|
||||
if fromErr != nil || toErr != nil {
|
||||
return fmt.Errorf("Windows update staging changed before cleanup; preserve replacement at %s", cleanup)
|
||||
}
|
||||
if restoreErr := windows.MoveFileEx(restoreFrom, restoreTo, windows.MOVEFILE_WRITE_THROUGH); restoreErr != nil {
|
||||
return fmt.Errorf("Windows update staging changed before cleanup; preserve replacement at %s: %w", cleanup, restoreErr)
|
||||
}
|
||||
return fmt.Errorf("Windows update staging changed before cleanup")
|
||||
}
|
||||
return os.RemoveAll(cleanup)
|
||||
}
|
||||
return fmt.Errorf("cannot allocate Windows update cleanup path")
|
||||
}
|
||||
|
||||
func startRelaunch(relaunch, installDir string) error {
|
||||
cmd := exec.Command(relaunch)
|
||||
cmd.Dir = installDir
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true}
|
||||
return cmd.Start()
|
||||
}
|
||||
@@ -1,563 +0,0 @@
|
||||
//go:build windows
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/platform/installlayout"
|
||||
"reasonix/internal/platform/repair"
|
||||
|
||||
"reasonix/internal/base/tempdir"
|
||||
)
|
||||
|
||||
const testInstallerSHA256 = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
|
||||
|
||||
func prepareLegacyWindowsUpdate(t *testing.T, installDir, toVersion string) *repair.UpdateTransaction {
|
||||
t.Helper()
|
||||
paths := windowsReleaseUnitPaths(installDir)
|
||||
for _, path := range paths {
|
||||
if err := os.WriteFile(path, []byte("old-"+filepath.Base(path)), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
tx, err := repair.PrepareFileUpdate("v1", toVersion, paths[0], paths[1:]...)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return tx
|
||||
}
|
||||
|
||||
func TestStageVerifiedInstallerFreezesExpectedBytes(t *testing.T) {
|
||||
source := filepath.Join(tempdir.New(t), "installer.exe")
|
||||
content := []byte("verified-installer")
|
||||
if err := os.WriteFile(source, content, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sum := sha256.Sum256(content)
|
||||
expected := hex.EncodeToString(sum[:])
|
||||
staged, cleanup, err := stageVerifiedInstaller(source, expected)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = cleanup() })
|
||||
if staged == source {
|
||||
t.Fatal("installer was not isolated from the mutable cache path")
|
||||
}
|
||||
if err := os.WriteFile(source, []byte("changed-after-handoff"), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := verifyInstallerSHA256(staged, expected); err != nil {
|
||||
t.Fatalf("isolated installer changed with source: %v", err)
|
||||
}
|
||||
if got, err := os.ReadFile(staged); err != nil || string(got) != string(content) {
|
||||
t.Fatalf("staged installer = %q, %v", got, err)
|
||||
}
|
||||
if err := os.WriteFile(staged, []byte("tampered"), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := verifyInstallerSHA256(staged, expected); err == nil {
|
||||
t.Fatal("tampered staged installer passed final verification")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStageVerifiedInstallerRejectsSourceHashDrift(t *testing.T) {
|
||||
source := filepath.Join(tempdir.New(t), "installer.exe")
|
||||
if err := os.WriteFile(source, []byte("different"), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, cleanup, err := stageVerifiedInstaller(source, testInstallerSHA256); err == nil {
|
||||
if cleanup != nil {
|
||||
_ = cleanup()
|
||||
}
|
||||
t.Fatal("installer bytes different from desktop verification were accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunRequiresTargetVersionBeforeStartingInstaller(t *testing.T) {
|
||||
if code := run([]string{
|
||||
"--installer", `C:\Temp\Reasonix-installer.exe`,
|
||||
"--installer-sha256", testInstallerSHA256,
|
||||
}); code != 2 {
|
||||
t.Fatalf("run without --to-version = %d, want 2", code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunVersionedLayoutDoesNotReadOrClaimLegacyPending(t *testing.T) {
|
||||
installDir := tempdir.New(t)
|
||||
seed := tempdir.New(t)
|
||||
for _, name := range []string{"reasonix-desktop.exe", "reasonix-cli.exe", "reasonix-update-helper.exe"} {
|
||||
if err := os.WriteFile(filepath.Join(seed, name), []byte("old-"+name), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := installlayout.ActivateVersion(installlayout.ActivationRequest{
|
||||
InstallRoot: installDir,
|
||||
Version: "v1.20.0",
|
||||
RequestID: "seed-windows-helper",
|
||||
Members: []installlayout.Member{
|
||||
{Name: "reasonix-desktop.exe", Path: filepath.Join(seed, "reasonix-desktop.exe")},
|
||||
{Name: "reasonix-cli.exe", Path: filepath.Join(seed, "reasonix-cli.exe")},
|
||||
{Name: "reasonix-update-helper.exe", Path: filepath.Join(seed, "reasonix-update-helper.exe")},
|
||||
},
|
||||
RequiredNames: []string{"reasonix-desktop.exe", "reasonix-cli.exe", "reasonix-update-helper.exe"},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
originalInstaller := runInstallerFn
|
||||
originalRelaunch := startRelaunchFn
|
||||
originalClaim := claimPendingFileUpdateFn
|
||||
originalStageInstaller := stageInstallerFn
|
||||
originalClaimInstallerExecution := claimInstallerExecutionFn
|
||||
originalReconcileUninstall := reconcileWindowsUninstallRegistrationFn
|
||||
t.Cleanup(func() {
|
||||
runInstallerFn = originalInstaller
|
||||
startRelaunchFn = originalRelaunch
|
||||
claimPendingFileUpdateFn = originalClaim
|
||||
stageInstallerFn = originalStageInstaller
|
||||
claimInstallerExecutionFn = originalClaimInstallerExecution
|
||||
reconcileWindowsUninstallRegistrationFn = originalReconcileUninstall
|
||||
})
|
||||
legacyClaimed := false
|
||||
claimPendingFileUpdateFn = func(string, string, string, string, []string, time.Duration) (*repair.UpdateTransaction, func(), error) {
|
||||
legacyClaimed = true
|
||||
return nil, func() {}, errors.New("legacy claim must not run")
|
||||
}
|
||||
stageInstallerFn = func(path, _ string) (string, func() error, error) {
|
||||
return path, func() error { return nil }, nil
|
||||
}
|
||||
claimInstallerExecutionFn = func(string, string) (func(), error) { return func() {}, nil }
|
||||
reconciledVersion := ""
|
||||
reconcileWindowsUninstallRegistrationFn = func(root, targetVersion string) (bool, error) {
|
||||
if root != installDir {
|
||||
t.Fatalf("reconcile root = %q, want %q", root, installDir)
|
||||
}
|
||||
reconciledVersion = targetVersion
|
||||
return true, nil
|
||||
}
|
||||
runInstallerFn = func(_ string, staging string) error {
|
||||
for _, name := range []string{"reasonix-desktop.exe", "reasonix-cli.exe", "reasonix-update-helper.exe", "reasonix-launcher.exe"} {
|
||||
if err := os.WriteFile(filepath.Join(staging, name), []byte("new-"+name), 0o700); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
relaunched := false
|
||||
startRelaunchFn = func(string, string) error { relaunched = true; return nil }
|
||||
|
||||
if code := run([]string{
|
||||
"--installer", filepath.Join(installDir, "installer.exe"),
|
||||
"--installer-sha256", testInstallerSHA256,
|
||||
"--install-dir", installDir,
|
||||
"--relaunch", filepath.Join(installDir, "reasonix-launcher.exe"),
|
||||
"--to-version", "v1.20.1",
|
||||
"--install-layout", "versioned-v1",
|
||||
}); code != 0 {
|
||||
t.Fatalf("run exit code = %d", code)
|
||||
}
|
||||
if legacyClaimed {
|
||||
t.Fatal("versioned update claimed legacy pending transaction")
|
||||
}
|
||||
if !relaunched {
|
||||
t.Fatal("versioned update did not relaunch")
|
||||
}
|
||||
if reconciledVersion != "v1.20.1" {
|
||||
t.Fatalf("reconciled version = %q, want v1.20.1", reconciledVersion)
|
||||
}
|
||||
ptr, err := installlayout.ReadCurrent(installDir)
|
||||
if err != nil || ptr.ActiveVersion != "v1.20.1" {
|
||||
t.Fatalf("pointer=%+v err=%v", ptr, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunHoldsReleaseUnitLockAcrossInstallerHandoff(t *testing.T) {
|
||||
t.Setenv("REASONIX_HOME", tempdir.New(t))
|
||||
installDir := tempdir.New(t)
|
||||
pending := prepareLegacyWindowsUpdate(t, installDir, "v2")
|
||||
var events []string
|
||||
originalWait := waitForProcessExitFn
|
||||
originalInstaller := runInstallerFn
|
||||
originalRelaunch := startRelaunchFn
|
||||
originalClaim := claimPendingFileUpdateFn
|
||||
originalInstallStaged := installStagedReleaseUnitFn
|
||||
originalRecord := recordInstalledUpdateFn
|
||||
originalStageInstaller := stageInstallerFn
|
||||
originalClaimInstallerExecution := claimInstallerExecutionFn
|
||||
t.Cleanup(func() {
|
||||
waitForProcessExitFn = originalWait
|
||||
runInstallerFn = originalInstaller
|
||||
startRelaunchFn = originalRelaunch
|
||||
claimPendingFileUpdateFn = originalClaim
|
||||
installStagedReleaseUnitFn = originalInstallStaged
|
||||
recordInstalledUpdateFn = originalRecord
|
||||
stageInstallerFn = originalStageInstaller
|
||||
claimInstallerExecutionFn = originalClaimInstallerExecution
|
||||
})
|
||||
waitForProcessExitFn = func(uint32, time.Duration) error {
|
||||
events = append(events, "wait")
|
||||
return nil
|
||||
}
|
||||
runInstallerFn = func(string, string) error {
|
||||
events = append(events, "installer")
|
||||
return nil
|
||||
}
|
||||
startRelaunchFn = func(string, string) error {
|
||||
events = append(events, "relaunch")
|
||||
return nil
|
||||
}
|
||||
claimPendingFileUpdateFn = func(toVersion, createdAt, transactionID, launcherPath string, paths []string, _ time.Duration) (*repair.UpdateTransaction, func(), error) {
|
||||
events = append(events, "claim:"+strings.Join([]string{toVersion, createdAt, transactionID, launcherPath, strings.Join(paths, "\x00")}, "\x01"))
|
||||
return pending, func() { events = append(events, "release") }, nil
|
||||
}
|
||||
installStagedReleaseUnitFn = func(*repair.UpdateTransaction, string) (bool, []repair.FileUpdateInstallReceipt, error) {
|
||||
events = append(events, "publish")
|
||||
return true, []repair.FileUpdateInstallReceipt{}, nil
|
||||
}
|
||||
recordInstalledUpdateFn = func(tx *repair.UpdateTransaction, _ ...repair.FileUpdateInstallReceipt) (*repair.UpdateTransaction, error) {
|
||||
events = append(events, "record-installed")
|
||||
return tx, nil
|
||||
}
|
||||
stageInstallerFn = func(path, sha256 string) (string, func() error, error) {
|
||||
events = append(events, "stage-installer:"+sha256)
|
||||
return path + ".claimed", func() error { return nil }, nil
|
||||
}
|
||||
claimInstallerExecutionFn = func(string, string) (func(), error) {
|
||||
events = append(events, "claim-installer")
|
||||
return func() { events = append(events, "release-installer") }, nil
|
||||
}
|
||||
|
||||
if code := run([]string{
|
||||
"--parent-pid", "1234",
|
||||
"--installer", filepath.Join(installDir, "installer.exe"),
|
||||
"--installer-sha256", testInstallerSHA256,
|
||||
"--install-dir", installDir,
|
||||
"--relaunch", filepath.Join(installDir, "reasonix-desktop.exe"),
|
||||
"--to-version", pending.ToVersion,
|
||||
"--created-at", pending.CreatedAt,
|
||||
"--transaction-id", repair.UpdateTransactionID(pending),
|
||||
}); code != 0 {
|
||||
t.Fatalf("run exit code = %d", code)
|
||||
}
|
||||
wantClaim := "claim:" + strings.Join([]string{
|
||||
pending.ToVersion,
|
||||
pending.CreatedAt,
|
||||
repair.UpdateTransactionID(pending),
|
||||
filepath.Join(installDir, "reasonix-desktop.exe"),
|
||||
strings.Join(windowsReleaseUnitPaths(installDir), "\x00"),
|
||||
}, "\x01")
|
||||
if len(events) != 10 || events[0] != "wait" || events[1] != wantClaim ||
|
||||
events[2] != "stage-installer:"+testInstallerSHA256 ||
|
||||
events[3] != "claim-installer" || events[4] != "installer" ||
|
||||
events[5] != "release-installer" || events[6] != "publish" ||
|
||||
events[7] != "record-installed" || events[8] != "relaunch" ||
|
||||
events[9] != "release" {
|
||||
t.Fatalf("handoff events = %#v", events)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunDoesNotClaimUpdateBeforeParentExits(t *testing.T) {
|
||||
installDir := tempdir.New(t)
|
||||
originalWait := waitForProcessExitFn
|
||||
originalInstaller := runInstallerFn
|
||||
originalClaim := claimPendingFileUpdateFn
|
||||
originalRecord := recordInstalledUpdateFn
|
||||
t.Cleanup(func() {
|
||||
waitForProcessExitFn = originalWait
|
||||
runInstallerFn = originalInstaller
|
||||
claimPendingFileUpdateFn = originalClaim
|
||||
recordInstalledUpdateFn = originalRecord
|
||||
})
|
||||
waitForProcessExitFn = func(uint32, time.Duration) error {
|
||||
return errors.New("parent still running")
|
||||
}
|
||||
claimed := false
|
||||
claimPendingFileUpdateFn = func(string, string, string, string, []string, time.Duration) (*repair.UpdateTransaction, func(), error) {
|
||||
claimed = true
|
||||
return &repair.UpdateTransaction{}, func() {}, nil
|
||||
}
|
||||
installerRan := false
|
||||
runInstallerFn = func(string, string) error {
|
||||
installerRan = true
|
||||
return nil
|
||||
}
|
||||
|
||||
if code := run([]string{
|
||||
"--parent-pid", "1234",
|
||||
"--installer", filepath.Join(installDir, "installer.exe"),
|
||||
"--installer-sha256", testInstallerSHA256,
|
||||
"--install-dir", installDir,
|
||||
"--to-version", "v2",
|
||||
"--created-at", "2026-07-29T00:00:00Z",
|
||||
"--transaction-id", "transaction-1",
|
||||
}); code != 1 {
|
||||
t.Fatalf("run exit code = %d, want 1", code)
|
||||
}
|
||||
if claimed || installerRan {
|
||||
t.Fatalf("claim=%v installer=%v before parent exit", claimed, installerRan)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunRelaunchesWhenLegacyPendingCannotBeClaimed(t *testing.T) {
|
||||
installDir := tempdir.New(t)
|
||||
originalWait := waitForProcessExitFn
|
||||
originalRelaunch := startRelaunchFn
|
||||
originalClaim := claimPendingFileUpdateFn
|
||||
t.Cleanup(func() {
|
||||
waitForProcessExitFn = originalWait
|
||||
startRelaunchFn = originalRelaunch
|
||||
claimPendingFileUpdateFn = originalClaim
|
||||
})
|
||||
|
||||
waitForProcessExitFn = func(uint32, time.Duration) error { return nil }
|
||||
relaunched := false
|
||||
startRelaunchFn = func(path, dir string) error {
|
||||
relaunched = path == filepath.Join(installDir, "reasonix-desktop.exe") && dir == installDir
|
||||
return nil
|
||||
}
|
||||
claimPendingFileUpdateFn = func(string, string, string, string, []string, time.Duration) (*repair.UpdateTransaction, func(), error) {
|
||||
return nil, nil, errors.New("pending lock unavailable")
|
||||
}
|
||||
|
||||
if code := run([]string{
|
||||
"--parent-pid", "1234",
|
||||
"--installer", filepath.Join(installDir, "installer.exe"),
|
||||
"--installer-sha256", testInstallerSHA256,
|
||||
"--install-dir", installDir,
|
||||
"--relaunch", filepath.Join(installDir, "reasonix-desktop.exe"),
|
||||
"--to-version", "v2",
|
||||
"--created-at", "2026-08-02T00:00:00Z",
|
||||
"--transaction-id", strings.Repeat("a", 64),
|
||||
}); code != 1 {
|
||||
t.Fatalf("run exit code = %d, want 1", code)
|
||||
}
|
||||
if !relaunched {
|
||||
t.Fatal("helper did not relaunch the existing desktop")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunCancelsTransactionWhenStagedExtractionFails(t *testing.T) {
|
||||
t.Setenv("REASONIX_HOME", tempdir.New(t))
|
||||
installDir := tempdir.New(t)
|
||||
originalWait := waitForProcessExitFn
|
||||
originalInstaller := runInstallerFn
|
||||
originalRelaunch := startRelaunchFn
|
||||
originalClaim := claimPendingFileUpdateFn
|
||||
originalInstallStaged := installStagedReleaseUnitFn
|
||||
originalRecord := recordInstalledUpdateFn
|
||||
originalStageInstaller := stageInstallerFn
|
||||
originalClaimInstallerExecution := claimInstallerExecutionFn
|
||||
t.Cleanup(func() {
|
||||
waitForProcessExitFn = originalWait
|
||||
runInstallerFn = originalInstaller
|
||||
startRelaunchFn = originalRelaunch
|
||||
claimPendingFileUpdateFn = originalClaim
|
||||
installStagedReleaseUnitFn = originalInstallStaged
|
||||
recordInstalledUpdateFn = originalRecord
|
||||
stageInstallerFn = originalStageInstaller
|
||||
claimInstallerExecutionFn = originalClaimInstallerExecution
|
||||
})
|
||||
waitForProcessExitFn = func(uint32, time.Duration) error { return nil }
|
||||
runInstallerFn = func(string, string) error { return errors.New("installer failed") }
|
||||
startRelaunchFn = func(string, string) error { return nil }
|
||||
claimed := prepareLegacyWindowsUpdate(t, installDir, "v2")
|
||||
claimPendingFileUpdateFn = func(string, string, string, string, []string, time.Duration) (*repair.UpdateTransaction, func(), error) {
|
||||
return claimed, func() {}, nil
|
||||
}
|
||||
stageInstallerFn = func(path, _ string) (string, func() error, error) {
|
||||
return path, func() error { return nil }, nil
|
||||
}
|
||||
claimInstallerExecutionFn = func(string, string) (func(), error) {
|
||||
return func() {}, nil
|
||||
}
|
||||
transactionID := repair.UpdateTransactionID(claimed)
|
||||
if code := run([]string{
|
||||
"--installer", filepath.Join(installDir, "installer.exe"),
|
||||
"--installer-sha256", testInstallerSHA256,
|
||||
"--install-dir", installDir,
|
||||
"--relaunch", filepath.Join(installDir, "reasonix-desktop.exe"),
|
||||
"--to-version", "v2",
|
||||
"--created-at", claimed.CreatedAt,
|
||||
"--transaction-id", transactionID,
|
||||
}); code != 1 {
|
||||
t.Fatalf("run exit code = %d, want 1", code)
|
||||
}
|
||||
if _, ok := repair.ReadUpdateApplyFailure(); ok {
|
||||
t.Fatal("staging-only installer failure left a recovery marker despite no live publish")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunTreatsInstalledReleaseUnitRecordingFailureAsApplyFailure(t *testing.T) {
|
||||
t.Setenv("REASONIX_HOME", tempdir.New(t))
|
||||
installDir := tempdir.New(t)
|
||||
originalWait := waitForProcessExitFn
|
||||
originalInstaller := runInstallerFn
|
||||
originalRelaunch := startRelaunchFn
|
||||
originalClaim := claimPendingFileUpdateFn
|
||||
originalRecord := recordInstalledUpdateFn
|
||||
originalStageInstaller := stageInstallerFn
|
||||
originalClaimInstallerExecution := claimInstallerExecutionFn
|
||||
t.Cleanup(func() {
|
||||
waitForProcessExitFn = originalWait
|
||||
runInstallerFn = originalInstaller
|
||||
startRelaunchFn = originalRelaunch
|
||||
claimPendingFileUpdateFn = originalClaim
|
||||
recordInstalledUpdateFn = originalRecord
|
||||
stageInstallerFn = originalStageInstaller
|
||||
claimInstallerExecutionFn = originalClaimInstallerExecution
|
||||
})
|
||||
waitForProcessExitFn = func(uint32, time.Duration) error { return nil }
|
||||
runInstallerFn = func(string, string) error { return nil }
|
||||
relaunched := false
|
||||
startRelaunchFn = func(string, string) error {
|
||||
relaunched = true
|
||||
return nil
|
||||
}
|
||||
claimed := prepareLegacyWindowsUpdate(t, installDir, "v2")
|
||||
claimPendingFileUpdateFn = func(string, string, string, string, []string, time.Duration) (*repair.UpdateTransaction, func(), error) {
|
||||
return claimed, func() {}, nil
|
||||
}
|
||||
stageInstallerFn = func(path, _ string) (string, func() error, error) {
|
||||
return path, func() error { return nil }, nil
|
||||
}
|
||||
claimInstallerExecutionFn = func(string, string) (func(), error) {
|
||||
return func() {}, nil
|
||||
}
|
||||
installStagedReleaseUnitFn = func(*repair.UpdateTransaction, string) (bool, []repair.FileUpdateInstallReceipt, error) {
|
||||
return true, []repair.FileUpdateInstallReceipt{{
|
||||
UpdateTransactionID: repair.UpdateTransactionID(claimed),
|
||||
TargetPath: claimed.TargetPath,
|
||||
InstalledStateID: "installed",
|
||||
}}, nil
|
||||
}
|
||||
recordInstalledUpdateFn = func(*repair.UpdateTransaction, ...repair.FileUpdateInstallReceipt) (*repair.UpdateTransaction, error) {
|
||||
return nil, errors.New("release unit drifted")
|
||||
}
|
||||
transactionID := repair.UpdateTransactionID(claimed)
|
||||
|
||||
if code := run([]string{
|
||||
"--installer", filepath.Join(installDir, "installer.exe"),
|
||||
"--installer-sha256", testInstallerSHA256,
|
||||
"--install-dir", installDir,
|
||||
"--relaunch", filepath.Join(installDir, "reasonix-desktop.exe"),
|
||||
"--to-version", "v2",
|
||||
"--created-at", claimed.CreatedAt,
|
||||
"--transaction-id", transactionID,
|
||||
}); code != 1 {
|
||||
t.Fatalf("run exit code = %d, want 1", code)
|
||||
}
|
||||
if !relaunched {
|
||||
t.Fatal("helper did not relaunch through recovery after post-install verification failure")
|
||||
}
|
||||
failure, ok := repair.ReadUpdateApplyFailure()
|
||||
if !ok || failure.UpdateTransactionID != transactionID {
|
||||
t.Fatalf("failure marker = %+v, present=%v", failure, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunRelaunchesWhenStagingIdentityCannotBeBound(t *testing.T) {
|
||||
t.Setenv("REASONIX_HOME", tempdir.New(t))
|
||||
installDir := tempdir.New(t)
|
||||
originalWait := waitForProcessExitFn
|
||||
originalInstaller := runInstallerFn
|
||||
originalRelaunch := startRelaunchFn
|
||||
originalClaim := claimPendingFileUpdateFn
|
||||
originalStageInstaller := stageInstallerFn
|
||||
originalLstatStaging := lstatUpdateStagingFn
|
||||
t.Cleanup(func() {
|
||||
waitForProcessExitFn = originalWait
|
||||
runInstallerFn = originalInstaller
|
||||
startRelaunchFn = originalRelaunch
|
||||
claimPendingFileUpdateFn = originalClaim
|
||||
stageInstallerFn = originalStageInstaller
|
||||
lstatUpdateStagingFn = originalLstatStaging
|
||||
})
|
||||
waitForProcessExitFn = func(uint32, time.Duration) error { return nil }
|
||||
installerRan := false
|
||||
runInstallerFn = func(string, string) error {
|
||||
installerRan = true
|
||||
return nil
|
||||
}
|
||||
relaunched := false
|
||||
startRelaunchFn = func(string, string) error {
|
||||
relaunched = true
|
||||
return nil
|
||||
}
|
||||
claimed := prepareLegacyWindowsUpdate(t, installDir, "v2")
|
||||
releases := 0
|
||||
claimPendingFileUpdateFn = func(string, string, string, string, []string, time.Duration) (*repair.UpdateTransaction, func(), error) {
|
||||
return claimed, func() { releases++ }, nil
|
||||
}
|
||||
stageInstallerFn = func(path, _ string) (string, func() error, error) {
|
||||
return path, func() error { return nil }, nil
|
||||
}
|
||||
lstatUpdateStagingFn = func(string) (os.FileInfo, error) {
|
||||
return nil, errors.New("staging identity unavailable")
|
||||
}
|
||||
|
||||
if code := run([]string{
|
||||
"--installer", filepath.Join(installDir, "installer.exe"),
|
||||
"--installer-sha256", testInstallerSHA256,
|
||||
"--install-dir", installDir,
|
||||
"--relaunch", filepath.Join(installDir, "reasonix-desktop.exe"),
|
||||
"--to-version", claimed.ToVersion,
|
||||
"--created-at", claimed.CreatedAt,
|
||||
"--transaction-id", repair.UpdateTransactionID(claimed),
|
||||
}); code != 1 {
|
||||
t.Fatalf("run exit code = %d, want 1", code)
|
||||
}
|
||||
if installerRan {
|
||||
t.Fatal("installer ran without a bound staging directory")
|
||||
}
|
||||
if !relaunched {
|
||||
t.Fatal("helper did not relaunch after staging identity failure")
|
||||
}
|
||||
if releases == 0 {
|
||||
t.Fatal("helper did not release the update claim")
|
||||
}
|
||||
}
|
||||
|
||||
func TestClaimVerifiedInstallerForExecutionFreezesPath(t *testing.T) {
|
||||
path := filepath.Join(tempdir.New(t), "installer.exe")
|
||||
content := []byte("verified-installer")
|
||||
if err := os.WriteFile(path, content, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sum := sha256.Sum256(content)
|
||||
release, err := claimVerifiedInstallerForExecution(path, hex.EncodeToString(sum[:]))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte("tampered"), 0o700); err == nil {
|
||||
release()
|
||||
t.Fatal("installer remained writable while execution claim was held")
|
||||
}
|
||||
if err := os.Rename(path, path+".replaced"); err == nil {
|
||||
release()
|
||||
t.Fatal("installer remained renameable while execution claim was held")
|
||||
}
|
||||
release()
|
||||
release()
|
||||
if err := os.WriteFile(path, []byte("replacement"), 0o700); err != nil {
|
||||
t.Fatalf("installer remained frozen after claim release: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClaimVerifiedInstallerForExecutionRejectsHashDrift(t *testing.T) {
|
||||
path := filepath.Join(tempdir.New(t), "installer.exe")
|
||||
if err := os.WriteFile(path, []byte("tampered"), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if release, err := claimVerifiedInstallerForExecution(path, testInstallerSHA256); err == nil {
|
||||
release()
|
||||
t.Fatal("installer with the wrong SHA-256 received an execution claim")
|
||||
}
|
||||
}
|
||||
@@ -1,238 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"reasonix/internal/platform/repair"
|
||||
"reasonix/internal/platform/update"
|
||||
)
|
||||
|
||||
const maxWindowsPayloadMetadataSize = 64 << 10
|
||||
|
||||
var verifyWindowsPayloadManifestFn = update.Verify
|
||||
|
||||
type stagedFileUpdateMember struct {
|
||||
targetPath string
|
||||
content []byte
|
||||
mode os.FileMode
|
||||
}
|
||||
|
||||
// loadWindowsStagedReleaseUnit validates and reads the complete NSIS payload
|
||||
// before any live release-unit member is moved. An existing Reasonix.exe is the
|
||||
// portable alias of reasonix-launcher.exe and reuses those staged bytes; an
|
||||
// installed package that did not have the alias remains unchanged.
|
||||
func loadWindowsStagedReleaseUnit(claimed *repair.UpdateTransaction, stagingDir string) ([]stagedFileUpdateMember, error) {
|
||||
if claimed == nil || claimed.TargetKind != "file" || len(claimed.Files) == 0 {
|
||||
return nil, fmt.Errorf("load staged release unit: transaction identity is incomplete")
|
||||
}
|
||||
if err := validateWindowsClaimedReleaseUnit(claimed); err != nil {
|
||||
return nil, fmt.Errorf("load staged release unit: %w", err)
|
||||
}
|
||||
stagingDir = filepath.Clean(strings.TrimSpace(stagingDir))
|
||||
if stagingDir == "" || stagingDir == "." || !filepath.IsAbs(stagingDir) {
|
||||
return nil, fmt.Errorf("load staged release unit: staging directory is invalid")
|
||||
}
|
||||
info, err := os.Lstat(stagingDir)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load staged release unit: inspect staging directory: %w", err)
|
||||
}
|
||||
if !info.IsDir() || info.Mode()&os.ModeSymlink != 0 {
|
||||
return nil, fmt.Errorf("load staged release unit: staging path is not a directory")
|
||||
}
|
||||
hashes, err := loadWindowsPayloadManifest(stagingDir, claimed.ToVersion)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load staged release unit: %w", err)
|
||||
}
|
||||
|
||||
contents := make(map[string][]byte)
|
||||
members := make([]stagedFileUpdateMember, 0, len(claimed.Files))
|
||||
seenTargets := make(map[string]struct{}, len(claimed.Files))
|
||||
for _, file := range claimed.Files {
|
||||
targetPath := filepath.Clean(strings.TrimSpace(file.TargetPath))
|
||||
targetKey := strings.ToLower(targetPath)
|
||||
if targetPath == "" || targetPath == "." {
|
||||
return nil, fmt.Errorf("load staged release unit: target path is invalid")
|
||||
}
|
||||
if _, ok := seenTargets[targetKey]; ok {
|
||||
return nil, fmt.Errorf("load staged release unit: duplicate target %s", filepath.Base(targetPath))
|
||||
}
|
||||
seenTargets[targetKey] = struct{}{}
|
||||
if strings.EqualFold(filepath.Base(targetPath), "Reasonix.exe") && file.MissingBefore {
|
||||
continue
|
||||
}
|
||||
|
||||
sourceName, err := windowsStagedSourceName(filepath.Base(targetPath))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sourcePath := filepath.Join(stagingDir, sourceName)
|
||||
content, ok := contents[sourceName]
|
||||
if !ok {
|
||||
sourceInfo, statErr := os.Lstat(sourcePath)
|
||||
if statErr != nil {
|
||||
return nil, fmt.Errorf("load staged release unit: inspect %s: %w", sourceName, statErr)
|
||||
}
|
||||
if !sourceInfo.Mode().IsRegular() {
|
||||
return nil, fmt.Errorf("load staged release unit: %s is not a regular file", sourceName)
|
||||
}
|
||||
content, err = readVerifiedWindowsStagedPayloadFn(sourcePath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load staged release unit: read %s: %w", sourceName, err)
|
||||
}
|
||||
if !strings.EqualFold(update.WindowsPayloadSHA256(content), hashes[sourceName]) {
|
||||
return nil, fmt.Errorf("load staged release unit: %s does not match the signed release manifest", sourceName)
|
||||
}
|
||||
contents[sourceName] = content
|
||||
}
|
||||
members = append(members, stagedFileUpdateMember{
|
||||
targetPath: targetPath,
|
||||
content: content,
|
||||
mode: 0o700,
|
||||
})
|
||||
}
|
||||
|
||||
// Publish the running desktop last. If an earlier member fails, the old
|
||||
// desktop remains the executable entry point that can report/retry recovery.
|
||||
sort.SliceStable(members, func(i, j int) bool {
|
||||
iPrimary := strings.EqualFold(members[i].targetPath, claimed.TargetPath)
|
||||
jPrimary := strings.EqualFold(members[j].targetPath, claimed.TargetPath)
|
||||
return !iPrimary && jPrimary
|
||||
})
|
||||
return members, nil
|
||||
}
|
||||
|
||||
func validateWindowsClaimedReleaseUnit(claimed *repair.UpdateTransaction) error {
|
||||
if claimed == nil ||
|
||||
!strings.EqualFold(filepath.Base(claimed.TargetPath), "reasonix-desktop.exe") {
|
||||
return fmt.Errorf("claimed release unit primary executable is invalid")
|
||||
}
|
||||
required := map[string]bool{
|
||||
"reasonix-desktop.exe": false,
|
||||
"reasonix-guard.exe": false,
|
||||
"reasonix-launcher.exe": false,
|
||||
"reasonix-update-helper.exe": false,
|
||||
"reasonix-cli.exe": false,
|
||||
"reasonix.exe": false,
|
||||
}
|
||||
installDir := filepath.Clean(filepath.Dir(claimed.TargetPath))
|
||||
for _, file := range claimed.Files {
|
||||
target := filepath.Clean(strings.TrimSpace(file.TargetPath))
|
||||
if target == "" || target == "." ||
|
||||
!strings.EqualFold(filepath.Dir(target), installDir) {
|
||||
return fmt.Errorf("claimed release unit target is outside the installation directory")
|
||||
}
|
||||
name := strings.ToLower(filepath.Base(target))
|
||||
seen, ok := required[name]
|
||||
if !ok {
|
||||
return fmt.Errorf("claimed release unit contains an unexpected target")
|
||||
}
|
||||
if seen {
|
||||
return fmt.Errorf("claimed release unit contains a duplicate target")
|
||||
}
|
||||
required[name] = true
|
||||
}
|
||||
for name, seen := range required {
|
||||
if !seen {
|
||||
return fmt.Errorf("claimed release unit omits %s", name)
|
||||
}
|
||||
}
|
||||
if len(claimed.Files) != len(required) {
|
||||
return fmt.Errorf("claimed release unit contains an unexpected target")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func loadWindowsPayloadManifest(stagingDir, expectedVersion string) (map[string]string, error) {
|
||||
manifest, err := readWindowsPayloadMetadata(filepath.Join(stagingDir, update.WindowsPayloadManifestName))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read signed release manifest: %w", err)
|
||||
}
|
||||
signature, err := readWindowsPayloadMetadata(filepath.Join(stagingDir, update.WindowsPayloadSignatureName))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read signed release manifest signature: %w", err)
|
||||
}
|
||||
if err := verifyWindowsPayloadManifestFn(manifest, signature); err != nil {
|
||||
return nil, fmt.Errorf("verify signed release manifest: %w", err)
|
||||
}
|
||||
hashes, err := update.DecodeWindowsPayloadManifest(manifest, expectedVersion)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return hashes, nil
|
||||
}
|
||||
|
||||
func readWindowsPayloadMetadata(path string) ([]byte, error) {
|
||||
pathInfo, err := os.Lstat(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !pathInfo.Mode().IsRegular() {
|
||||
return nil, fmt.Errorf("%s is not a regular file", filepath.Base(path))
|
||||
}
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer file.Close()
|
||||
info, err := file.Stat()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !info.Mode().IsRegular() || info.Size() <= 0 || info.Size() > maxWindowsPayloadMetadataSize {
|
||||
return nil, fmt.Errorf("%s is not a bounded regular file", filepath.Base(path))
|
||||
}
|
||||
if !os.SameFile(pathInfo, info) {
|
||||
return nil, fmt.Errorf("%s changed before it was opened", filepath.Base(path))
|
||||
}
|
||||
data, err := io.ReadAll(io.LimitReader(file, maxWindowsPayloadMetadataSize+1))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(data) == 0 || len(data) > maxWindowsPayloadMetadataSize {
|
||||
return nil, fmt.Errorf("%s changed size while it was read", filepath.Base(path))
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func windowsStagedSourceName(targetBase string) (string, error) {
|
||||
switch strings.ToLower(strings.TrimSpace(targetBase)) {
|
||||
case "reasonix-desktop.exe":
|
||||
return "reasonix-desktop.exe", nil
|
||||
case "reasonix-guard.exe":
|
||||
return "reasonix-guard.exe", nil
|
||||
case "reasonix-launcher.exe":
|
||||
return "reasonix-launcher.exe", nil
|
||||
case "reasonix-update-helper.exe":
|
||||
return "reasonix-update-helper.exe", nil
|
||||
case "reasonix-cli.exe":
|
||||
return "reasonix-cli.exe", nil
|
||||
case "reasonix.exe":
|
||||
return "reasonix-launcher.exe", nil
|
||||
default:
|
||||
return "", fmt.Errorf("load staged release unit: unsupported target %q", targetBase)
|
||||
}
|
||||
}
|
||||
|
||||
func publishLoadedFileUpdateReleaseUnit(
|
||||
claimed *repair.UpdateTransaction,
|
||||
members []stagedFileUpdateMember,
|
||||
publish func(*repair.UpdateTransaction, string, []byte, os.FileMode) (repair.FileUpdateInstallReceipt, error),
|
||||
) ([]repair.FileUpdateInstallReceipt, error) {
|
||||
if publish == nil || len(members) == 0 {
|
||||
return nil, fmt.Errorf("publish staged release unit: payload is incomplete")
|
||||
}
|
||||
receipts := make([]repair.FileUpdateInstallReceipt, 0, len(members))
|
||||
for _, member := range members {
|
||||
receipt, err := publish(claimed, member.targetPath, member.content, member.mode)
|
||||
if err != nil {
|
||||
return receipts, fmt.Errorf("publish staged release unit %s: %w", filepath.Base(member.targetPath), err)
|
||||
}
|
||||
receipts = append(receipts, receipt)
|
||||
}
|
||||
return receipts, nil
|
||||
}
|
||||
@@ -1,377 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/base/tempdir"
|
||||
"reasonix/internal/platform/repair"
|
||||
"reasonix/internal/platform/update"
|
||||
)
|
||||
|
||||
func TestLoadWindowsStagedReleaseUnitPreflightsAllMembersAndPublishesDesktopLast(t *testing.T) {
|
||||
staging := tempdir.New(t)
|
||||
for name, content := range map[string]string{
|
||||
"reasonix-desktop.exe": "desktop-v2",
|
||||
"reasonix-guard.exe": "guard-v2",
|
||||
"reasonix-launcher.exe": "launcher-v2",
|
||||
"reasonix-update-helper.exe": "helper-v2",
|
||||
"reasonix-cli.exe": "cli-v2",
|
||||
} {
|
||||
if err := os.WriteFile(filepath.Join(staging, name), []byte(content), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
useTestWindowsPayloadManifest(t, staging, "v2")
|
||||
installDir := tempdir.New(t)
|
||||
claimed := &repair.UpdateTransaction{
|
||||
SchemaVersion: 1,
|
||||
ToVersion: "v2",
|
||||
TargetKind: "file",
|
||||
TargetPath: filepath.Join(installDir, "reasonix-desktop.exe"),
|
||||
Files: []repair.UpdateTransactionFile{
|
||||
{TargetPath: filepath.Join(installDir, "reasonix-desktop.exe")},
|
||||
{TargetPath: filepath.Join(installDir, "reasonix-guard.exe")},
|
||||
{TargetPath: filepath.Join(installDir, "reasonix-launcher.exe")},
|
||||
{TargetPath: filepath.Join(installDir, "reasonix-update-helper.exe")},
|
||||
{TargetPath: filepath.Join(installDir, "reasonix-cli.exe")},
|
||||
{TargetPath: filepath.Join(installDir, "Reasonix.exe")},
|
||||
},
|
||||
}
|
||||
|
||||
members, err := loadWindowsStagedReleaseUnit(claimed, staging)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := filepath.Base(members[len(members)-1].targetPath); !strings.EqualFold(got, "reasonix-desktop.exe") {
|
||||
t.Fatalf("last published member = %q, want desktop", got)
|
||||
}
|
||||
var published []string
|
||||
receipts, err := publishLoadedFileUpdateReleaseUnit(claimed, members, func(_ *repair.UpdateTransaction, target string, content []byte, _ os.FileMode) (repair.FileUpdateInstallReceipt, error) {
|
||||
published = append(published, filepath.Base(target)+"="+string(content))
|
||||
return repair.FileUpdateInstallReceipt{TargetPath: target}, nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(receipts) != len(members) {
|
||||
t.Fatalf("publish receipts = %d, want %d", len(receipts), len(members))
|
||||
}
|
||||
if got := strings.Join(published, ","); !strings.Contains(got, "Reasonix.exe=launcher-v2") {
|
||||
t.Fatalf("portable alias did not reuse launcher payload: %s", got)
|
||||
}
|
||||
if !strings.HasPrefix(published[len(published)-1], "reasonix-desktop.exe=") {
|
||||
t.Fatalf("publish order = %v", published)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadWindowsStagedReleaseUnitRejectsIncompletePayloadBeforePublish(t *testing.T) {
|
||||
staging := tempdir.New(t)
|
||||
if err := os.WriteFile(filepath.Join(staging, "reasonix-desktop.exe"), []byte("desktop-v2"), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
useTestWindowsPayloadManifest(t, staging, "v2")
|
||||
installDir := tempdir.New(t)
|
||||
claimed := &repair.UpdateTransaction{
|
||||
SchemaVersion: 1,
|
||||
ToVersion: "v2",
|
||||
TargetKind: "file",
|
||||
TargetPath: filepath.Join(installDir, "reasonix-desktop.exe"),
|
||||
Files: []repair.UpdateTransactionFile{
|
||||
{TargetPath: filepath.Join(installDir, "reasonix-desktop.exe")},
|
||||
{TargetPath: filepath.Join(installDir, "reasonix-guard.exe")},
|
||||
},
|
||||
}
|
||||
if _, err := loadWindowsStagedReleaseUnit(claimed, staging); err == nil {
|
||||
t.Fatal("incomplete staged release unit was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateWindowsClaimedReleaseUnitRequiresExactTargets(t *testing.T) {
|
||||
_, complete := completeWindowsStagedReleaseUnitForTest(t, "v2")
|
||||
for i, file := range complete.Files {
|
||||
t.Run("missing-"+strings.ToLower(filepath.Base(file.TargetPath)), func(t *testing.T) {
|
||||
claimed := *complete
|
||||
claimed.Files = append([]repair.UpdateTransactionFile(nil), complete.Files[:i]...)
|
||||
claimed.Files = append(claimed.Files, complete.Files[i+1:]...)
|
||||
if err := validateWindowsClaimedReleaseUnit(&claimed); err == nil ||
|
||||
!strings.Contains(err.Error(), "omits") {
|
||||
t.Fatalf("missing target error = %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
t.Run("extra", func(t *testing.T) {
|
||||
claimed := *complete
|
||||
claimed.Files = append(append([]repair.UpdateTransactionFile(nil), complete.Files...),
|
||||
repair.UpdateTransactionFile{TargetPath: filepath.Join(filepath.Dir(complete.TargetPath), "other.exe")})
|
||||
if err := validateWindowsClaimedReleaseUnit(&claimed); err == nil ||
|
||||
!strings.Contains(err.Error(), "unexpected") {
|
||||
t.Fatalf("extra target error = %v", err)
|
||||
}
|
||||
})
|
||||
t.Run("duplicate", func(t *testing.T) {
|
||||
claimed := *complete
|
||||
claimed.Files = append(append([]repair.UpdateTransactionFile(nil), complete.Files...), complete.Files[0])
|
||||
if err := validateWindowsClaimedReleaseUnit(&claimed); err == nil ||
|
||||
!strings.Contains(err.Error(), "duplicate") {
|
||||
t.Fatalf("duplicate target error = %v", err)
|
||||
}
|
||||
})
|
||||
t.Run("outside", func(t *testing.T) {
|
||||
claimed := *complete
|
||||
claimed.Files = append([]repair.UpdateTransactionFile(nil), complete.Files...)
|
||||
claimed.Files[1].TargetPath = filepath.Join(tempdir.New(t), filepath.Base(claimed.Files[1].TargetPath))
|
||||
if err := validateWindowsClaimedReleaseUnit(&claimed); err == nil ||
|
||||
!strings.Contains(err.Error(), "outside") {
|
||||
t.Fatalf("outside target error = %v", err)
|
||||
}
|
||||
})
|
||||
t.Run("primary", func(t *testing.T) {
|
||||
claimed := *complete
|
||||
claimed.TargetPath = filepath.Join(filepath.Dir(complete.TargetPath), "reasonix-guard.exe")
|
||||
if err := validateWindowsClaimedReleaseUnit(&claimed); err == nil ||
|
||||
!strings.Contains(err.Error(), "primary") {
|
||||
t.Fatalf("primary target error = %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestLoadWindowsStagedReleaseUnitDoesNotCreateMissingPortableAlias(t *testing.T) {
|
||||
staging := tempdir.New(t)
|
||||
for _, name := range update.WindowsPayloadFileNames() {
|
||||
content := "payload:" + name
|
||||
if err := os.WriteFile(filepath.Join(staging, name), []byte(content), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
useTestWindowsPayloadManifest(t, staging, "v2")
|
||||
installDir := tempdir.New(t)
|
||||
claimed := &repair.UpdateTransaction{
|
||||
SchemaVersion: 1,
|
||||
ToVersion: "v2",
|
||||
TargetKind: "file",
|
||||
TargetPath: filepath.Join(installDir, "reasonix-desktop.exe"),
|
||||
Files: []repair.UpdateTransactionFile{
|
||||
{TargetPath: filepath.Join(installDir, "reasonix-desktop.exe")},
|
||||
{TargetPath: filepath.Join(installDir, "reasonix-guard.exe")},
|
||||
{TargetPath: filepath.Join(installDir, "reasonix-launcher.exe")},
|
||||
{TargetPath: filepath.Join(installDir, "reasonix-update-helper.exe")},
|
||||
{TargetPath: filepath.Join(installDir, "reasonix-cli.exe")},
|
||||
{TargetPath: filepath.Join(installDir, "Reasonix.exe"), MissingBefore: true},
|
||||
},
|
||||
}
|
||||
|
||||
members, err := loadWindowsStagedReleaseUnit(claimed, staging)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, member := range members {
|
||||
if strings.EqualFold(filepath.Base(member.targetPath), "Reasonix.exe") {
|
||||
t.Fatalf("missing portable alias was added to publish set: %+v", members)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublishLoadedFileUpdateReleaseUnitStopsOnFirstFailedCompareAndPublish(t *testing.T) {
|
||||
claimed := &repair.UpdateTransaction{TargetKind: "file"}
|
||||
members := []stagedFileUpdateMember{
|
||||
{targetPath: "guard.exe", content: []byte("guard"), mode: 0o700},
|
||||
{targetPath: "desktop.exe", content: []byte("desktop"), mode: 0o700},
|
||||
}
|
||||
var published []string
|
||||
receipts, err := publishLoadedFileUpdateReleaseUnit(claimed, members, func(_ *repair.UpdateTransaction, target string, _ []byte, _ os.FileMode) (repair.FileUpdateInstallReceipt, error) {
|
||||
published = append(published, target)
|
||||
return repair.FileUpdateInstallReceipt{}, errors.New("concurrent recreation")
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "concurrent recreation") {
|
||||
t.Fatalf("publish error = %v", err)
|
||||
}
|
||||
if len(published) != 1 {
|
||||
t.Fatalf("published members = %v, want one attempted member", published)
|
||||
}
|
||||
if len(receipts) != 0 {
|
||||
t.Fatalf("failed first publish returned receipts: %+v", receipts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadWindowsStagedReleaseUnitRejectsUnverifiedPayloadBeforePublish(t *testing.T) {
|
||||
staging := tempdir.New(t)
|
||||
for _, name := range []string{
|
||||
"reasonix-desktop.exe",
|
||||
"reasonix-guard.exe",
|
||||
"reasonix-launcher.exe",
|
||||
"reasonix-update-helper.exe",
|
||||
"reasonix-cli.exe",
|
||||
} {
|
||||
if err := os.WriteFile(filepath.Join(staging, name), []byte(name), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
writeWindowsPayloadManifestForTest(t, staging, "v2")
|
||||
acceptWindowsPayloadManifestForTest(t)
|
||||
_, claimed := completeWindowsStagedReleaseUnitForTest(t, "v2")
|
||||
original := readVerifiedWindowsStagedPayloadFn
|
||||
readVerifiedWindowsStagedPayloadFn = func(string) ([]byte, error) {
|
||||
return nil, errors.New("payload signature rejected")
|
||||
}
|
||||
t.Cleanup(func() { readVerifiedWindowsStagedPayloadFn = original })
|
||||
|
||||
if _, err := loadWindowsStagedReleaseUnit(claimed, staging); err == nil {
|
||||
t.Fatal("unsigned staged payload must be rejected before publication")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadWindowsStagedReleaseUnitReadsEachSourceThroughVerifier(t *testing.T) {
|
||||
staging := tempdir.New(t)
|
||||
for name, content := range map[string]string{
|
||||
"reasonix-desktop.exe": "desktop-v2",
|
||||
"reasonix-guard.exe": "guard-v2",
|
||||
"reasonix-launcher.exe": "launcher-v2",
|
||||
"reasonix-update-helper.exe": "helper-v2",
|
||||
"reasonix-cli.exe": "cli-v2",
|
||||
} {
|
||||
if err := os.WriteFile(filepath.Join(staging, name), []byte(content), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
writeWindowsPayloadManifestForTest(t, staging, "v2")
|
||||
acceptWindowsPayloadManifestForTest(t)
|
||||
_, claimed := completeWindowsStagedReleaseUnitForTest(t, "v2")
|
||||
var verified []string
|
||||
original := readVerifiedWindowsStagedPayloadFn
|
||||
readVerifiedWindowsStagedPayloadFn = func(path string) ([]byte, error) {
|
||||
verified = append(verified, filepath.Base(path))
|
||||
return os.ReadFile(path)
|
||||
}
|
||||
t.Cleanup(func() { readVerifiedWindowsStagedPayloadFn = original })
|
||||
|
||||
if _, err := loadWindowsStagedReleaseUnit(claimed, staging); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(verified) != len(update.WindowsPayloadFileNames()) {
|
||||
t.Fatalf("verified payload count = %d, want %d (%v)", len(verified), len(update.WindowsPayloadFileNames()), verified)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadWindowsStagedReleaseUnitRejectsMissingManifest(t *testing.T) {
|
||||
staging, claimed := completeWindowsStagedReleaseUnitForTest(t, "v2")
|
||||
useUnverifiedPayloadReaderForTest(t)
|
||||
if _, err := loadWindowsStagedReleaseUnit(claimed, staging); err == nil ||
|
||||
!strings.Contains(err.Error(), update.WindowsPayloadManifestName) {
|
||||
t.Fatalf("missing signed manifest error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadWindowsStagedReleaseUnitRejectsBadManifestSignature(t *testing.T) {
|
||||
staging, claimed := completeWindowsStagedReleaseUnitForTest(t, "v2")
|
||||
writeWindowsPayloadManifestForTest(t, staging, "v2")
|
||||
useUnverifiedPayloadReaderForTest(t)
|
||||
if _, err := loadWindowsStagedReleaseUnit(claimed, staging); err == nil ||
|
||||
!strings.Contains(err.Error(), "verify signed release manifest") {
|
||||
t.Fatalf("bad signed manifest error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadWindowsStagedReleaseUnitRejectsManifestVersionDrift(t *testing.T) {
|
||||
staging, claimed := completeWindowsStagedReleaseUnitForTest(t, "v2")
|
||||
writeWindowsPayloadManifestForTest(t, staging, "v3")
|
||||
acceptWindowsPayloadManifestForTest(t)
|
||||
useUnverifiedPayloadReaderForTest(t)
|
||||
if _, err := loadWindowsStagedReleaseUnit(claimed, staging); err == nil ||
|
||||
!strings.Contains(err.Error(), "identity does not match") {
|
||||
t.Fatalf("release version drift error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadWindowsStagedReleaseUnitRejectsManifestMemberHashDrift(t *testing.T) {
|
||||
staging, claimed := completeWindowsStagedReleaseUnitForTest(t, "v2")
|
||||
writeWindowsPayloadManifestForTest(t, staging, "v2")
|
||||
acceptWindowsPayloadManifestForTest(t)
|
||||
useUnverifiedPayloadReaderForTest(t)
|
||||
if err := os.WriteFile(filepath.Join(staging, "reasonix-guard.exe"), []byte("tampered"), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := loadWindowsStagedReleaseUnit(claimed, staging); err == nil ||
|
||||
!strings.Contains(err.Error(), "does not match the signed release manifest") {
|
||||
t.Fatalf("release member drift error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func completeWindowsStagedReleaseUnitForTest(t *testing.T, version string) (string, *repair.UpdateTransaction) {
|
||||
t.Helper()
|
||||
staging := tempdir.New(t)
|
||||
for _, name := range update.WindowsPayloadFileNames() {
|
||||
if err := os.WriteFile(filepath.Join(staging, name), []byte("payload:"+name), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
installDir := tempdir.New(t)
|
||||
files := make([]repair.UpdateTransactionFile, 0, len(update.WindowsPayloadFileNames())+1)
|
||||
for _, name := range update.WindowsPayloadFileNames() {
|
||||
files = append(files, repair.UpdateTransactionFile{TargetPath: filepath.Join(installDir, name)})
|
||||
}
|
||||
files = append(files, repair.UpdateTransactionFile{
|
||||
TargetPath: filepath.Join(installDir, "Reasonix.exe"),
|
||||
MissingBefore: true,
|
||||
})
|
||||
return staging, &repair.UpdateTransaction{
|
||||
SchemaVersion: 1,
|
||||
ToVersion: version,
|
||||
TargetKind: "file",
|
||||
TargetPath: filepath.Join(installDir, "reasonix-desktop.exe"),
|
||||
Files: files,
|
||||
}
|
||||
}
|
||||
|
||||
func writeWindowsPayloadManifestForTest(t *testing.T, staging, version string) {
|
||||
t.Helper()
|
||||
hashes := make(map[string]string)
|
||||
for _, name := range update.WindowsPayloadFileNames() {
|
||||
content, err := os.ReadFile(filepath.Join(staging, name))
|
||||
if os.IsNotExist(err) {
|
||||
content = []byte("missing:" + name)
|
||||
} else if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
hashes[name] = update.WindowsPayloadSHA256(content)
|
||||
}
|
||||
manifest, err := update.EncodeWindowsPayloadManifest(version, hashes)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(staging, update.WindowsPayloadManifestName), manifest, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(staging, update.WindowsPayloadSignatureName), []byte("test signature"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func acceptWindowsPayloadManifestForTest(t *testing.T) {
|
||||
t.Helper()
|
||||
original := verifyWindowsPayloadManifestFn
|
||||
verifyWindowsPayloadManifestFn = func(_, signature []byte) error {
|
||||
if string(signature) != "test signature" {
|
||||
return errors.New("unexpected test signature")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
t.Cleanup(func() { verifyWindowsPayloadManifestFn = original })
|
||||
}
|
||||
|
||||
func useTestWindowsPayloadManifest(t *testing.T, staging, version string) {
|
||||
t.Helper()
|
||||
writeWindowsPayloadManifestForTest(t, staging, version)
|
||||
acceptWindowsPayloadManifestForTest(t)
|
||||
useUnverifiedPayloadReaderForTest(t)
|
||||
}
|
||||
|
||||
func useUnverifiedPayloadReaderForTest(t *testing.T) {
|
||||
t.Helper()
|
||||
original := readVerifiedWindowsStagedPayloadFn
|
||||
readVerifiedWindowsStagedPayloadFn = os.ReadFile
|
||||
t.Cleanup(func() { readVerifiedWindowsStagedPayloadFn = original })
|
||||
}
|
||||
@@ -1,118 +0,0 @@
|
||||
//go:build windows
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"reasonix/internal/platform/installlayout"
|
||||
"reasonix/internal/platform/repair"
|
||||
)
|
||||
|
||||
// activateVersionedWindowsFromStaging publishes the versioned-v1 layout from a
|
||||
// staged NSIS payload:
|
||||
//
|
||||
// InstallRoot/
|
||||
// reasonix-launcher.exe
|
||||
// Reasonix.exe (launcher alias when present or portable)
|
||||
// reasonix-cli.exe (CLI entry; full binary for now)
|
||||
// current.json
|
||||
// versions/<version>/
|
||||
// reasonix-desktop.exe
|
||||
// reasonix-cli.exe
|
||||
// reasonix-update-helper.exe
|
||||
//
|
||||
// Any failure before the current.json pointer swap leaves the previous active
|
||||
// version unchanged. The helper never counts crashes or selects prior versions.
|
||||
func activateVersionedWindowsFromStaging(claimed *repair.UpdateTransaction, stagingDir string) error {
|
||||
if claimed == nil {
|
||||
return fmt.Errorf("versioned activate: transaction is nil")
|
||||
}
|
||||
installRoot := filepath.Clean(strings.TrimSpace(filepath.Dir(claimed.TargetPath)))
|
||||
// When the claimed primary is already under versions/<ver>/, climb to root.
|
||||
if root, err := installlayout.ResolveInstallRoot(claimed.TargetPath); err == nil && root != "" {
|
||||
installRoot = root
|
||||
}
|
||||
version := strings.TrimSpace(claimed.ToVersion)
|
||||
if err := installlayout.ValidateVersionName(version); err != nil {
|
||||
// Accept bare product versions from NSIS (1.20.0 → v1.20.0).
|
||||
if !strings.HasPrefix(version, "v") {
|
||||
version = "v" + version
|
||||
}
|
||||
if err := installlayout.ValidateVersionName(version); err != nil {
|
||||
return fmt.Errorf("versioned activate: %w", err)
|
||||
}
|
||||
}
|
||||
stagingDir = filepath.Clean(strings.TrimSpace(stagingDir))
|
||||
|
||||
desktopSrc := filepath.Join(stagingDir, "reasonix-desktop.exe")
|
||||
cliSrc := filepath.Join(stagingDir, "reasonix-cli.exe")
|
||||
helperSrc := filepath.Join(stagingDir, "reasonix-update-helper.exe")
|
||||
launcherSrc := filepath.Join(stagingDir, "reasonix-launcher.exe")
|
||||
for _, path := range []string{desktopSrc, cliSrc, helperSrc, launcherSrc} {
|
||||
info, err := os.Lstat(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("versioned activate: staged %s: %w", filepath.Base(path), err)
|
||||
}
|
||||
if !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 {
|
||||
return fmt.Errorf("versioned activate: staged %s is not a regular file", filepath.Base(path))
|
||||
}
|
||||
}
|
||||
|
||||
requestID := repair.UpdateTransactionID(claimed)
|
||||
if requestID == "" {
|
||||
requestID = "helper-" + version
|
||||
}
|
||||
if err := installlayout.ActivateVersion(installlayout.ActivationRequest{
|
||||
InstallRoot: installRoot,
|
||||
Version: version,
|
||||
RequestID: requestID,
|
||||
Members: []installlayout.Member{
|
||||
{Name: "reasonix-desktop.exe", Path: desktopSrc, Mode: 0o700},
|
||||
{Name: "reasonix-cli.exe", Path: cliSrc, Mode: 0o700},
|
||||
{Name: "reasonix-update-helper.exe", Path: helperSrc, Mode: 0o700},
|
||||
},
|
||||
RootMembers: []installlayout.Member{
|
||||
{Name: "reasonix-launcher.exe", Path: launcherSrc, Mode: 0o700},
|
||||
{Name: "Reasonix.exe", Path: launcherSrc, Mode: 0o700},
|
||||
{Name: "reasonix-cli.exe", Path: cliSrc, Mode: 0o700},
|
||||
},
|
||||
RequiredRootNames: []string{"reasonix-launcher.exe", "Reasonix.exe", "reasonix-cli.exe"},
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Remove flat release-unit leftovers so the install root is the thin layout.
|
||||
// Do not remove the launcher/CLI/alias we just wrote.
|
||||
for _, name := range []string{
|
||||
"reasonix-desktop.exe",
|
||||
"reasonix-guard.exe",
|
||||
"reasonix-update-helper.exe", // helper lives only under versions/
|
||||
} {
|
||||
_ = os.Remove(filepath.Join(installRoot, name))
|
||||
}
|
||||
// Best-effort retention GC of older version trees.
|
||||
_ = installlayout.RetainPreviousVersions(installRoot, 0)
|
||||
_ = installlayout.CleanupStaleStaging(installRoot, 0)
|
||||
return nil
|
||||
}
|
||||
|
||||
// preferVersionedWindowsActivation reports whether the staged payload is
|
||||
// complete enough for versioned-v1 activation.
|
||||
func preferVersionedWindowsActivation(stagingDir string) bool {
|
||||
for _, name := range []string{
|
||||
"reasonix-desktop.exe",
|
||||
"reasonix-cli.exe",
|
||||
"reasonix-update-helper.exe",
|
||||
"reasonix-launcher.exe",
|
||||
} {
|
||||
info, err := os.Lstat(filepath.Join(stagingDir, name))
|
||||
if err != nil || !info.Mode().IsRegular() {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -1,131 +0,0 @@
|
||||
//go:build windows
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/platform/installlayout"
|
||||
"reasonix/internal/platform/repair"
|
||||
|
||||
"reasonix/internal/base/tempdir"
|
||||
)
|
||||
|
||||
func TestPreferVersionedWindowsActivation(t *testing.T) {
|
||||
staging := tempdir.New(t)
|
||||
if preferVersionedWindowsActivation(staging) {
|
||||
t.Fatal("empty staging must not prefer versioned")
|
||||
}
|
||||
for _, name := range []string{
|
||||
"reasonix-desktop.exe",
|
||||
"reasonix-cli.exe",
|
||||
"reasonix-update-helper.exe",
|
||||
"reasonix-launcher.exe",
|
||||
} {
|
||||
if err := os.WriteFile(filepath.Join(staging, name), []byte(name), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if !preferVersionedWindowsActivation(staging) {
|
||||
t.Fatal("complete staging must prefer versioned")
|
||||
}
|
||||
}
|
||||
|
||||
func TestActivateVersionedWindowsFromStaging(t *testing.T) {
|
||||
installDir := tempdir.New(t)
|
||||
staging := tempdir.New(t)
|
||||
for _, name := range []string{
|
||||
"reasonix-desktop.exe",
|
||||
"reasonix-cli.exe",
|
||||
"reasonix-update-helper.exe",
|
||||
"reasonix-launcher.exe",
|
||||
"reasonix-guard.exe",
|
||||
} {
|
||||
if err := os.WriteFile(filepath.Join(staging, name), []byte("payload:"+name), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
// Seed a flat desktop so cleanup is observable.
|
||||
if err := os.WriteFile(filepath.Join(installDir, "reasonix-desktop.exe"), []byte("old"), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(installDir, "reasonix-guard.exe"), []byte("old-guard"), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
claimed := &repair.UpdateTransaction{
|
||||
SchemaVersion: 1,
|
||||
ToVersion: "v1.20.0",
|
||||
TargetKind: "file",
|
||||
TargetPath: filepath.Join(installDir, "reasonix-desktop.exe"),
|
||||
CreatedAt: "2026-01-01T00:00:00Z",
|
||||
}
|
||||
if err := activateVersionedWindowsFromStaging(claimed, staging); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !installlayout.HasCurrent(installDir) {
|
||||
t.Fatal("current.json missing")
|
||||
}
|
||||
ptr, err := installlayout.ReadCurrent(installDir)
|
||||
if err != nil || ptr.ActiveVersion != "v1.20.0" {
|
||||
t.Fatalf("pointer=%+v err=%v", ptr, err)
|
||||
}
|
||||
desktop, err := installlayout.ActiveDesktopPath(installDir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
raw, err := os.ReadFile(desktop)
|
||||
if err != nil || string(raw) != "payload:reasonix-desktop.exe" {
|
||||
t.Fatalf("desktop payload = %q err=%v", raw, err)
|
||||
}
|
||||
for _, name := range []string{"reasonix-launcher.exe", "Reasonix.exe", "reasonix-cli.exe"} {
|
||||
if _, err := os.Stat(filepath.Join(installDir, name)); err != nil {
|
||||
t.Fatalf("root entry %s: %v", name, err)
|
||||
}
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(installDir, "reasonix-desktop.exe")); !os.IsNotExist(err) {
|
||||
t.Fatal("flat desktop should be removed")
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(installDir, "reasonix-guard.exe")); !os.IsNotExist(err) {
|
||||
t.Fatal("flat guard should be removed")
|
||||
}
|
||||
if prefer := preferRelaunchPath("", installDir); filepath.Base(prefer) != "reasonix-launcher.exe" {
|
||||
t.Fatalf("prefer relaunch = %s", prefer)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallStagedWindowsReleaseUnitPrefersVersioned(t *testing.T) {
|
||||
installDir := tempdir.New(t)
|
||||
staging := tempdir.New(t)
|
||||
for _, name := range []string{
|
||||
"reasonix-desktop.exe",
|
||||
"reasonix-cli.exe",
|
||||
"reasonix-update-helper.exe",
|
||||
"reasonix-launcher.exe",
|
||||
"reasonix-guard.exe",
|
||||
} {
|
||||
if err := os.WriteFile(filepath.Join(staging, name), []byte("p:"+name), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
// Minimal claimed unit (versioned path does not need full flat file list).
|
||||
claimed := &repair.UpdateTransaction{
|
||||
SchemaVersion: 1,
|
||||
ToVersion: "v1.20.0",
|
||||
TargetKind: "file",
|
||||
TargetPath: filepath.Join(installDir, "reasonix-desktop.exe"),
|
||||
CreatedAt: "2026-01-01T00:00:00Z",
|
||||
}
|
||||
started, receipts, err := installStagedWindowsReleaseUnit(claimed, staging)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !started || receipts != nil {
|
||||
t.Fatalf("started=%v receipts=%v want started with nil receipts", started, receipts)
|
||||
}
|
||||
if !installlayout.HasCurrent(installDir) {
|
||||
t.Fatal("versioned activation did not write current.json")
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,7 @@ Json listApps() {
|
||||
windows.push(Json::object()
|
||||
.set("id", static_cast<long long>(reinterpret_cast<uintptr_t>(hwnd)))
|
||||
.set("title", narrow(std::wstring(title, n > 0 ? n : 0)))
|
||||
.set("owned", GetWindow(hwnd, GW_OWNER) != nullptr && !(GetWindowLongPtrW(hwnd, GWL_EXSTYLE) & WS_EX_APPWINDOW))
|
||||
.set("bounds", rectJson(windowBounds(hwnd))));
|
||||
}
|
||||
apps.push(Json::object()
|
||||
|
||||
@@ -48,6 +48,9 @@ bool ownsPoint(DWORD pid, POINT at);
|
||||
bool isFront(DWORD pid);
|
||||
HWND heldOwner(HWND window, DWORD pid);
|
||||
void front(DWORD pid);
|
||||
// targetWindow aims the request being served at one of the application's
|
||||
// windows, which appWindows then lists first; null leaves the front one.
|
||||
void targetWindow(DWORD pid, HWND window);
|
||||
RECT windowBounds(HWND hwnd);
|
||||
Json rectJson(const RECT& r);
|
||||
|
||||
|
||||
@@ -109,6 +109,13 @@ static Json handle(const std::string& method, const Json& params) {
|
||||
if (!perApp.count(method)) throw Failure{"computer.bad_request", "unknown method " + method};
|
||||
DWORD pid = pidParam(params);
|
||||
requireOperable(pid);
|
||||
struct Aim {
|
||||
explicit Aim(DWORD pid, const Json& params) {
|
||||
const Json* w = params.get("window");
|
||||
if (w && w->isNumber() && w->number() > 0 && w->number() <= 9007199254740992.0) targetWindow(pid, reinterpret_cast<HWND>(static_cast<uintptr_t>(w->number())));
|
||||
}
|
||||
~Aim() { targetWindow(0, nullptr); }
|
||||
} aim(pid, params);
|
||||
if (method == "snapshot") return snapshot(pid);
|
||||
if (method == "screenshot") return capture(pid);
|
||||
if (method == "press") return press(pid, stringParam(params, "ref"));
|
||||
|
||||
@@ -153,6 +153,30 @@ bool appListed(HWND top) {
|
||||
return GetWindow(top, GW_OWNER) == nullptr && (ex & WS_EX_NOACTIVATE) == 0;
|
||||
}
|
||||
|
||||
static HWND aimed = nullptr;
|
||||
|
||||
// ownsForeground is whether the foreground is the aimed window or a dialog it
|
||||
// owns; with no window aimed any foreground of the process will do.
|
||||
static bool ownsForeground(HWND window) {
|
||||
if (!window) return true;
|
||||
for (HWND w = GetForegroundWindow(); w; w = GetWindow(w, GW_OWNER)) {
|
||||
if (w == window) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
void targetWindow(DWORD pid, HWND window) {
|
||||
aimed = nullptr;
|
||||
if (!window) return;
|
||||
for (HWND w : appWindows(pid, true)) {
|
||||
if (w == window) {
|
||||
aimed = window;
|
||||
return;
|
||||
}
|
||||
}
|
||||
throw Failure{"computer.no_window", "that window is not one of the application's windows on screen"};
|
||||
}
|
||||
|
||||
// appWindows lists a process's top-level windows front to back, dialogs it
|
||||
// owns included, with the one in front of the person first.
|
||||
std::vector<HWND> appWindows(DWORD pid, bool includeMinimized) {
|
||||
@@ -168,7 +192,7 @@ std::vector<HWND> appWindows(DWORD pid, bool includeMinimized) {
|
||||
if (r.right - r.left > 1 && r.bottom - r.top > 1) w->out.push_back(hwnd);
|
||||
return TRUE;
|
||||
}, reinterpret_cast<LPARAM>(&w));
|
||||
HWND fg = GetForegroundWindow();
|
||||
HWND fg = aimed ? aimed : GetForegroundWindow();
|
||||
for (size_t i = 1; i < w.out.size(); i++) {
|
||||
if (w.out[i] == fg) {
|
||||
w.out.erase(w.out.begin() + i);
|
||||
@@ -223,7 +247,7 @@ HWND heldOwner(HWND window, DWORD pid) {
|
||||
// foreground is. Windows grants the foreground only to a process that received
|
||||
// the last input, so an input with no effect is injected first.
|
||||
void front(DWORD pid) {
|
||||
if (isFront(pid)) return;
|
||||
if (isFront(pid) && ownsForeground(aimed)) return;
|
||||
std::vector<HWND> wins = appWindows(pid, true);
|
||||
if (wins.empty()) throw Failure{"computer.no_window", "the application has no window to bring to the front"};
|
||||
HWND target = wins[0];
|
||||
@@ -233,8 +257,8 @@ void front(DWORD pid) {
|
||||
nudge.mi.dwFlags = MOUSEEVENTF_MOVE;
|
||||
SendInput(1, &nudge, sizeof nudge);
|
||||
SetForegroundWindow(target);
|
||||
for (int i = 0; i < 25 && !isFront(pid); i++) std::this_thread::sleep_for(std::chrono::milliseconds(20));
|
||||
if (!isFront(pid)) {
|
||||
for (int i = 0; i < 25 && !(isFront(pid) && ownsForeground(aimed)); i++) std::this_thread::sleep_for(std::chrono::milliseconds(20));
|
||||
if (!(isFront(pid) && ownsForeground(aimed))) {
|
||||
DWORD fgThread = GetWindowThreadProcessId(GetForegroundWindow(), nullptr);
|
||||
DWORD me = GetCurrentThreadId();
|
||||
if (fgThread && fgThread != me && AttachThreadInput(me, fgThread, TRUE)) {
|
||||
@@ -242,9 +266,9 @@ void front(DWORD pid) {
|
||||
SetForegroundWindow(target);
|
||||
AttachThreadInput(me, fgThread, FALSE);
|
||||
}
|
||||
for (int i = 0; i < 25 && !isFront(pid); i++) std::this_thread::sleep_for(std::chrono::milliseconds(20));
|
||||
for (int i = 0; i < 25 && !(isFront(pid) && ownsForeground(aimed)); i++) std::this_thread::sleep_for(std::chrono::milliseconds(20));
|
||||
}
|
||||
if (!isFront(pid)) {
|
||||
if (!(isFront(pid) && ownsForeground(aimed))) {
|
||||
throw Failure{"computer.needs_front",
|
||||
"Windows kept this application from coming to the front; ask the person to click it once, then try again"};
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
"smoke": "electron test/smoke.js",
|
||||
"browser-live": "electron test/browser_live.js",
|
||||
"freeze-live": "electron test/freeze_live.js",
|
||||
"windowstate-live": "electron test/windowstate_live.js",
|
||||
"computer-live": "electron test/computer_live.js"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
"use strict";
|
||||
|
||||
// The acts the kernel asks of the application, because only this process can do
|
||||
// them. relaunch and quit are a handover and arrive in that order, so arming
|
||||
// the restart before ending is the sequence rather than a coincidence; show
|
||||
// brings the window forward for a clicked toast.
|
||||
//
|
||||
// Nothing is answered. Each is performed by ending or by showing, so an
|
||||
// acknowledgement would have to come from a process on its way out.
|
||||
function actHandler({ app, showWindow }) {
|
||||
return (act) => {
|
||||
if (act === "relaunch") {
|
||||
app.relaunch();
|
||||
return;
|
||||
}
|
||||
if (act === "quit") app.quit();
|
||||
if (act === "show") showWindow();
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { actHandler };
|
||||
@@ -27,9 +27,11 @@ const { BrowserViews } = require("./browserviews");
|
||||
const { startBrowserRelay } = require("./browserrelay");
|
||||
const { groundFor } = require("./ground");
|
||||
const { loadPrefs, prefsFile, registerPrefs } = require("./prefs");
|
||||
const { stateFile, openWindow } = require("./windowstate");
|
||||
const { accelerationOff, shouldDisableGpu, graphicsHandler } = require("./graphics");
|
||||
const { createPowerGuard, keepsAwake } = require("./powerguard");
|
||||
const { openLogs, redactArgv, failStartup } = require("./shelllog");
|
||||
const { actHandler } = require("./acts");
|
||||
const { crashDir, recordHostExit, pendingHostExit, clearHostExit, hostExitNotice } = require("./hostexit");
|
||||
|
||||
// A page in a minimized or fully covered window counts as hidden, and a hidden
|
||||
@@ -64,6 +66,7 @@ const hostBinary = layout.hostBinary(where);
|
||||
const computerHelper = layout.computerHelper(where);
|
||||
const pageDir = layout.pageDir(where);
|
||||
|
||||
const acts = actHandler({ app, showWindow: () => showWindow() });
|
||||
let kernel = null;
|
||||
let win = null;
|
||||
let origin = "";
|
||||
@@ -127,7 +130,7 @@ async function launchKernel(args) {
|
||||
app.quit();
|
||||
}
|
||||
},
|
||||
onAct: handOver,
|
||||
onAct: (act) => acts(act),
|
||||
});
|
||||
const current = kernel;
|
||||
current.child.on("error", (err) => logs.shell.line(`host: spawn failed: ${err.message}`));
|
||||
@@ -310,28 +313,34 @@ function createWindow() {
|
||||
const mac = process.platform === "darwin";
|
||||
const windows = process.platform === "win32";
|
||||
const chrome = mac || windows;
|
||||
return new BrowserWindow({
|
||||
...fitted(),
|
||||
...appIcon(),
|
||||
minWidth: MIN_SIZE.width,
|
||||
minHeight: MIN_SIZE.height,
|
||||
// Shown once it has been measured against the screen it landed on; sizing a
|
||||
// visible window makes the correction a flicker.
|
||||
show: false,
|
||||
backgroundColor: ground(),
|
||||
frame: !windows,
|
||||
titleBarStyle: mac ? "hiddenInset" : "default",
|
||||
...(mac ? { trafficLightPosition: LIGHTS } : {}),
|
||||
webPreferences: {
|
||||
preload: path.join(__dirname, "preload.js"),
|
||||
// Stated rather than left to the defaults: this is the list a reviewer
|
||||
// reads to know what the renderer may reach.
|
||||
nodeIntegration: false,
|
||||
contextIsolation: true,
|
||||
sandbox: true,
|
||||
webviewTag: false,
|
||||
webSecurity: true,
|
||||
additionalArguments: [`--reasonix-titlebar=${chrome ? "1" : "0"}`],
|
||||
return openWindow({
|
||||
BrowserWindow,
|
||||
screen,
|
||||
file: stateFile(app.getPath("userData")),
|
||||
fallback: fitted(),
|
||||
min: MIN_SIZE,
|
||||
options: {
|
||||
...appIcon(),
|
||||
minWidth: MIN_SIZE.width,
|
||||
minHeight: MIN_SIZE.height,
|
||||
// Shown once it has been measured against the screen it landed on; sizing a
|
||||
// visible window makes the correction a flicker.
|
||||
show: false,
|
||||
backgroundColor: ground(),
|
||||
frame: !windows,
|
||||
titleBarStyle: mac ? "hiddenInset" : "default",
|
||||
...(mac ? { trafficLightPosition: LIGHTS } : {}),
|
||||
webPreferences: {
|
||||
preload: path.join(__dirname, "preload.js"),
|
||||
// Stated rather than left to the defaults: this is the list a reviewer
|
||||
// reads to know what the renderer may reach.
|
||||
nodeIntegration: false,
|
||||
contextIsolation: true,
|
||||
sandbox: true,
|
||||
webviewTag: false,
|
||||
webSecurity: true,
|
||||
additionalArguments: [`--reasonix-titlebar=${chrome ? "1" : "0"}`],
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -517,21 +526,6 @@ async function hostLastWords() {
|
||||
return logs.host.tail();
|
||||
}
|
||||
|
||||
// The acts a handover asks of the application. The kernel decides when: it has
|
||||
// downloaded and staged a replacement, and what is left is the part only this
|
||||
// process can do. They arrive in this order, so arming the restart before
|
||||
// ending is the sequence rather than a coincidence.
|
||||
//
|
||||
// Nothing is answered. Both are performed by ending, so an acknowledgement
|
||||
// would have to come from a process on its way out.
|
||||
function handOver(act) {
|
||||
if (act === "relaunch") {
|
||||
app.relaunch();
|
||||
return;
|
||||
}
|
||||
if (act === "quit") app.quit();
|
||||
}
|
||||
|
||||
// Before the handshake the only window is the starting one, and the launch
|
||||
// decides for itself whether to go on.
|
||||
app.on("window-all-closed", () => {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
"use strict";
|
||||
const { Menu, Tray, nativeImage, screen } = require("electron");
|
||||
const { trayImage } = require("./trayimage.js");
|
||||
|
||||
// How often the fold is re-read. It is a projection the kernel recomputes on
|
||||
@@ -10,18 +9,25 @@ const FOLD_INTERVAL_MS = 5000;
|
||||
// installTray brings the icon up and reports whether there is one. A shell with
|
||||
// no icon must never background its window: hiding it would leave a running
|
||||
// session with nothing left to bring it back.
|
||||
function installTray(host, { onOpen, onQuit }) {
|
||||
function installTray(host, { onOpen, onQuit }, electron = require("electron")) {
|
||||
const { Menu, Tray, nativeImage, screen } = electron;
|
||||
const image = trayImage(nativeImage, screen.getPrimaryDisplay().scaleFactor);
|
||||
if (image.isEmpty()) return null;
|
||||
const icon = new Tray(image);
|
||||
const state = { fold: null, prefs: null };
|
||||
|
||||
const paint = () => {
|
||||
// The menu is rebuilt only when what it says changed. Linux hands the menu to
|
||||
// the desktop's indicator over D-Bus, and replacing it every poll closes one
|
||||
// the person has open or leaves the icon without one.
|
||||
let painted = "";
|
||||
const paint = (force = false) => {
|
||||
// The sentence and the menu words come from the kernel with the numbers:
|
||||
// the language is the desktop's setting, and one surface should not need a
|
||||
// second catalogue to say what the first already said.
|
||||
const line = state.fold?.line ?? "";
|
||||
const words = state.fold?.labels ?? {};
|
||||
const shown = JSON.stringify([line, words, !!state.prefs?.closeToTray]);
|
||||
if (!force && shown === painted) return;
|
||||
icon.setToolTip(line ? `Reasonix Studio — ${line}` : "Reasonix Studio");
|
||||
icon.setContextMenu(
|
||||
Menu.buildFromTemplate([
|
||||
@@ -37,13 +43,16 @@ function installTray(host, { onOpen, onQuit }) {
|
||||
// switch has one implementation whichever control asked.
|
||||
const next = await host.setTrayPrefs(true, !state.prefs?.closeToTray);
|
||||
if (next) state.prefs = next;
|
||||
paint();
|
||||
// The native checkbox has already flipped itself; redraw from state
|
||||
// even when nothing changed, so a refused save puts it back.
|
||||
paint(true);
|
||||
},
|
||||
},
|
||||
{ type: "separator" },
|
||||
{ label: words.quit ?? "Quit", click: onQuit },
|
||||
]),
|
||||
);
|
||||
painted = shown;
|
||||
};
|
||||
|
||||
const refit = () => icon.setImage(trayImage(nativeImage, screen.getPrimaryDisplay().scaleFactor));
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
"use strict";
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
|
||||
// Where the window was when it last closed, so the next launch opens the same
|
||||
// rectangle instead of the default. Only a rectangle some display can still
|
||||
// show is reused: a monitor that has gone leaves the saved position pointing at
|
||||
// nothing, and a frameless window has no title bar to drag back from there.
|
||||
const SAVE_DELAY_MS = 400;
|
||||
const GRAB = { width: 120, height: 40 };
|
||||
|
||||
const stateFile = (userData) => path.join(userData, "window-state.json");
|
||||
|
||||
const whole = (v) => Number.isFinite(v) && Number.isInteger(v);
|
||||
|
||||
function loadState(file) {
|
||||
try {
|
||||
const raw = JSON.parse(fs.readFileSync(file, "utf8"));
|
||||
if (!raw || typeof raw !== "object" || !whole(raw.width) || !whole(raw.height) || raw.width <= 0 || raw.height <= 0) return null;
|
||||
const placed = whole(raw.x) && whole(raw.y);
|
||||
return { ...(placed ? { x: raw.x, y: raw.y } : {}), width: raw.width, height: raw.height, maximized: raw.maximized === true };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Written beside and renamed over, so a crash mid-write leaves the last good copy.
|
||||
function saveState(file, state) {
|
||||
fs.mkdirSync(path.dirname(file), { recursive: true });
|
||||
const tmp = `${file}.tmp`;
|
||||
fs.writeFileSync(tmp, JSON.stringify(state));
|
||||
fs.renameSync(tmp, file);
|
||||
}
|
||||
|
||||
// The strip a person grabs the window by must lie inside some display's work area.
|
||||
function grabbable(saved, areas) {
|
||||
if (saved.x === undefined) return null;
|
||||
return (
|
||||
areas.find((a) => {
|
||||
const across = Math.min(saved.x + saved.width, a.x + a.width) - Math.max(saved.x, a.x);
|
||||
return across >= Math.min(GRAB.width, saved.width) && saved.y >= a.y && saved.y + GRAB.height <= a.y + a.height;
|
||||
}) ?? null
|
||||
);
|
||||
}
|
||||
|
||||
/** placement answers the rectangle to open at. The size is cut to the display
|
||||
* that shows the saved position, and the position is moved in until the whole
|
||||
* window fits; a rectangle nothing shows keeps only its size, cut to the
|
||||
* primary display, and the platform picks the place. The layout's minimum
|
||||
* comes last. */
|
||||
function placement({ saved, areas, primary, fallback, min }) {
|
||||
if (!saved) return { ...fallback, maximized: false };
|
||||
const home = grabbable(saved, areas);
|
||||
const area = home ?? primary;
|
||||
const width = Math.max(min.width, Math.min(saved.width, area.width));
|
||||
const height = Math.max(min.height, Math.min(saved.height, area.height));
|
||||
if (!home) return { width, height, maximized: saved.maximized };
|
||||
const within = (v, lo, span, size) => Math.max(lo, Math.min(v, lo + span - size));
|
||||
return { x: within(saved.x, area.x, area.width, width), y: within(saved.y, area.y, area.height, height), width, height, maximized: saved.maximized };
|
||||
}
|
||||
|
||||
// A minimised or full-screen window reports the screen's rectangle as its normal
|
||||
// bounds on some platforms, so what it says then is not what to come back to.
|
||||
const settled = (win) => !win.isMinimized() && !win.isFullScreen();
|
||||
|
||||
const capture = (win) => ({ ...win.getNormalBounds(), maximized: win.isMaximized() });
|
||||
|
||||
/** track writes the window's rectangle shortly after it settles and once more as
|
||||
* it closes. A minimised or full-screen window keeps its normal bounds. */
|
||||
function track(win, file, { delay = SAVE_DELAY_MS, timer = setTimeout, untimer = clearTimeout } = {}) {
|
||||
let pending = null;
|
||||
const write = () => {
|
||||
pending = null;
|
||||
if (win.isDestroyed() || !settled(win)) return;
|
||||
try {
|
||||
saveState(file, capture(win));
|
||||
} catch {
|
||||
// A rectangle that did not save is one launch at the default size, not a reason to fail the window.
|
||||
}
|
||||
};
|
||||
const later = () => {
|
||||
if (pending) untimer(pending);
|
||||
pending = timer(write, delay);
|
||||
};
|
||||
for (const event of ["resize", "move", "maximize", "unmaximize"]) win.on(event, later);
|
||||
win.on("close", () => {
|
||||
if (pending) untimer(pending);
|
||||
write();
|
||||
});
|
||||
}
|
||||
|
||||
/** openWindow builds the window at its remembered rectangle and keeps that
|
||||
* record current. It is hidden until the caller shows it, so a maximise has
|
||||
* nothing to flicker against. */
|
||||
function openWindow({ BrowserWindow, screen, file, fallback, min, options }) {
|
||||
const { maximized, ...rect } = placement({
|
||||
saved: loadState(file),
|
||||
areas: screen.getAllDisplays().map((d) => d.workArea),
|
||||
primary: screen.getPrimaryDisplay().workArea,
|
||||
fallback,
|
||||
min,
|
||||
});
|
||||
const win = new BrowserWindow({ ...rect, ...options });
|
||||
if (maximized) win.maximize();
|
||||
track(win, file);
|
||||
return win;
|
||||
}
|
||||
|
||||
module.exports = { stateFile, loadState, saveState, placement, track, openWindow, GRAB };
|
||||
@@ -1866,3 +1866,186 @@ test("packaging: a Windows installer named for x64 is renamed to amd64", async (
|
||||
["ReasonixStudio-windows-amd64-installer.exe", "ReasonixStudio-windows-amd64.zip"],
|
||||
);
|
||||
});
|
||||
|
||||
const { stateFile, loadState, saveState, placement, track } = require("../src/windowstate.js");
|
||||
const { EventEmitter } = require("node:events");
|
||||
|
||||
const AREA = { x: 0, y: 0, width: 1920, height: 1040 };
|
||||
const SECOND = { x: 1920, y: 0, width: 1280, height: 1000 };
|
||||
const MIN = { width: 760, height: 480 };
|
||||
const FALLBACK = { width: 1440, height: 900 };
|
||||
const open = (saved, areas = [AREA, SECOND]) => placement({ saved, areas, primary: areas[0], fallback: FALLBACK, min: MIN });
|
||||
|
||||
test("window state: only a whole rectangle is read back, and it survives a round trip", () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "winstate-"));
|
||||
const file = stateFile(dir);
|
||||
assert.equal(loadState(file), null);
|
||||
fs.writeFileSync(file, "{not json");
|
||||
assert.equal(loadState(file), null);
|
||||
for (const bad of [{ width: 0, height: 500 }, { width: "900", height: 500 }, { width: 900.5, height: 500 }, [], null]) {
|
||||
fs.writeFileSync(file, JSON.stringify(bad));
|
||||
assert.equal(loadState(file), null, JSON.stringify(bad));
|
||||
}
|
||||
saveState(file, { x: 40, y: 60, width: 1000, height: 700, maximized: true });
|
||||
assert.deepEqual(loadState(file), { x: 40, y: 60, width: 1000, height: 700, maximized: true });
|
||||
fs.writeFileSync(file, JSON.stringify({ width: 1000, height: 700 }));
|
||||
assert.deepEqual(loadState(file), { width: 1000, height: 700, maximized: false });
|
||||
});
|
||||
|
||||
test("window state: a first launch opens at the default size and a saved rectangle is reopened as it was", () => {
|
||||
assert.deepEqual(open(null), { ...FALLBACK, maximized: false });
|
||||
const saved = { x: 300, y: 120, width: 1100, height: 760, maximized: false };
|
||||
assert.deepEqual(open(saved), saved);
|
||||
assert.deepEqual(open({ ...saved, x: 2100, y: 100 }), { ...saved, x: 2100, y: 100 });
|
||||
});
|
||||
|
||||
test("window state: a rectangle no display shows keeps its size and loses its place", () => {
|
||||
const gone = { x: 2400, y: 100, width: 1100, height: 760, maximized: false };
|
||||
assert.deepEqual(open(gone, [AREA]), { width: 1100, height: 760, maximized: false });
|
||||
const above = { x: 300, y: -500, width: 1100, height: 760, maximized: false };
|
||||
assert.deepEqual(open(above, [AREA]), { width: 1100, height: 760, maximized: false });
|
||||
const huge = { x: 5000, y: 0, width: 4000, height: 3000, maximized: true };
|
||||
assert.deepEqual(open(huge, [AREA]), { width: 1920, height: 1040, maximized: true });
|
||||
});
|
||||
|
||||
test("window state: a saved size is raised to the layout's floor", () => {
|
||||
const small = open({ x: 10, y: 10, width: 300, height: 200, maximized: false });
|
||||
assert.deepEqual([small.width, small.height], [760, 480]);
|
||||
});
|
||||
|
||||
function fakeWindow(normal) {
|
||||
const win = new EventEmitter();
|
||||
Object.assign(win, { maximized: false, destroyed: false, normal });
|
||||
win.getNormalBounds = () => win.normal;
|
||||
win.isMaximized = () => win.maximized;
|
||||
win.isDestroyed = () => win.destroyed;
|
||||
win.minimized = false;
|
||||
win.fullscreen = false;
|
||||
win.isMinimized = () => win.minimized;
|
||||
win.isFullScreen = () => win.fullscreen;
|
||||
return win;
|
||||
}
|
||||
|
||||
test("window state: moves are written once they settle, a close is written at once, and a maximised window keeps its normal bounds", () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "winstate-"));
|
||||
const file = stateFile(dir);
|
||||
const timers = [];
|
||||
const win = fakeWindow({ x: 10, y: 20, width: 900, height: 600 });
|
||||
track(win, file, { timer: (fn) => timers.push(fn) - 1, untimer: (id) => (timers[id] = () => {}) });
|
||||
win.emit("resize");
|
||||
win.emit("move");
|
||||
win.emit("resize");
|
||||
assert.equal(loadState(file), null);
|
||||
timers.at(-1)();
|
||||
assert.deepEqual(loadState(file), { x: 10, y: 20, width: 900, height: 600, maximized: false });
|
||||
win.normal = { x: 50, y: 60, width: 1000, height: 650 };
|
||||
win.maximized = true;
|
||||
win.emit("close");
|
||||
assert.deepEqual(loadState(file), { x: 50, y: 60, width: 1000, height: 650, maximized: true });
|
||||
win.destroyed = true;
|
||||
win.normal = { x: 1, y: 1, width: 5, height: 5 };
|
||||
win.emit("move");
|
||||
timers.at(-1)();
|
||||
assert.equal(loadState(file).width, 1000);
|
||||
});
|
||||
|
||||
test("window state: a minimised or full-screen close keeps the last settled record", () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "winstate-"));
|
||||
const file = stateFile(dir);
|
||||
const win = fakeWindow({ x: 10, y: 20, width: 900, height: 600 });
|
||||
track(win, file, { timer: () => 0, untimer: () => {} });
|
||||
win.emit("close");
|
||||
const kept = loadState(file);
|
||||
win.normal = { x: 0, y: 0, width: 1920, height: 1040 };
|
||||
win.minimized = true;
|
||||
win.emit("close");
|
||||
assert.deepEqual(loadState(file), kept);
|
||||
win.minimized = false;
|
||||
win.fullscreen = true;
|
||||
win.emit("close");
|
||||
assert.deepEqual(loadState(file), kept);
|
||||
});
|
||||
|
||||
test("window state: a saved size is cut to the display it opens on and the window is moved in", () => {
|
||||
const big = { x: 1700, y: 600, width: 1400, height: 900, maximized: false };
|
||||
const out = open(big, [AREA]);
|
||||
assert.deepEqual([out.width, out.height], [1400, 900].map((v, i) => Math.min(v, [1920, 1040][i])));
|
||||
assert.ok(out.x + out.width <= 1920 && out.y + out.height <= 1040, JSON.stringify(out));
|
||||
const small = { x: 100, y: 100, width: 1800, height: 1000, maximized: false };
|
||||
const cut = open(small, [{ x: 0, y: 0, width: 1366, height: 728 }]);
|
||||
assert.deepEqual(cut, { x: 0, y: 0, width: 1366, height: 728, maximized: false });
|
||||
});
|
||||
|
||||
test("kernel acts: show brings the window forward, relaunch arms the restart, quit ends, anything else is ignored", () => {
|
||||
const { actHandler } = require("../src/acts.js");
|
||||
const calls = [];
|
||||
const handle = actHandler({
|
||||
app: { relaunch: () => calls.push("relaunch"), quit: () => calls.push("quit") },
|
||||
showWindow: () => calls.push("show"),
|
||||
});
|
||||
for (const act of ["show", "relaunch", "quit", "unknown"]) handle(act);
|
||||
assert.deepEqual(calls, ["show", "relaunch", "quit"]);
|
||||
});
|
||||
|
||||
test("tray: the menu is replaced only when what it says changes, so an open menu is not torn down by the poll", async () => {
|
||||
const { installTray } = require("../src/tray.js");
|
||||
const menus = [];
|
||||
let tips = 0;
|
||||
class FakeTray {
|
||||
on() {}
|
||||
setImage() {}
|
||||
setToolTip() { tips++; }
|
||||
setContextMenu(menu) { menus.push(menu); }
|
||||
destroy() {}
|
||||
}
|
||||
const electron = {
|
||||
Menu: { buildFromTemplate: (template) => ({ template }) },
|
||||
Tray: FakeTray,
|
||||
nativeImage: { createFromPath: () => ({ isEmpty: () => false }), createFromBuffer: () => ({ isEmpty: () => false }) },
|
||||
screen: { getPrimaryDisplay: () => ({ scaleFactor: 1 }), on() {}, removeListener() {} },
|
||||
};
|
||||
let fold = { line: "1 running", labels: { open: "Open", quit: "Quit", closeToTray: "Keep running" } };
|
||||
const host = { trayPrefs: async () => ({ closeToTray: false }), trayState: async () => fold, setTrayPrefs: async () => ({ closeToTray: true }) };
|
||||
const tray = installTray(host, { onOpen() {}, onQuit() {} }, electron);
|
||||
await tray.refresh();
|
||||
await tray.refresh();
|
||||
await tray.refresh();
|
||||
assert.equal(menus.length, 1, "three identical reads rebuilt the menu");
|
||||
assert.equal(tips, 1);
|
||||
fold = { ...fold, line: "2 running" };
|
||||
await tray.refresh();
|
||||
assert.equal(menus.length, 2);
|
||||
assert.equal(menus[1].template.find((item) => item.enabled === false).label, "2 running");
|
||||
tray.close();
|
||||
});
|
||||
|
||||
test("tray: a refused close-to-tray save redraws the checkbox, and a menu that failed to draw is tried again", async () => {
|
||||
const { installTray } = require("../src/tray.js");
|
||||
const menus = [];
|
||||
let failNext = false;
|
||||
class FakeTray {
|
||||
on() {}
|
||||
setImage() {}
|
||||
setToolTip() {}
|
||||
setContextMenu(menu) { if (failNext) { failNext = false; throw new Error("indicator busy"); } menus.push(menu); }
|
||||
destroy() {}
|
||||
}
|
||||
const electron = {
|
||||
Menu: { buildFromTemplate: (template) => ({ template }) },
|
||||
Tray: FakeTray,
|
||||
nativeImage: { createFromPath: () => ({ isEmpty: () => false }), createFromBuffer: () => ({ isEmpty: () => false }) },
|
||||
screen: { getPrimaryDisplay: () => ({ scaleFactor: 1 }), on() {}, removeListener() {} },
|
||||
};
|
||||
const fold = { line: "idle", labels: { open: "Open", quit: "Quit", closeToTray: "Keep running" } };
|
||||
const host = { trayPrefs: async () => ({ closeToTray: false }), trayState: async () => fold, setTrayPrefs: async () => null };
|
||||
const tray = installTray(host, { onOpen() {}, onQuit() {} }, electron);
|
||||
failNext = true;
|
||||
await assert.rejects(tray.refresh());
|
||||
await tray.refresh();
|
||||
assert.equal(menus.length, 1, "a menu whose draw threw was never retried");
|
||||
const toggle = menus[0].template.find((item) => item.type === "checkbox");
|
||||
await toggle.click();
|
||||
assert.equal(menus.length, 2, "a refused save left the flipped checkbox on screen");
|
||||
assert.equal(menus[1].template.find((item) => item.type === "checkbox").checked, false);
|
||||
tray.close();
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
"use strict";
|
||||
// Opens, moves, maximises and closes real windows through the shell's own openWindow, then reopens them: the
|
||||
// rectangle a window had when it closed is the one the next launch opens at. Needs no kernel and no network.
|
||||
// Run with `pnpm windowstate-live`.
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const { app, BrowserWindow, screen } = require("electron");
|
||||
const { stateFile, openWindow } = require("../src/windowstate");
|
||||
|
||||
const wait = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
const fails = [];
|
||||
const check = (name, ok, got) => {
|
||||
console.log((ok ? "ok " : "FAIL ") + name + (ok ? "" : " — " + JSON.stringify(got)));
|
||||
if (!ok) fails.push(name);
|
||||
};
|
||||
|
||||
const file = stateFile(fs.mkdtempSync(path.join(os.tmpdir(), "windowstate-live-")));
|
||||
const launch = () =>
|
||||
openWindow({
|
||||
BrowserWindow,
|
||||
screen,
|
||||
file,
|
||||
fallback: { width: 1000, height: 640 },
|
||||
min: { width: 400, height: 300 },
|
||||
options: { show: true, minWidth: 400, minHeight: 300 },
|
||||
});
|
||||
const closeOne = async (win) => {
|
||||
win.close();
|
||||
await wait(100);
|
||||
};
|
||||
|
||||
// Closing the last window would otherwise end the run between launches.
|
||||
app.on("window-all-closed", () => {});
|
||||
app.whenReady().then(async () => {
|
||||
const first = launch();
|
||||
await wait(300);
|
||||
check("a first launch opens at the default size", first.getBounds().width === 1000 && first.getBounds().height === 640, first.getBounds());
|
||||
const area = screen.getPrimaryDisplay().workArea;
|
||||
const rect = { x: area.x + 60, y: area.y + 50, width: 820, height: 560 };
|
||||
first.setBounds(rect);
|
||||
await wait(700);
|
||||
await closeOne(first);
|
||||
const kept = JSON.parse(fs.readFileSync(file, "utf8"));
|
||||
check("closing records the rectangle", kept.width === rect.width && kept.height === rect.height && kept.x === rect.x && kept.y === rect.y && kept.maximized === false, kept);
|
||||
|
||||
const second = launch();
|
||||
await wait(300);
|
||||
const got = second.getBounds();
|
||||
check("the next launch reopens at that rectangle", got.x === rect.x && got.y === rect.y && got.width === rect.width && got.height === rect.height, got);
|
||||
|
||||
second.maximize();
|
||||
await wait(700);
|
||||
await closeOne(second);
|
||||
const maxed = JSON.parse(fs.readFileSync(file, "utf8"));
|
||||
check("a maximised close keeps the rectangle it will restore to", maxed.maximized === true && maxed.width === rect.width && maxed.height === rect.height, maxed);
|
||||
|
||||
const third = launch();
|
||||
await wait(500);
|
||||
check("the next launch is maximised again", third.isMaximized() === true, third.getBounds());
|
||||
third.unmaximize();
|
||||
await wait(600);
|
||||
const restored = third.getBounds();
|
||||
check("and restores to the rectangle it had before", restored.width === rect.width && restored.height === rect.height, restored);
|
||||
await closeOne(third);
|
||||
|
||||
const fourth = launch();
|
||||
await wait(400);
|
||||
fourth.maximize();
|
||||
await wait(900);
|
||||
fourth.minimize();
|
||||
await wait(900);
|
||||
await closeOne(fourth);
|
||||
const minimised = JSON.parse(fs.readFileSync(file, "utf8"));
|
||||
check("closing while minimised keeps the maximised record and its restore size", minimised.maximized === true && minimised.width === rect.width && minimised.height === rect.height, minimised);
|
||||
|
||||
fs.writeFileSync(file, JSON.stringify({ x: area.x + 40, y: area.y + 40, width: area.width + 800, height: area.height + 600, maximized: false }));
|
||||
const big = launch();
|
||||
await wait(400);
|
||||
const o = big.getBounds();
|
||||
check("a saved size larger than the display is cut to it and kept on screen", o.width <= area.width && o.height <= area.height && o.x >= area.x && o.y >= area.y && o.x + o.width <= area.x + area.width && o.y + o.height <= area.y + area.height, o);
|
||||
await closeOne(big);
|
||||
|
||||
fs.writeFileSync(file, JSON.stringify({ x: area.x + area.width + 5000, y: 10, width: 700, height: 500, maximized: false }));
|
||||
const lost = launch();
|
||||
await wait(300);
|
||||
const b = lost.getBounds();
|
||||
const shown = screen.getAllDisplays().some((d) => b.x >= d.workArea.x && b.x < d.workArea.x + d.workArea.width && b.y >= d.workArea.y);
|
||||
check("a rectangle on a display that is gone opens on one that is there", shown && b.width === 700, b);
|
||||
await closeOne(lost);
|
||||
|
||||
console.log(fails.length ? `${fails.length} failed` : "all checks passed");
|
||||
app.exit(fails.length ? 1 : 0);
|
||||
});
|
||||
@@ -25,13 +25,13 @@
|
||||
"@codemirror/lang-sql": "^6.10.0",
|
||||
"@codemirror/lang-xml": "^6.1.0",
|
||||
"@codemirror/lang-yaml": "^6.1.3",
|
||||
"@codemirror/language": "^6.12.4",
|
||||
"@codemirror/language": "^6.13.1",
|
||||
"@codemirror/search": "^6.7.2",
|
||||
"@codemirror/state": "^6.7.6",
|
||||
"@codemirror/view": "^6.43.13",
|
||||
"@codemirror/view": "^6.43.14",
|
||||
"@lezer/highlight": "^1.2.5",
|
||||
"codemirror": "^6.0.2",
|
||||
"katex": "^0.18.10",
|
||||
"katex": "^0.19.0",
|
||||
"react": "^19.3.0",
|
||||
"react-dom": "^19.3.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
@@ -44,16 +44,16 @@
|
||||
"remark-math": "^6.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/parser": "^8.0.6",
|
||||
"@babel/parser": "^8.0.7",
|
||||
"@testing-library/react": "^16.3.3",
|
||||
"@testing-library/user-event": "^14.6.7",
|
||||
"@types/react": "^19.3.0",
|
||||
"@types/react-dom": "^19.3.0",
|
||||
"@vitejs/plugin-react": "^6.1.1",
|
||||
"jsdom": "^30.1.1",
|
||||
"playwright": "^1.63.0",
|
||||
"@vitejs/plugin-react": "^6.1.2",
|
||||
"jsdom": "^30.1.2",
|
||||
"playwright": "^1.64.0",
|
||||
"typescript": "^7.0.2",
|
||||
"vite": "^8.3.2",
|
||||
"vite": "^8.3.3",
|
||||
"vitest": "^5.0.3"
|
||||
}
|
||||
}
|
||||
@@ -68,6 +68,13 @@ node perf/find.mjs # 对话内查找:快捷键穿过输入框、跳到
|
||||
node perf/focus.mjs # 专注的守卫:拿走外围之后,退出时世界是否原样
|
||||
node perf/tooltip.mjs # 控件提示的守卫:文字不溢出提示框、不在半透明祖先之下、对比度达 AA(深浅×中英×宽窄)
|
||||
node perf/navhint.mjs # Icon-rail hover hints: drawn, inside the window, clipped by no ancestor, painted over by nothing (light/dark × on/collapsed)
|
||||
node perf/runningtool.mjs # 还没输出的运行中调用:行能展开、写明仍在运行与已运行多久、写出命令自带的超时
|
||||
node perf/gutterhit.mjs # 分栏拖拽手柄不压在相邻滚动条上:滚动条拇指处按下命中的不是手柄,握把仍可拖开收起的栏
|
||||
node perf/dock.mjs # 停靠的浏览器列从 pane 顶部起、与导航行并排,取消停靠后行归还(浅/深 × 宽/窄 × 首开/重载)
|
||||
node perf/signals.mjs # 运行分析的信号轨说明:它是什么、为什么标记不能拖(浅/深 × 宽/手机)
|
||||
node perf/narrow.mjs # 窄窗口 + 1.8 倍缩放:标题栏与输入区的行互不重叠、不越出窗口(浅/深 × 390/320 × 1.8/1.3,另加宽窗口对照)
|
||||
node perf/sidebar.mjs # 侧栏开关只有一个且位置不变(图标栏 开/仅收起时/关 × 浅/深);同一模型连续回复只在首条带头
|
||||
node perf/start.mjs # 经工作台标签/边缘把手打开工作台先出入口页(文件/改动/浏览器),不替人开浏览器;标题栏 Browser 按钮仍直达浏览器(浅/深 × 宽/窄)
|
||||
```
|
||||
|
||||
`lang.mjs` 从仓库外跑时用 `PERF_SRC` 指向 `src/`(它要读固件源码来决定
|
||||
|
||||
@@ -165,7 +165,7 @@ const edges = () => col.evaluate(() => {
|
||||
};
|
||||
});
|
||||
const setMeasure = (full) => col.evaluate((f) => {
|
||||
if (f) document.documentElement.dataset.measure = "full";
|
||||
if (f) document.documentElement.dataset.measure = typeof f === "string" ? f : "full";
|
||||
else delete document.documentElement.dataset.measure;
|
||||
}, full);
|
||||
const composeAt = {};
|
||||
@@ -197,6 +197,31 @@ check("全宽:转录比输入框宽", full.flow.r - full.flow.l > full.compose
|
||||
check("全宽:卡片跟着转录到边", full.boxed.every((x) => Math.abs(x.l - full.flow.l) <= 1 && Math.abs(x.r - full.flow.r) <= 1),
|
||||
`转录 ${full.flow.l}–${full.flow.r},卡片 ${JSON.stringify(full.boxed)}`);
|
||||
|
||||
await col.setViewportSize({ width: 1440, height: 900 });
|
||||
await setMeasure("full-all");
|
||||
await col.waitForTimeout(300);
|
||||
const fullAll = await edges();
|
||||
for (const width of [1000, 720]) {
|
||||
await col.setViewportSize({ width, height: 900 });
|
||||
await setMeasure("full-all");
|
||||
await col.waitForTimeout(300);
|
||||
const n = await edges();
|
||||
const inside = await col.evaluate(() => {
|
||||
const b = document.querySelector(".compose").getBoundingClientRect();
|
||||
const pane = document.querySelector(".compose").parentElement.getBoundingClientRect();
|
||||
return { left: Math.round(b.left - pane.left), right: Math.round(pane.right - b.right), w: innerWidth };
|
||||
});
|
||||
check(`${width}px 连输入框一起铺满:输入框不超出所在栏`, inside.left >= 0 && inside.right >= 0, `左 ${inside.left},右 ${inside.right}`);
|
||||
check(`${width}px 连输入框一起铺满:输入框与转录左右对齐(容差一个槽)`, Math.abs(n.compose.l - n.flow.l) <= 40 && Math.abs(n.compose.r - n.flow.r) <= 40,
|
||||
`转录 ${n.flow.l}–${n.flow.r},输入框 ${n.compose.l}–${n.compose.r}`);
|
||||
}
|
||||
await col.setViewportSize({ width: 1440, height: 900 });
|
||||
await setMeasure(false);
|
||||
check("连输入框一起铺满:输入框与转录同宽", Math.abs(fullAll.compose.l - fullAll.flow.l) <= 40 && Math.abs(fullAll.compose.r - fullAll.flow.r) <= 40,
|
||||
`转录 ${fullAll.flow.l}–${fullAll.flow.r},输入框 ${fullAll.compose.l}–${fullAll.compose.r}`);
|
||||
check("连输入框一起铺满:输入框比适宜阅读宽", fullAll.compose.r - fullAll.compose.l > composeAt[1440].r - composeAt[1440].l + 100,
|
||||
`适宜阅读 ${composeAt[1440].r - composeAt[1440].l}px,铺满 ${fullAll.compose.r - fullAll.compose.l}px`);
|
||||
|
||||
await browser.close();
|
||||
if (fails.length) {
|
||||
console.error(`\n${fails.length} 项不合格:\n ` + fails.join("\n "));
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
// A docked browser column starts at the top of the pane, beside the navigation row, and gives the rows back when it
|
||||
// is undocked. Where things start is a layout question, so it is asked of a real browser.
|
||||
import { chromium } from "playwright";
|
||||
|
||||
const BASE = process.env.PERF_URL ?? "http://localhost:4399/perf.html";
|
||||
const SHOTS = process.env.DOCK_SHOTS;
|
||||
const fails = [];
|
||||
const check = (name, ok, detail = "") => {
|
||||
console.log(`${ok ? " ok" : "FAIL"} ${name}${detail ? " — " + detail : ""}`);
|
||||
if (!ok) fails.push(name);
|
||||
};
|
||||
|
||||
const geometry = (page) =>
|
||||
page.evaluate(() => {
|
||||
const box = (sel) => {
|
||||
const el = document.querySelector(sel);
|
||||
const r = el?.getBoundingClientRect();
|
||||
return r && r.width > 0 ? { left: r.left, right: r.right, top: r.top, bottom: r.bottom } : null;
|
||||
};
|
||||
return { pane: box(".pane"), tabs: box(".pane > .tabs"), side: box('.pane > .scroll[data-pane="browser"]'), body: box(".pane > .pbody") };
|
||||
});
|
||||
|
||||
const browser = await chromium.launch();
|
||||
for (const scheme of ["light", "dark"]) {
|
||||
for (const [label, width] of [["wide", 1440], ["narrow", 560]]) {
|
||||
const page = await browser.newPage({ viewport: { width, height: 800 }, colorScheme: scheme, reducedMotion: "reduce" });
|
||||
page.setDefaultTimeout(8000);
|
||||
page.on("pageerror", (e) => fails.push("page error: " + e.message));
|
||||
const tag = `${scheme}/${label}`;
|
||||
let docked = null;
|
||||
for (const round of ["first open", "after a reload"]) {
|
||||
await page.goto(`${BASE}?pref=en&turns=3`, { waitUntil: "networkidle" });
|
||||
await page.waitForSelector(".app");
|
||||
await page.locator('[data-action="browser.open"]').first().click();
|
||||
await page.waitForTimeout(700);
|
||||
const open = await geometry(page);
|
||||
docked = open;
|
||||
check(`${tag} ${round}: the browser column is drawn`, !!open.side);
|
||||
if (open.side) {
|
||||
if (label === "wide") {
|
||||
check(`${tag} ${round}: it starts at the top of the pane, beside the navigation row`, Math.abs(open.side.top - open.pane.top) <= 1 && Math.abs(open.tabs.top - open.pane.top) <= 1, `pane ${open.pane.top} tabs ${open.tabs.top} column ${open.side.top}`);
|
||||
check(`${tag} ${round}: it runs to the bottom of the pane`, Math.abs(open.side.bottom - open.pane.bottom) <= 1, `${open.side.bottom} against ${open.pane.bottom}`);
|
||||
check(`${tag} ${round}: the navigation row stays left of it`, open.tabs.right <= open.side.left + 1, `${open.tabs.right} / ${open.side.left}`);
|
||||
} else {
|
||||
check(`${tag} ${round}: the navigation row stays above the page`, !!open.tabs && open.side.top >= open.tabs.bottom - 1, JSON.stringify([open.tabs, open.side]));
|
||||
}
|
||||
}
|
||||
if (SHOTS && round === "first open") await page.screenshot({ path: `${SHOTS}/${process.env.DOCK_TAG ?? "shot"}-${tag.replace("/", "-")}.png` });
|
||||
}
|
||||
await page.locator('[data-action="browser.open"]').first().click();
|
||||
await page.waitForTimeout(700);
|
||||
const shut = await geometry(page);
|
||||
check(`${tag} undocked: the column is gone and the navigation row is as wide as the pane's body again`, (!shut.side || shut.side.left >= shut.pane.right - 1) && !!shut.tabs && shut.tabs.right - shut.tabs.left >= shut.pane.right - shut.pane.left - 24, JSON.stringify([shut.side, shut.tabs, shut.pane]));
|
||||
await page.close();
|
||||
}
|
||||
}
|
||||
// Every room between phone and wide, not two samples: the left column shrinks with the window, and the tabs it holds
|
||||
// must still fit and stay clear of the browser column.
|
||||
{
|
||||
const page = await browser.newPage({ viewport: { width: 1440, height: 800 } });
|
||||
const bad = [];
|
||||
for (let width = 560; width <= 1440; width += 20) {
|
||||
await page.setViewportSize({ width, height: 800 });
|
||||
await page.goto(`${BASE}?pref=en&turns=3`, { waitUntil: "networkidle" });
|
||||
await page.waitForSelector(".app");
|
||||
await page.locator('[data-action="browser.open"]').first().click();
|
||||
await page.waitForTimeout(400);
|
||||
const g = await page.evaluate(() => {
|
||||
const tabs = document.querySelector(".pane > .tabs");
|
||||
const side = document.querySelector('.pane > .scroll[data-pane="browser"]');
|
||||
const t = tabs.getBoundingClientRect();
|
||||
const last = [...tabs.querySelectorAll(".tab")].at(-1)?.getBoundingClientRect();
|
||||
const s = side?.getBoundingClientRect();
|
||||
const stacked = !!s && s.top >= t.bottom - 1;
|
||||
const first = tabs.querySelector(".tab")?.getBoundingClientRect();
|
||||
const pad = parseFloat(getComputedStyle(tabs).paddingLeft) * 2;
|
||||
const need = last && first ? last.right - first.left + pad : 0;
|
||||
const pane = document.querySelector(".pane").getBoundingClientRect().width;
|
||||
return { fits: tabs.scrollWidth <= tabs.clientWidth + 1, clear: stacked || !s || !last || last.right <= s.left + 1, stacked, need, pane };
|
||||
});
|
||||
const should = g.pane < g.need + 320 || width <= 640;
|
||||
if (!g.fits || !g.clear || g.stacked !== should) bad.push(`${width}:${g.fits ? "" : "overflow "}${g.clear ? "" : "overlap "}${g.stacked !== should ? `stacked=${g.stacked} pane=${Math.round(g.pane)} need=${Math.round(g.need)}` : ""}`);
|
||||
}
|
||||
check("from 560 to 1440 the tabs fit their row, stay clear of the browser column, and the column stacks exactly when tabs plus its 320px floor no longer fit", bad.length === 0, bad.join(" "));
|
||||
await page.close();
|
||||
}
|
||||
await browser.close();
|
||||
if (fails.length) {
|
||||
console.log(`\n${fails.length} failed`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log("\nall ok");
|
||||
@@ -0,0 +1,119 @@
|
||||
// A column's drag handle must not sit on top of the scrollbar beside it: a press on the thumb has to scroll, and a
|
||||
// press on the handle has to resize. Hit-testing is a layout question, so it is asked of a real browser.
|
||||
import { chromium } from "playwright";
|
||||
|
||||
const BASE = process.env.PERF_URL ?? "http://localhost:4399/perf.html";
|
||||
const SHOTS = process.env.GUTTER_SHOTS;
|
||||
const fails = [];
|
||||
const check = (name, ok, detail = "") => {
|
||||
console.log(`${ok ? " ok" : "FAIL"} ${name}${detail ? " — " + detail : ""}`);
|
||||
if (!ok) fails.push(name);
|
||||
};
|
||||
|
||||
// The scrollbar is 10px wide against the scroller's right edge, its thumb inset 3px on each side.
|
||||
const THUMB = 5;
|
||||
const hit = (page, x, y) =>
|
||||
page.evaluate(([x, y]) => {
|
||||
const el = document.elementFromPoint(x, y);
|
||||
const g = el?.closest(".gutter");
|
||||
return { gutter: g ? g.className : null, tag: el ? el.className || el.tagName : null };
|
||||
}, [x, y]);
|
||||
|
||||
// Every row of the scroller's right edge, not one sample: a handle that only covers the top or the middle is still a
|
||||
// handle on the thumb whenever the thumb is there.
|
||||
const sweep = (page, right, top, height) =>
|
||||
page.evaluate(([right, top, height, inset]) => {
|
||||
const hits = [];
|
||||
for (let y = top + 2; y < top + height - 2; y += 12) {
|
||||
for (const dx of inset) {
|
||||
const g = document.elementFromPoint(right - dx, y)?.closest(".gutter");
|
||||
if (g) hits.push(`${g.className.split(" ").at(-1)}@${Math.round(y)}`);
|
||||
}
|
||||
}
|
||||
return hits;
|
||||
}, [right, top, height, [4, 5, 6]]);
|
||||
|
||||
const flowBox = (page) =>
|
||||
page.evaluate(() => {
|
||||
const r = document.querySelector('[data-pane="flow"]').getBoundingClientRect();
|
||||
return { right: r.right, top: r.top, height: r.height };
|
||||
});
|
||||
|
||||
const browser = await chromium.launch();
|
||||
for (const scheme of ["light", "dark"]) {
|
||||
for (const width of [1280, 1440]) {
|
||||
const page = await browser.newPage({ viewport: { width, height: 800 }, colorScheme: scheme, reducedMotion: "reduce" });
|
||||
page.setDefaultTimeout(8000);
|
||||
page.on("pageerror", (e) => fails.push("page error: " + e.message));
|
||||
await page.goto(`${BASE}?pref=en&turns=30`, { waitUntil: "networkidle" });
|
||||
await page.waitForSelector(".app");
|
||||
await page.evaluate(() => document.fonts.ready);
|
||||
const tag = `${scheme} ${width}px`;
|
||||
const flow = await page.evaluate(() => {
|
||||
const r = document.querySelector('[data-pane="flow"]').getBoundingClientRect();
|
||||
return { right: r.right, top: r.top, height: r.height };
|
||||
});
|
||||
const dockShut = await page.evaluate(() => document.querySelector(".gutter-r")?.hasAttribute("data-shut"));
|
||||
check(`${tag}: the workbench starts shut`, dockShut === true);
|
||||
const covered = await sweep(page, flow.right, flow.top, flow.height);
|
||||
check(`${tag}: no row of the transcript's scrollbar is under the workbench handle`, covered.length === 0, covered.slice(0, 4).join(" "));
|
||||
await page.mouse.move(flow.right - 5, flow.top + 8);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(flow.right - 5, flow.top + 160, { steps: 6 });
|
||||
await page.mouse.up();
|
||||
await page.waitForTimeout(250);
|
||||
check(`${tag}: pressing the scrollbar's top and dragging does not open the workbench`, (await page.evaluate(() => document.querySelector(".gutter-r")?.hasAttribute("data-shut"))) === true);
|
||||
|
||||
const grip = await page.locator(".gutter-r .grip").boundingBox();
|
||||
const pane = await page.evaluate(() => {
|
||||
const r = document.querySelector(".pane").getBoundingClientRect();
|
||||
return { top: r.top, height: r.height };
|
||||
});
|
||||
const mid = pane.top + pane.height / 2;
|
||||
check(`${tag}: the shut handle sits mid-height`, Math.abs(grip.y + grip.height / 2 - mid) <= pane.height * 0.05, `${Math.round(grip.y + grip.height / 2)} against ${Math.round(mid)}`);
|
||||
const onGrip = await hit(page, grip.x + grip.width / 2, grip.y + grip.height / 2);
|
||||
check(`${tag}: the shut workbench still has a handle to press`, onGrip.gutter?.includes("gutter-r") === true, String(onGrip.gutter));
|
||||
|
||||
const rail = await page.evaluate(() => {
|
||||
const el = document.querySelector(".rail .scroll");
|
||||
const r = el?.getBoundingClientRect();
|
||||
return r && r.width > 0 ? { right: r.right, top: r.top, height: r.height } : null;
|
||||
});
|
||||
if (rail) {
|
||||
const edge = await hit(page, rail.right - THUMB, rail.top + rail.height * 0.25);
|
||||
check(`${tag}: the workspace column's scrollbar is not under its handle`, edge.gutter === null, String(edge.gutter));
|
||||
const seam = await hit(page, rail.right + 3, rail.top + rail.height * 0.75);
|
||||
check(`${tag}: the workspace column's seam still resizes`, seam.gutter?.includes("gutter-l") === true, String(seam.gutter));
|
||||
}
|
||||
|
||||
if (SHOTS) await page.screenshot({ path: `${SHOTS}/${process.env.GUTTER_TAG ?? "shot"}-shut-${scheme}-${width}.png` });
|
||||
await page.mouse.move(grip.x + grip.width / 2, grip.y + grip.height / 2);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(grip.x - 200, grip.y + grip.height / 2, { steps: 8 });
|
||||
await page.mouse.up();
|
||||
await page.waitForTimeout(300);
|
||||
const shut = await page.evaluate(() => document.querySelector(".gutter-r")?.hasAttribute("data-shut"));
|
||||
check(`${tag}: dragging the handle opens the workbench`, shut === false);
|
||||
const now = await flowBox(page);
|
||||
const openCovered = await sweep(page, now.right, now.top, now.height);
|
||||
check(`${tag}: nor is it once the workbench is open`, openCovered.length === 0, openCovered.slice(0, 4).join(" "));
|
||||
const seamHit = await hit(page, now.right + 2, now.top + now.height * 0.5);
|
||||
check(`${tag}: the open seam still resizes`, seamHit.gutter?.includes("gutter-r") === true, String(seamHit.gutter));
|
||||
const wide = () => page.evaluate(() => document.querySelector('.scroll[data-pane="browser"]').getBoundingClientRect().width);
|
||||
const before = await wide();
|
||||
await page.mouse.move(now.right - 5, now.top + 8);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(now.right - 5, now.top + 160, { steps: 6 });
|
||||
await page.mouse.up();
|
||||
await page.waitForTimeout(250);
|
||||
check(`${tag}: pressing the scrollbar and dragging leaves the open workbench's width alone`, Math.abs((await wide()) - before) < 1);
|
||||
if (SHOTS) await page.screenshot({ path: `${SHOTS}/${process.env.GUTTER_TAG ?? "shot"}-${scheme}-${width}.png` });
|
||||
await page.close();
|
||||
}
|
||||
}
|
||||
await browser.close();
|
||||
if (fails.length) {
|
||||
console.log(`\n${fails.length} failed`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log("\nall ok");
|
||||
@@ -3,7 +3,13 @@ import { boot as bootLang } from "../src/i18n";
|
||||
import { track as trackWidth } from "../src/ui/viewport";
|
||||
import "../src/styles/tokens.css";
|
||||
import "../src/styles/app.css";
|
||||
import "../src/styles/dock.css";
|
||||
import "../src/styles/studio.css";
|
||||
import "../src/styles/tips.css";
|
||||
import "../src/styles/waitnote.css";
|
||||
import "../src/styles/narrow.css";
|
||||
import "../src/styles/workfold.css";
|
||||
import "../src/styles/workbench-launcher.css";
|
||||
import "../src/styles/feedback.css";
|
||||
import { App } from "../src/ui/App";
|
||||
import { MockHub } from "../src/port/mock_hub";
|
||||
@@ -78,6 +84,10 @@ class BenchPort extends MockPort {
|
||||
return ONBOARDING ? { required: true, provider: "", model: "" } : null;
|
||||
}
|
||||
|
||||
async foldWorkSettings() {
|
||||
return { ...(await super.foldWorkSettings()), fold: query.has("fold") };
|
||||
}
|
||||
|
||||
async workspaceGit() {
|
||||
const git = await super.workspaceGit();
|
||||
return query.get("git") === "none" ? { ...git, repo: false } : git;
|
||||
@@ -209,6 +219,11 @@ const NO_PANES = PANES === 0;
|
||||
|
||||
class BenchHub extends MockHub {
|
||||
readonly feeds = new Map<string, BenchPort>();
|
||||
private readonly settingsPort = new BenchPort();
|
||||
|
||||
machinePort(): AgentPort {
|
||||
return this.settingsPort;
|
||||
}
|
||||
|
||||
portFor(rt: RuntimeView): AgentPort {
|
||||
let port = this.feeds.get(rt.id);
|
||||
|
||||
@@ -1,85 +1,16 @@
|
||||
// The transcript skips layout for off-screen cards with content-visibility,
|
||||
// and each one holds an estimated height until it is first laid out. The
|
||||
// estimate need not be accurate, but its error must fall on both sides: below
|
||||
// the shortest card every unread card is underestimated, the error accumulates
|
||||
// in one direction, and the scroller lands short and then corrects — which is
|
||||
// the lurch. This measures the real distribution and checks the stylesheet's
|
||||
// number against it.
|
||||
// A transcript card's height is never an estimate. Rows near the viewport render, and Block unmounts the far ones
|
||||
// while holding the height it measured; a card that skipped layout with `content-visibility: auto` held a guess until
|
||||
// it first scrolled into view and the page then shrank under the wheel. The stylesheet must not bring that back.
|
||||
import { readFileSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { chromium } from "playwright";
|
||||
|
||||
const HERE = dirname(fileURLToPath(import.meta.url));
|
||||
const SRC = process.env.PERF_SRC ?? join(HERE, "..", "src");
|
||||
const PAGE = process.env.PERF_URL ?? "http://localhost:4399/perf.html?ws=1&sess=1&turns=40&pref=zh";
|
||||
|
||||
const css = readFileSync(join(SRC, "styles", "app.css"), "utf8");
|
||||
const declared = Number(css.match(/contain-intrinsic-size:\s*auto\s+(\d+(?:\.\d+)?)px/)?.[1] ?? NaN);
|
||||
if (!Number.isFinite(declared)) {
|
||||
console.log("未在样式表中找到 contain-intrinsic-size 的兜底值:该检查将始终通过。");
|
||||
process.exit(1);
|
||||
}
|
||||
const css = ["app.css", "studio.css"].map((f) => readFileSync(join(SRC, "styles", f), "utf8")).join("\n");
|
||||
|
||||
const fails = [];
|
||||
const check = (name, ok, detail = "") => {
|
||||
console.log(`${ok ? " ok" : "FAIL"} ${name}${detail ? " — " + detail : ""}`);
|
||||
if (!ok) fails.push(name);
|
||||
};
|
||||
|
||||
const browser = await chromium.launch();
|
||||
const page = await browser.newPage({ viewport: { width: 1440, height: 900 }, colorScheme: "dark" });
|
||||
await page.goto(PAGE, { waitUntil: "networkidle" });
|
||||
await page.waitForSelector(".compose");
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// Walk the whole transcript so every card lays out at least once: only
|
||||
// then is the distribution the one a reader meets.
|
||||
const stats = await page.evaluate(async () => {
|
||||
const sc = [...document.querySelectorAll("*")]
|
||||
.filter((el) => {
|
||||
const s = getComputedStyle(el);
|
||||
return /auto|scroll/.test(s.overflowY) && el.scrollHeight > el.clientHeight + 4 && el.querySelector(".call");
|
||||
})
|
||||
.pop();
|
||||
if (sc) {
|
||||
for (let y = 0; y <= sc.scrollHeight; y += 400) {
|
||||
sc.scrollTop = y;
|
||||
await new Promise((r) => requestAnimationFrame(r));
|
||||
}
|
||||
}
|
||||
const hs = [...document.querySelectorAll(".chunk > .enterbox > .call")]
|
||||
.map((c) => c.offsetHeight)
|
||||
.filter((x) => x > 0)
|
||||
.sort((a, b) => a - b);
|
||||
if (!hs.length) return null;
|
||||
const q = (p) => hs[Math.floor((hs.length - 1) * p)];
|
||||
return {
|
||||
n: hs.length,
|
||||
min: hs[0],
|
||||
median: q(0.5),
|
||||
mean: Math.round(hs.reduce((a, b) => a + b, 0) / hs.length),
|
||||
max: hs[hs.length - 1],
|
||||
};
|
||||
});
|
||||
|
||||
if (!stats) {
|
||||
console.log("未量到卡片:该检查将始终通过,请先确认页面存在转录内容。");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`样式表兜底值 ${declared}px 实测 n=${stats.n} 最小 ${stats.min} 中位数 ${stats.median} 均值 ${stats.mean} 最大 ${stats.max}`);
|
||||
check(
|
||||
"占位估算不低于最小卡片高度",
|
||||
declared >= stats.min,
|
||||
declared < stats.min ? `${declared} < ${stats.min}:每张未读卡片均被低估` : "",
|
||||
);
|
||||
check(
|
||||
"占位估算处于实测分布区间内",
|
||||
declared <= stats.max,
|
||||
declared > stats.max ? `${declared} > ${stats.max}:误差将反向累加` : "",
|
||||
);
|
||||
|
||||
await browser.close();
|
||||
console.log(fails.length ? `\n${fails.length} 项未通过` : "\n全部通过");
|
||||
const bad = [...css.matchAll(/([^{}]*\.call[^{}]*)\{([^{}]*)\}/g)].filter((m) => /\.chunk|\.enterbox/.test(m[1]) && /content-visibility\s*:\s*auto/.test(m[2]));
|
||||
if (bad.length) fails.push("estimated height");
|
||||
console.log(bad.length ? `FAIL ${bad.map((m) => m[1].trim()).join(" | ")} uses content-visibility: auto` : " ok no .call row in the transcript flow estimates its height");
|
||||
process.exit(fails.length ? 1 : 0);
|
||||
@@ -0,0 +1,133 @@
|
||||
// Under a high interface scale the layout's room is the window divided by the zoom, so a phone-width window can leave
|
||||
// the shell a couple of hundred CSS pixels. Rows that cannot fit must give something up, never paint over each other.
|
||||
import { chromium } from "playwright";
|
||||
|
||||
const BASE = process.env.PERF_URL ?? "http://localhost:4399/perf.html";
|
||||
const SHOTS = process.env.NARROW_SHOTS;
|
||||
const fails = [];
|
||||
const check = (name, ok, detail = "") => {
|
||||
console.log(`${ok ? " ok" : "FAIL"} ${name}${detail ? " — " + detail : ""}`);
|
||||
if (!ok) fails.push(name);
|
||||
};
|
||||
|
||||
const probe = (page) =>
|
||||
page.evaluate(() => {
|
||||
const live = (el) => {
|
||||
const r = el.getBoundingClientRect();
|
||||
return r.width > 0 && r.height > 0 && getComputedStyle(el).visibility !== "hidden" ? r : null;
|
||||
};
|
||||
const name = (el) => (el.className && String(el.className).split(/\s+/)[0]) || el.tagName;
|
||||
const overlaps = (parent) => {
|
||||
const boxes = [...parent.children].map((el) => [el, live(el)]).filter(([, r]) => r);
|
||||
const hits = [];
|
||||
for (let i = 0; i < boxes.length; i++)
|
||||
for (let j = i + 1; j < boxes.length; j++) {
|
||||
const [a, ra] = boxes[i];
|
||||
const [b, rb] = boxes[j];
|
||||
const x = Math.min(ra.right, rb.right) - Math.max(ra.left, rb.left);
|
||||
const y = Math.min(ra.bottom, rb.bottom) - Math.max(ra.top, rb.top);
|
||||
if (x > 1 && y > 1) hits.push(`${name(a)}/${name(b)}`);
|
||||
}
|
||||
return hits;
|
||||
};
|
||||
const outside = (parent) =>
|
||||
[...parent.children].filter((el) => live(el) && live(el).right > innerWidth + 1).map(name);
|
||||
const bar = document.querySelector(".chrome");
|
||||
const row = document.querySelector(".compose .row");
|
||||
const tools = document.querySelector(".compose .turntools");
|
||||
const crumb = bar.querySelector(".crumb");
|
||||
const title = crumb.querySelector("b");
|
||||
const clip = crumb.getBoundingClientRect();
|
||||
const t = title.getBoundingClientRect();
|
||||
return {
|
||||
bar: { overlaps: overlaps(bar).concat(overlaps(bar.querySelector(".r"))), outside: outside(bar).concat(outside(bar.querySelector(".r"))) },
|
||||
row: { overlaps: overlaps(row), outside: outside(row) },
|
||||
toolsOverflow: [...tools.children].filter((el) => live(el) && live(el).right > tools.getBoundingClientRect().right + 1).map(name),
|
||||
titleShown: t.width,
|
||||
titleOutsideCrumb: t.width > 0 && (t.left < clip.left - 1 || t.right > clip.right + 1),
|
||||
crumbWidth: clip.width,
|
||||
room: document.body.clientWidth,
|
||||
};
|
||||
});
|
||||
|
||||
const CONTROLS = [".phone-action", ".find-action", ".theme-toggle", ".top-action", ".browser-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) {
|
||||
const more = page.locator(".chrome .r-more");
|
||||
if (await more.isVisible()) {
|
||||
await more.click();
|
||||
await page.waitForTimeout(150);
|
||||
}
|
||||
return page.evaluate((controls) => {
|
||||
const out = [];
|
||||
for (const sel of controls) {
|
||||
const el = document.querySelector(`.chrome ${sel}`);
|
||||
if (!el) {
|
||||
out.push(`${sel} missing`);
|
||||
continue;
|
||||
}
|
||||
const r = el.getBoundingClientRect();
|
||||
const top = document.elementFromPoint(r.left + r.width / 2, r.top + r.height / 2);
|
||||
const inside = r.width > 0 && r.left >= 0 && r.right <= innerWidth + 1;
|
||||
if (!inside || !(el === top || el.contains(top))) out.push(`${sel}${inside ? " covered" : " off screen"}`);
|
||||
}
|
||||
return out;
|
||||
}, CONTROLS);
|
||||
}
|
||||
|
||||
const browser = await chromium.launch();
|
||||
|
||||
// The fold's threshold is derived, not chosen: wherever the bar is not folded the title keeps its floor.
|
||||
{
|
||||
const page = await browser.newPage({ viewport: { width: 1280, height: 800 } });
|
||||
const bad = [];
|
||||
for (let room = 190; room <= 460; room += 10) {
|
||||
await page.setViewportSize({ width: room, height: 800 });
|
||||
await page.goto(`${BASE}?pref=en&turns=2`, { waitUntil: "networkidle" });
|
||||
await page.waitForSelector(".app");
|
||||
await page.waitForTimeout(200);
|
||||
const w = await page.evaluate(() => document.querySelector(".chrome .crumb").getBoundingClientRect().width);
|
||||
if (w < 48) bad.push(`${room}:${Math.round(w)}`);
|
||||
}
|
||||
check("at every room from 190 to 460 the title keeps its 4em floor", bad.length === 0, bad.join(" "));
|
||||
await page.close();
|
||||
}
|
||||
for (const scheme of ["light", "dark"]) {
|
||||
for (const [width, zoom] of [[390, 1.8], [390, 1.3], [320, 1.8], [1280, 1]]) {
|
||||
const page = await browser.newPage({ viewport: { width, height: 800 }, colorScheme: scheme, reducedMotion: "reduce" });
|
||||
page.setDefaultTimeout(8000);
|
||||
page.on("pageerror", (e) => fails.push("page error: " + e.message));
|
||||
await page.goto(`${BASE}?pref=en&turns=2&zoom=${zoom}`, { waitUntil: "networkidle" });
|
||||
await page.waitForSelector(".app");
|
||||
await page.evaluate(() => document.fonts.ready);
|
||||
await page.waitForTimeout(500);
|
||||
const tag = `${scheme} ${width}px zoom ${zoom}`;
|
||||
const m = await probe(page);
|
||||
check(`${tag}: title bar controls do not overlap`, m.bar.overlaps.length === 0, m.bar.overlaps.join(", "));
|
||||
check(`${tag}: title bar stays inside the window`, m.bar.outside.length === 0, m.bar.outside.join(", "));
|
||||
check(`${tag}: composer row does not overlap`, m.row.overlaps.length === 0, m.row.overlaps.join(", "));
|
||||
check(`${tag}: composer toolbar controls stay in the toolbar`, m.toolsOverflow.length === 0, m.toolsOverflow.join(", "));
|
||||
check(`${tag}: the title keeps its 4em floor inside its own box`, m.crumbWidth >= 48 && !m.titleOutsideCrumb, `crumb ${Math.round(m.crumbWidth)}px`);
|
||||
if (SHOTS) await page.screenshot({ path: `${SHOTS}/${process.env.NARROW_TAG ?? "shot"}-${scheme}-${width}-z${zoom}.png` });
|
||||
const hint = await page.evaluate(() => {
|
||||
const el = document.querySelector(".compose textarea");
|
||||
const cs = getComputedStyle(el);
|
||||
const ctx = document.createElement("canvas").getContext("2d");
|
||||
ctx.font = `${cs.fontStyle} ${cs.fontWeight} ${cs.fontSize} ${cs.fontFamily}`;
|
||||
const room = el.clientWidth - parseFloat(cs.paddingLeft) - parseFloat(cs.paddingRight);
|
||||
return { text: el.placeholder, width: ctx.measureText(el.placeholder).width, room };
|
||||
});
|
||||
check(`${tag}: the composer's placeholder is on one line and not empty`, hint.text.length > 0 && hint.width <= hint.room + 1, JSON.stringify(hint));
|
||||
const reach = await reachable(page);
|
||||
check(`${tag}: every title-bar control can be reached, folded or not`, reach.length === 0, reach.join(", "));
|
||||
if (SHOTS) await page.screenshot({ path: `${SHOTS}/${process.env.NARROW_TAG ?? "shot"}-open-${scheme}-${width}-z${zoom}.png` });
|
||||
await page.close();
|
||||
}
|
||||
}
|
||||
await browser.close();
|
||||
if (fails.length) {
|
||||
console.log(`\n${fails.length} failed`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log("\nall ok");
|
||||
@@ -1,4 +1,4 @@
|
||||
// Icon-rail hover hints: each must be drawn, fully inside the window, and not clipped by any ancestor.
|
||||
// Icon-rail hover hints (drawn after a short hover delay): each must be drawn, fully inside the window, and not clipped by any ancestor.
|
||||
// Clipping is a layout question jsdom cannot answer. Modes are the stored rail choice; "collapsed" also closes the workspace rail.
|
||||
import { chromium } from "playwright";
|
||||
|
||||
@@ -58,7 +58,12 @@ for (const scheme of ["light", "dark"]) {
|
||||
for (let i = 0; i < count; i++) {
|
||||
const btn = page.locator(".nav .navbtn").nth(i);
|
||||
await btn.hover();
|
||||
await page.waitForTimeout(300);
|
||||
if (i === 0) {
|
||||
await page.waitForTimeout(120);
|
||||
const early = await measure(page, i);
|
||||
check(`${scheme}/${mode}: the hint is not drawn the instant the pointer arrives`, early && early.opacity < 1, `opacity ${early?.opacity}`);
|
||||
}
|
||||
await page.waitForTimeout(700);
|
||||
const m = await measure(page, i);
|
||||
const tag = `${scheme}/${mode}/#${i} ${m?.text ?? "?"}`;
|
||||
if (!m) { check(`${tag}: hint exists`, false); continue; }
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
// A long runtime notice has to be readable whole and dismissible without scrolling.
|
||||
// Reads geometry: the bar's sentence is not clipped by .composenotes and its dismiss
|
||||
// button lies inside the visible part of it.
|
||||
import { chromium } from "playwright";
|
||||
|
||||
const PAGE = 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 browser = await chromium.launch();
|
||||
const page = await browser.newPage({ viewport: { width: 1440, height: 900 } });
|
||||
page.on("pageerror", (e) => fails.push("page error: " + e.message));
|
||||
await page.goto(`${PAGE}?turns=2`, { waitUntil: "networkidle" });
|
||||
await page.waitForSelector(".app", { timeout: 20000 });
|
||||
await page.evaluate(() => {
|
||||
window.__feed({
|
||||
kind: "notice",
|
||||
level: "warn",
|
||||
audience: "operator",
|
||||
code: "permission_rules_dormant",
|
||||
text: "1 permission rule matches no tool",
|
||||
detail: JSON.stringify({ rules: [{ list: "ask", rule: "Foo(bar)" }] }),
|
||||
});
|
||||
});
|
||||
await page.waitForSelector(".rtbar", { timeout: 5000 });
|
||||
await page.waitForTimeout(1500);
|
||||
|
||||
for (const [w, h] of [[1440, 900], [400, 800], [1100, 640]]) {
|
||||
await page.setViewportSize({ width: w, height: h });
|
||||
await page.waitForTimeout(900);
|
||||
const g = await page.evaluate(() => {
|
||||
const notes = document.querySelector(".composenotes");
|
||||
const nr = notes.getBoundingClientRect();
|
||||
const btn = document.querySelector(".rtbar button").getBoundingClientRect();
|
||||
return {
|
||||
clipped: document.querySelector(".rtbar").getBoundingClientRect().bottom > nr.bottom + 1,
|
||||
btnIn: btn.top >= nr.top - 1 && btn.bottom <= nr.bottom + 1 && btn.right <= innerWidth + 1,
|
||||
sh: notes.scrollHeight, ch: notes.clientHeight,
|
||||
text: document.querySelector(".rtbar .t").textContent,
|
||||
};
|
||||
});
|
||||
// A short window has no room for a long sentence; it scrolls, and the button stays on the first row.
|
||||
if (h >= 720) check(`${w}x${h}: sentence is not clipped`, !g.clipped, `scroll ${g.sh} / client ${g.ch}`);
|
||||
check(`${w}x${h}: dismiss button is visible without scrolling`, g.btnIn);
|
||||
check(`${w}x${h}: no raw placeholder`, !/\{\w+\}/.test(g.text), g.text.slice(0, 30));
|
||||
}
|
||||
await browser.close();
|
||||
if (fails.length) {
|
||||
console.log(`\n${fails.length} failed`);
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -36,9 +36,11 @@ for (const scheme of ["light", "dark"]) {
|
||||
const g = await page.evaluate(() => {
|
||||
const el = document.querySelector(".reask textarea");
|
||||
if (!el) return null;
|
||||
return { h: el.getBoundingClientRect().height, scroll: el.scrollHeight, client: el.clientHeight, view: innerHeight };
|
||||
const row = el.closest(".call").getBoundingClientRect();
|
||||
return { h: el.getBoundingClientRect().height, w: el.getBoundingClientRect().width, row: row.width, scroll: el.scrollHeight, client: el.clientHeight, view: innerHeight };
|
||||
});
|
||||
check(`${scheme}/${label}: the rewrite box opened`, !!g);
|
||||
if (g) check(`${scheme}/${label}: the rewrite box spans its row, not the width a bare textarea starts at`, g.w >= g.row * 0.9 - 2, `box ${Math.round(g.w)}px of a ${Math.round(g.row)}px row`);
|
||||
if (g) {
|
||||
const room = Math.min(g.scroll, g.view * 0.6, 448);
|
||||
check(`${scheme}/${label}: long single-line text is visible without scrolling the box`, g.client >= room - 2, `box ${Math.round(g.h)}px, content ${g.scroll}px`);
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
// A running call with no output yet: its row opens, says it is still running and for how long, and names its own timeout.
|
||||
import { chromium } from "playwright";
|
||||
const fails = [];
|
||||
const check = (name, ok, detail = "") => { console.log(`${ok ? " ok" : "FAIL"} ${name}${detail ? " — " + detail : ""}`); if (!ok) fails.push(name); };
|
||||
const [,, out, w] = process.argv;
|
||||
const b = await chromium.launch();
|
||||
const p = await b.newPage({ viewport: { width: +w || 1100, height: 700 } });
|
||||
await p.goto((process.env.PERF_URL ?? "http://localhost:4521/perf.html") + "?pref=zh", { waitUntil: "networkidle" }); await p.waitForSelector(".app"); await p.waitForTimeout(400);
|
||||
await p.evaluate(async () => {
|
||||
window.__feed({ kind: "turn_started", authoredTurn: 1, msgIndex: 0, text: "list them" });
|
||||
window.__feed({ kind: "text", text: "我来看一下目录。" });
|
||||
const cmd = 'cd /Users/me/Documents/AI项目/音频搜索 && for f in *.md; do echo "=== $f ($(wc -l < "$f") 行)"; head -20 "$f"; done; ls -la /Users/me/Documents/ ; echo ---; find /Users/me/Documents -maxdepth 3 -name "*.md" | head -50';
|
||||
window.__feed({ kind: "tool_dispatch", tool: { id: "b1", name: "bash", args: JSON.stringify({ command: cmd, timeout_seconds: 900 }) } });
|
||||
await new Promise((r) => setTimeout(r, 300));
|
||||
});
|
||||
await p.waitForTimeout(600);
|
||||
if (out) await p.screenshot({ path: out + "-1.png" });
|
||||
const row = p.locator(".activity-group .call").first();
|
||||
check("the running row is a disclosure", (await row.locator("details.tool-disclosure").count()) === 1);
|
||||
await row.locator("summary").click().catch(() => {});
|
||||
await p.waitForTimeout(1500);
|
||||
const g = await p.evaluate(() => ({ note: document.querySelector(".activity-call-wait")?.textContent ?? "", input: !!document.querySelector(".activity-call-input"), title: document.querySelector(".activity-group .arg")?.getAttribute("title") ?? "" }));
|
||||
check("opened, it shows the whole command", g.input && g.title.includes("maxdepth 3"));
|
||||
check("and says it is still running with no output, and for how long", /命令仍在运行/.test(g.note) && /已运行/.test(g.note), g.note);
|
||||
check("and names the command's own timeout", /超时上限 900 秒/.test(g.note), g.note);
|
||||
if (out) await p.screenshot({ path: out + "-2.png" });
|
||||
await b.close();
|
||||
console.log(fails.length ? `\n${fails.length} failed` : "\nall ok");
|
||||
process.exit(fails.length ? 1 : 0);
|
||||
@@ -0,0 +1,83 @@
|
||||
// One sidebar toggle, drawn at one place whatever the sidebar and the icon rail are doing, never under another control;
|
||||
// and a run of replies from one model is headed once. Position and hit-testing are questions for a real browser.
|
||||
import { chromium } from "playwright";
|
||||
|
||||
const BASE = process.env.PERF_URL ?? "http://localhost:4399/perf.html";
|
||||
const SHOTS = process.env.SIDEBAR_SHOTS;
|
||||
const fails = [];
|
||||
const check = (name, ok, detail = "") => {
|
||||
console.log(`${ok ? " ok" : "FAIL"} ${name}${detail ? " — " + detail : ""}`);
|
||||
if (!ok) fails.push(name);
|
||||
};
|
||||
|
||||
const browser = await chromium.launch();
|
||||
for (const scheme of ["light", "dark"]) {
|
||||
for (const mode of ["on", "collapsed", "off"]) {
|
||||
const page = await browser.newPage({ viewport: { width: 1440, height: 900 }, colorScheme: scheme, reducedMotion: "reduce" });
|
||||
page.setDefaultTimeout(8000);
|
||||
page.on("pageerror", (e) => fails.push("page error: " + e.message));
|
||||
await page.addInitScript((m) => localStorage.setItem("rx-nav-rail", m), mode);
|
||||
await page.goto(`${BASE}?pref=en&turns=3`, { waitUntil: "networkidle" });
|
||||
await page.waitForSelector(".app");
|
||||
await page.evaluate(() => document.fonts.ready);
|
||||
const tag = `${scheme}/${mode}`;
|
||||
const spot = async () => {
|
||||
await page.waitForTimeout(700);
|
||||
return page.evaluate(() => {
|
||||
const toggles = [...document.querySelectorAll('[data-action="chrome.rail"]')].filter((b) => b.tabIndex !== -1 && b.getBoundingClientRect().width > 0 && b.getBoundingClientRect().right > 0);
|
||||
const b = toggles[0]?.getBoundingClientRect();
|
||||
if (!b) return { count: toggles.length };
|
||||
const x = b.left + b.width / 2;
|
||||
const y = b.top + b.height / 2;
|
||||
const top = document.elementFromPoint(x, y);
|
||||
return { count: toggles.length, x: Math.round(x), y: Math.round(y), reached: toggles[0].contains(top) };
|
||||
});
|
||||
};
|
||||
const open = await spot();
|
||||
check(`${tag}: exactly one sidebar toggle is drawn while the sidebar is open`, open.count === 1, String(open.count));
|
||||
check(`${tag}: nothing lies over it`, open.reached === true);
|
||||
if (SHOTS) await page.screenshot({ path: `${SHOTS}/${process.env.SIDEBAR_TAG ?? "shot"}-open-${tag.replace("/", "-")}.png` });
|
||||
await page.locator('[data-action="chrome.rail"]').filter({ visible: true }).first().click({ position: { x: 15, y: 15 } });
|
||||
const shut = await spot();
|
||||
check(`${tag}: exactly one toggle after collapsing`, shut.count === 1, String(shut.count));
|
||||
check(`${tag}: it has not moved`, shut.x === open.x && shut.y === open.y, `${open.x},${open.y} -> ${shut.x},${shut.y}`);
|
||||
check(`${tag}: nothing lies over it once collapsed`, shut.reached === true);
|
||||
if (SHOTS) await page.screenshot({ path: `${SHOTS}/${process.env.SIDEBAR_TAG ?? "shot"}-shut-${tag.replace("/", "-")}.png` });
|
||||
await page.close();
|
||||
}
|
||||
}
|
||||
for (const scheme of ["light", "dark"]) {
|
||||
const page = await browser.newPage({ viewport: { width: 1280, height: 900 }, colorScheme: scheme, reducedMotion: "reduce" });
|
||||
await page.goto(`${BASE}?pref=en`, { waitUntil: "networkidle" });
|
||||
await page.waitForSelector(".app");
|
||||
await page.evaluate(async () => {
|
||||
const say = (text) => {
|
||||
window.__feed({ kind: "text", text });
|
||||
window.__feed({ kind: "message" });
|
||||
};
|
||||
window.__feed({ kind: "turn_started" });
|
||||
say("First reply.");
|
||||
window.__feed({ kind: "tool_dispatch", tool: { id: "t1", name: "bash", args: "{}" } });
|
||||
window.__feed({ kind: "tool_result", tool: { id: "t1", name: "bash", args: "{}", output: "ok", durationMs: 5 } });
|
||||
say("Second reply, same model.");
|
||||
say("Third reply, same model.");
|
||||
window.__feed({ kind: "turn_done" });
|
||||
});
|
||||
await page.waitForTimeout(600);
|
||||
const heads = await page.evaluate(() =>
|
||||
[...document.querySelectorAll('.flow .call[data-k="say"]')].map((c) => {
|
||||
const hl = c.querySelector(":scope > .c > .hl");
|
||||
const sym = c.querySelector(":scope > .g");
|
||||
return { quiet: c.hasAttribute("data-quiet"), head: !!hl && getComputedStyle(hl).display !== "none", mark: !!sym && getComputedStyle(sym).display !== "none" };
|
||||
}),
|
||||
);
|
||||
check(`${scheme}: three replies in a turn, one header`, heads.length === 3 && heads.filter((h) => h.head && h.mark).length === 1 && heads[0].head && heads[0].mark, JSON.stringify(heads));
|
||||
if (SHOTS) await page.screenshot({ path: `${SHOTS}/${process.env.SIDEBAR_TAG ?? "shot"}-replies-${scheme}.png` });
|
||||
await page.close();
|
||||
}
|
||||
await browser.close();
|
||||
if (fails.length) {
|
||||
console.log(`\n${fails.length} failed`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log("\nall ok");
|
||||
@@ -0,0 +1,46 @@
|
||||
// The signal rail of the run analysis says what it is and why its marks cannot be moved.
|
||||
import { chromium } from "playwright";
|
||||
|
||||
const BASE = process.env.PERF_URL ?? "http://localhost:4399/perf.html";
|
||||
const SHOTS = process.env.SIGNALS_SHOTS;
|
||||
const fails = [];
|
||||
const check = (name, ok, detail = "") => {
|
||||
console.log(`${ok ? " ok" : "FAIL"} ${name}${detail ? " — " + detail : ""}`);
|
||||
if (!ok) fails.push(name);
|
||||
};
|
||||
|
||||
const browser = await chromium.launch();
|
||||
for (const scheme of ["light", "dark"]) {
|
||||
for (const [label, width] of [["wide", 1280], ["phone", 390]]) {
|
||||
const page = await browser.newPage({ viewport: { width, height: 900 }, colorScheme: scheme, reducedMotion: "reduce" });
|
||||
page.on("pageerror", (e) => fails.push("page error: " + e.message));
|
||||
await page.goto(`${BASE}?pref=en`, { waitUntil: "networkidle" });
|
||||
await page.waitForSelector(".app");
|
||||
await page.evaluate(() => {
|
||||
window.__feed({ kind: "turn_started" });
|
||||
window.__feed({ kind: "retrying", retryAttempt: 1, retryMax: 2, retryScope: "stream" });
|
||||
window.__feed({ kind: "tool_dispatch", tool: { id: "t1", name: "bash", args: "{}" } });
|
||||
window.__feed({ kind: "tool_result", tool: { id: "t1", name: "bash", args: "{}", output: "ok", durationMs: 40 } });
|
||||
window.__feed({ kind: "turn_done" });
|
||||
});
|
||||
await page.locator('[data-action="pane.view"][data-value="analysis"]').click();
|
||||
await page.waitForSelector(".run-signals");
|
||||
const tag = `${scheme}/${label}`;
|
||||
const g = await page.evaluate(() => {
|
||||
const note = document.querySelector(".run-signals .run-section-note");
|
||||
const r = note?.getBoundingClientRect();
|
||||
const box = document.querySelector(".run-signals").getBoundingClientRect();
|
||||
return { text: note?.textContent ?? "", inside: !!r && r.left >= box.left - 1 && r.right <= box.right + 1 && r.width > 0 };
|
||||
});
|
||||
check(`${tag}: the rail says what its marks are and why they stay put`, /位置就是时间|position is their time/.test(g.text), g.text);
|
||||
check(`${tag}: the explanation sits inside the section`, g.inside);
|
||||
if (SHOTS) await page.screenshot({ path: `${SHOTS}/${process.env.SIGNALS_TAG ?? "shot"}-${tag.replace("/", "-")}.png` });
|
||||
await page.close();
|
||||
}
|
||||
}
|
||||
await browser.close();
|
||||
if (fails.length) {
|
||||
console.log(`\n${fails.length} failed`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log("\nall ok");
|
||||
@@ -0,0 +1,57 @@
|
||||
// The workbench tab opens a launcher instead of a browser; the title-bar Browser button still goes straight to one.
|
||||
import { chromium } from "playwright";
|
||||
|
||||
const BASE = process.env.PERF_URL ?? "http://localhost:4399/perf.html";
|
||||
const SHOTS = process.env.START_SHOTS;
|
||||
const fails = [];
|
||||
const check = (name, ok, detail = "") => {
|
||||
console.log(`${ok ? " ok" : "FAIL"} ${name}${detail ? " — " + detail : ""}`);
|
||||
if (!ok) fails.push(name);
|
||||
};
|
||||
|
||||
const browser = await chromium.launch();
|
||||
for (const scheme of ["light", "dark"]) {
|
||||
for (const [label, width] of [["wide", 1440], ["narrow", 900]]) {
|
||||
const page = await browser.newPage({ viewport: { width, height: 900 }, colorScheme: scheme, reducedMotion: "reduce" });
|
||||
page.setDefaultTimeout(8000);
|
||||
page.on("pageerror", (e) => fails.push("page error: " + e.message));
|
||||
await page.goto(`${BASE}?pref=en&turns=2`, { waitUntil: "networkidle" });
|
||||
await page.waitForSelector(".app");
|
||||
const tag = `${scheme}/${label}`;
|
||||
const tabs = () => page.evaluate(() => document.querySelectorAll(".workbench-tab").length);
|
||||
await page.locator('[data-action="browser.open"]').first().click();
|
||||
await page.waitForTimeout(700);
|
||||
const direct = await page.evaluate(() => ({ launcher: !!document.querySelector(".workbench-launcher"), tabs: document.querySelectorAll(".workbench-tab").length }));
|
||||
check(`${tag}: the title-bar Browser button goes straight to a browser`, direct.tabs === 1 && !direct.launcher, JSON.stringify(direct));
|
||||
await page.goto(`${BASE}?pref=en&turns=2`, { waitUntil: "networkidle" });
|
||||
await page.waitForSelector(".app");
|
||||
await page.locator('[data-action="pane.view"][data-value="browser"]').click();
|
||||
await page.waitForTimeout(700);
|
||||
const start = await page.evaluate(() => {
|
||||
const box = document.querySelector(".workbench-launcher");
|
||||
const r = box?.getBoundingClientRect();
|
||||
return {
|
||||
shown: !!r && r.width > 0,
|
||||
inside: !!r && r.left >= 0 && r.right <= innerWidth && r.top >= 0 && r.bottom <= innerHeight,
|
||||
choices: [...(box?.querySelectorAll("button") ?? [])].map((b) => b.textContent.trim()),
|
||||
tabs: document.querySelectorAll(".workbench-tab").length,
|
||||
};
|
||||
});
|
||||
check(`${tag}: the Workbench tab opens the launcher`, start.shown, JSON.stringify(start));
|
||||
check(`${tag}: it offers files, changes and the browser`, start.choices.length === 3 && ["Files", "Changes", "Browser"].every((c, i) => start.choices[i].startsWith(c)), start.choices.join(" | "));
|
||||
check(`${tag}: no browser has been opened for the person`, start.tabs === 0, String(start.tabs));
|
||||
check(`${tag}: the launcher is inside the window`, start.inside);
|
||||
if (SHOTS) await page.screenshot({ path: `${SHOTS}/${process.env.START_TAG ?? "shot"}-${tag.replace("/", "-")}.png` });
|
||||
await page.locator(".workbench-launcher button", { hasText: "Browser" }).click().catch(() => {});
|
||||
await page.waitForTimeout(500);
|
||||
const after = await page.evaluate(() => ({ tabs: document.querySelectorAll(".workbench-tab").length, launcher: !!document.querySelector(".workbench-launcher") }));
|
||||
check(`${tag}: choosing the browser opens one and the launcher steps aside`, after.tabs === 1 && !after.launcher, JSON.stringify(after));
|
||||
await page.close();
|
||||
}
|
||||
}
|
||||
await browser.close();
|
||||
if (fails.length) {
|
||||
console.log(`\n${fails.length} failed`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log("\nall ok");
|
||||
@@ -1,10 +1,9 @@
|
||||
// 滚轮轨迹的探针:在一份很高的两轮转录里一路向上滚,scrollTop 在滚轮向上时
|
||||
// 不许往下走、单步不许比滚轮多走、scrollHeight 不许缩水。
|
||||
//
|
||||
// 目前只报告、不判红:`.call` 的 `content-visibility: auto` 配上
|
||||
// `contain-intrinsic-size: auto 216px`,会在块第一次进入视口时把预估高度改成
|
||||
// 实测高度,scrollHeight 随之缩水(12975 → 11217),内容在手指下跳动。修复
|
||||
// 落地后把 `WHEEL_STRICT` 的默认值翻成 1,这条探针就成为那次修复的验收。
|
||||
// `.call` 不再带 `content-visibility: auto`:它按预估高度占位、首次进入视口时改成实测高度,
|
||||
// scrollHeight 随之缩水(12975 → 11217),内容在手指下跳动。块的卸载与占位由 Block 负责。
|
||||
// 默认严格;WHEEL_STRICT=0 只报告。
|
||||
import { chromium } from "playwright";
|
||||
|
||||
const PAGE = process.env.PERF_URL ?? "http://localhost:4399/perf.html";
|
||||
@@ -12,7 +11,7 @@ const STEP = Number(process.env.WHEEL_STEP ?? 100);
|
||||
const TURNS = Number(process.env.WHEEL_TURNS ?? 2);
|
||||
const CALLS = Number(process.env.WHEEL_CALLS ?? 6);
|
||||
const WAIT = Number(process.env.WHEEL_WAIT ?? 120);
|
||||
const STRICT = (process.env.WHEEL_STRICT ?? "0") === "1";
|
||||
const STRICT = (process.env.WHEEL_STRICT ?? "1") === "1";
|
||||
|
||||
const browser = await chromium.launch();
|
||||
const ctx = await browser.newContext({ locale: "zh-CN", viewport: { width: 1440, height: 900 } });
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
// A finished turn's work, folded under one line when the kernel's setting asks for it: the answer stays outside, the
|
||||
// line opens on click and keeps its state, a failure shows on the line, and the running turn is never folded.
|
||||
import { chromium } from "playwright";
|
||||
|
||||
const BASE = process.env.PERF_URL ?? "http://localhost:4399/perf.html";
|
||||
const SHOTS = process.env.FOLD_SHOTS;
|
||||
const fails = [];
|
||||
const check = (name, ok, detail = "") => {
|
||||
console.log(`${ok ? " ok" : "FAIL"} ${name}${detail ? " — " + detail : ""}`);
|
||||
if (!ok) fails.push(name);
|
||||
};
|
||||
|
||||
const feedTurn = (page, { fail = false, done = true } = {}) =>
|
||||
page.evaluate(async ({ fail, done }) => {
|
||||
const frame = () => new Promise((r) => requestAnimationFrame(r));
|
||||
window.__feed({ kind: "turn_started", authoredTurn: 1, msgIndex: 0, text: "Please fix the failing test" });
|
||||
window.__feed({ kind: "text", text: "I will read the test first." });
|
||||
window.__feed({ kind: "message", text: "I will read the test first." });
|
||||
for (const [i, name] of ["read_file", "bash", "edit_file"].entries()) {
|
||||
const tool = { id: `t${i}`, name, args: JSON.stringify({ path: `pkg/f${i}.go` }) };
|
||||
window.__feed({ kind: "tool_dispatch", tool });
|
||||
window.__feed({ kind: "tool_result", tool: { ...tool, output: fail && i === 1 ? "exit 1" : "ok", durationMs: 120, ...(fail && i === 1 ? { err: "exit status 1" } : {}) } });
|
||||
await frame();
|
||||
}
|
||||
window.__feed({ kind: "text", text: "The cause was an off-by-one in the loop bound.\n\nI changed it and the test passes." });
|
||||
window.__feed({ kind: "message", text: "The cause was an off-by-one in the loop bound.\n\nI changed it and the test passes." });
|
||||
if (done) window.__feed({ kind: "turn_done" });
|
||||
await frame();
|
||||
}, { fail, done });
|
||||
|
||||
const browser = await chromium.launch();
|
||||
for (const scheme of ["light", "dark"]) {
|
||||
for (const [label, width] of [["wide", 1280], ["narrow", 420]]) {
|
||||
const tag = `${scheme}/${label}`;
|
||||
const page = await browser.newPage({ viewport: { width, height: 800 }, colorScheme: scheme, reducedMotion: "reduce" });
|
||||
page.setDefaultTimeout(8000);
|
||||
page.on("pageerror", (e) => fails.push("page error: " + e.message));
|
||||
await page.goto(`${BASE}?pref=en&fold`, { waitUntil: "networkidle" });
|
||||
await page.waitForSelector(".app");
|
||||
await feedTurn(page, { fail: true });
|
||||
await page.waitForTimeout(700);
|
||||
const g = await page.evaluate(() => {
|
||||
const fold = document.querySelector("details.work-fold");
|
||||
const final = [...document.querySelectorAll('.call[data-k="say"]')].at(-1);
|
||||
const r = fold?.getBoundingClientRect();
|
||||
return {
|
||||
folds: document.querySelectorAll("details.work-fold").length,
|
||||
open: fold?.open,
|
||||
failed: fold?.hasAttribute("data-failed"),
|
||||
line: fold?.querySelector("summary")?.textContent ?? "",
|
||||
bodyHidden: fold ? fold.querySelector(".work-fold-body").getBoundingClientRect().height === 0 || getComputedStyle(fold.querySelector(".work-fold-body")).display === "none" || !fold.open : null,
|
||||
answerOutside: !!final && !fold?.contains(final) && final.textContent.includes("off-by-one"),
|
||||
inside: !!r && r.left >= 0 && r.right <= innerWidth,
|
||||
};
|
||||
});
|
||||
check(`${tag}: the finished turn has one folded line`, g.folds === 1 && g.open === false, JSON.stringify(g));
|
||||
check(`${tag}: the answer stays outside it`, g.answerOutside);
|
||||
check(`${tag}: the line counts the calls and marks the failure`, /3/.test(g.line) && g.failed === true, g.line);
|
||||
check(`${tag}: the line fits the window`, g.inside);
|
||||
if (SHOTS) await page.screenshot({ path: `${SHOTS}/${process.env.FOLD_TAG ?? "shot"}-folded-${tag.replace("/", "-")}.png` });
|
||||
await page.locator("details.work-fold > summary").click();
|
||||
await page.waitForTimeout(500);
|
||||
check(`${tag}: a click opens it and the steps show`, await page.evaluate(() => document.querySelector("details.work-fold").open && document.querySelectorAll("details.work-fold .call").length >= 3));
|
||||
if (SHOTS) await page.screenshot({ path: `${SHOTS}/${process.env.FOLD_TAG ?? "shot"}-open-${tag.replace("/", "-")}.png` });
|
||||
await page.close();
|
||||
}
|
||||
}
|
||||
{
|
||||
const page = await browser.newPage({ viewport: { width: 1280, height: 800 } });
|
||||
await page.goto(`${BASE}?pref=en&fold`, { waitUntil: "networkidle" });
|
||||
await page.waitForSelector(".app");
|
||||
await feedTurn(page, { done: false });
|
||||
await page.waitForTimeout(500);
|
||||
check("a turn still running is never folded", (await page.locator("details.work-fold").count()) === 0);
|
||||
await page.evaluate(() => window.__feed({ kind: "turn_done" }));
|
||||
await page.waitForTimeout(500);
|
||||
check("it folds when the turn settles", (await page.locator("details.work-fold").count()) === 1);
|
||||
await page.close();
|
||||
}
|
||||
{
|
||||
const page = await browser.newPage({ viewport: { width: 1280, height: 800 } });
|
||||
await page.goto(`${BASE}?pref=en`, { waitUntil: "networkidle" });
|
||||
await page.waitForSelector(".app");
|
||||
await feedTurn(page);
|
||||
await page.waitForTimeout(500);
|
||||
check("with the setting off nothing is folded", (await page.locator("details.work-fold").count()) === 0);
|
||||
await page.close();
|
||||
}
|
||||
{
|
||||
const page = await browser.newPage({ viewport: { width: 1280, height: 700 } });
|
||||
await page.goto(`${BASE}?pref=en&fold&turns=30`, { waitUntil: "networkidle" });
|
||||
await page.waitForSelector(".app");
|
||||
await feedTurn(page, { done: false });
|
||||
await page.waitForTimeout(600);
|
||||
await page.evaluate(() => { const sc = document.querySelector('[data-pane="flow"]'); sc.scrollTop = sc.scrollHeight; });
|
||||
await page.waitForTimeout(300);
|
||||
const before = await page.evaluate(() => {
|
||||
const sc = document.querySelector('[data-pane="flow"]');
|
||||
const answer = [...document.querySelectorAll('.call[data-k="say"]')].at(-1).getBoundingClientRect();
|
||||
return { top: sc.scrollTop, h: sc.scrollHeight, answerTop: Math.round(answer.top), view: innerHeight };
|
||||
});
|
||||
const frames = await page.evaluate(async () => {
|
||||
const sc = document.querySelector('[data-pane="flow"]');
|
||||
const seen = [];
|
||||
const sample = () => {
|
||||
const a = [...document.querySelectorAll('.call[data-k="say"]')].at(-1);
|
||||
const fold = document.querySelector("details.work-fold");
|
||||
seen.push({ folds: fold ? 1 : 0, open: !!fold?.open, answerTop: Math.round(a.getBoundingClientRect().top), top: sc.scrollTop, h: sc.scrollHeight });
|
||||
};
|
||||
window.__feed({ kind: "turn_done" });
|
||||
for (let i = 0; i < 12; i++) {
|
||||
await new Promise((r) => requestAnimationFrame(r));
|
||||
sample();
|
||||
}
|
||||
return seen;
|
||||
});
|
||||
const first = frames[0];
|
||||
const last = frames.at(-1);
|
||||
const answerMoves = new Set(frames.map((f) => f.answerTop)).size;
|
||||
console.log(`settle: before answerTop=${before.answerTop} scrollHeight=${before.h}; first frame ${JSON.stringify(first)}; last ${JSON.stringify(last)}; distinct answer positions over 12 frames: ${answerMoves}`);
|
||||
check("the work is folded in the first frame after the turn ends, never drawn open and then closed", first.folds === 1 && frames.every((f) => !f.open));
|
||||
check("the answer settles in one move rather than wandering", answerMoves <= 2, String(answerMoves));
|
||||
check("the answer is still on screen after the fold", last.answerTop >= 0 && last.answerTop < before.view, String(last.answerTop));
|
||||
check("a reader pinned to the bottom stays there: the page and the scroll position shrink together", Math.abs(last.top + before.view - last.h) < before.view && last.answerTop > 0, JSON.stringify({ top: last.top, h: last.h }));
|
||||
await page.close();
|
||||
}
|
||||
await browser.close();
|
||||
if (fails.length) {
|
||||
console.log(`\n${fails.length} failed`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log("\nall ok");
|
||||
Generated
+167
-148
@@ -48,8 +48,8 @@ importers:
|
||||
specifier: ^6.1.3
|
||||
version: 6.1.3
|
||||
'@codemirror/language':
|
||||
specifier: ^6.12.4
|
||||
version: 6.12.4
|
||||
specifier: ^6.13.1
|
||||
version: 6.13.1
|
||||
'@codemirror/search':
|
||||
specifier: ^6.7.2
|
||||
version: 6.7.2
|
||||
@@ -57,8 +57,8 @@ importers:
|
||||
specifier: ^6.7.6
|
||||
version: 6.7.6
|
||||
'@codemirror/view':
|
||||
specifier: ^6.43.13
|
||||
version: 6.43.13
|
||||
specifier: ^6.43.14
|
||||
version: 6.43.14
|
||||
'@lezer/highlight':
|
||||
specifier: ^1.2.5
|
||||
version: 1.2.5
|
||||
@@ -66,8 +66,8 @@ importers:
|
||||
specifier: ^6.0.2
|
||||
version: 6.0.2
|
||||
katex:
|
||||
specifier: ^0.18.10
|
||||
version: 0.18.10
|
||||
specifier: ^0.19.0
|
||||
version: 0.19.0
|
||||
react:
|
||||
specifier: ^19.3.0
|
||||
version: 19.3.0
|
||||
@@ -100,8 +100,8 @@ importers:
|
||||
version: 6.0.0
|
||||
devDependencies:
|
||||
'@babel/parser':
|
||||
specifier: ^8.0.6
|
||||
version: 8.0.6
|
||||
specifier: ^8.0.7
|
||||
version: 8.0.7
|
||||
'@testing-library/react':
|
||||
specifier: ^16.3.3
|
||||
version: 16.3.3(@testing-library/[email protected])(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])
|
||||
@@ -115,32 +115,32 @@ importers:
|
||||
specifier: ^19.3.0
|
||||
version: 19.3.0(@types/[email protected])
|
||||
'@vitejs/plugin-react':
|
||||
specifier: ^6.1.1
|
||||
version: 6.1.1([email protected].2([email protected]))
|
||||
specifier: ^6.1.2
|
||||
version: 6.1.2([email protected].3([email protected]))
|
||||
jsdom:
|
||||
specifier: ^30.1.1
|
||||
version: 30.1.1
|
||||
specifier: ^30.1.2
|
||||
version: 30.1.2
|
||||
playwright:
|
||||
specifier: ^1.63.0
|
||||
version: 1.63.0
|
||||
specifier: ^1.64.0
|
||||
version: 1.64.0
|
||||
typescript:
|
||||
specifier: ^7.0.2
|
||||
version: 7.0.2
|
||||
vite:
|
||||
specifier: ^8.3.2
|
||||
version: 8.3.2([email protected])
|
||||
specifier: ^8.3.3
|
||||
version: 8.3.3([email protected])
|
||||
vitest:
|
||||
specifier: ^5.0.3
|
||||
version: 5.0.3([email protected].1)([email protected].2([email protected]))
|
||||
version: 5.0.3([email protected].2)([email protected].3([email protected]))
|
||||
|
||||
packages:
|
||||
|
||||
'@asamuzakjp/css-color@7.0.1':
|
||||
resolution: {integrity: sha512-C9duntabagkBZ1LebM7FKmphR4Q1pBclLxVbZETQV0akkFjV0ooFxo8FlvAQyKj9F6l8rEnmidgcTlpyxFYizg==}
|
||||
'@asamuzakjp/css-color@7.1.3':
|
||||
resolution: {integrity: sha512-1t1U8Cm3RBl9vh3RAZhjkPaduzLSvQJRToRrxpGR/eDRW99mOREleuu+mWfiFa795bUzjwht7NgphCKTs2ianQ==}
|
||||
engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0}
|
||||
|
||||
'@asamuzakjp/[email protected].1':
|
||||
resolution: {integrity: sha512-NT4s3yZLjovPpliRpTvdsdzyPjqRqiCZj9MxnarBihbaY5MbAG7DWAJcrLlhmC7xKTNCXsTELcqB8YsqpLnUFQ==}
|
||||
'@asamuzakjp/[email protected].4':
|
||||
resolution: {integrity: sha512-YnVzxDxLaqL0t7Q4wfcgHZjg55Wm6y5EaO7Bhu3q9J7wq/8iw3PhsnWiOQhYekx4VexnbkgWpTT6n+QIV7kczw==}
|
||||
engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0}
|
||||
|
||||
'@babel/[email protected]':
|
||||
@@ -159,8 +159,8 @@ packages:
|
||||
resolution: {integrity: sha512-HYx0YmuchRxRSpnjvcNffnPS8DCauKNmnumIuHG3jnQAVewRA7kfJGwF13ayEpSZs7s4R/oBH4eppuXcAQd5JQ==}
|
||||
engines: {node: ^22.18.0 || >=24.11.0}
|
||||
|
||||
'@babel/[email protected].6':
|
||||
resolution: {integrity: sha512-LpGDIYJAzc3Y3PT9x+FxBrGYu2PlWxLmyN7SwPTzaX0LSwEmFJBsRnPBqmaXF2r1v+Zhz6lNiEJ+7yKtM5+Ugg==}
|
||||
'@babel/[email protected].7':
|
||||
resolution: {integrity: sha512-UkDwEbPO1px2JQSrdh4qIgaH1JgG7GO/pFeJkpxhDxIETkT/QvGpnigXIw75ViHYPtIgKITmVUJqLjiO+JuWjA==}
|
||||
engines: {node: ^22.18.0 || >=24.11.0}
|
||||
hasBin: true
|
||||
|
||||
@@ -218,8 +218,8 @@ packages:
|
||||
'@codemirror/[email protected]':
|
||||
resolution: {integrity: sha512-AZ8DJBuXGVHybpBQhmZtgew5//4hv3tdkXnr3vDmOUMJRuB6vn/uuwtmTOTlqEaQFg3hQSVeA90NmvIQyUV6FQ==}
|
||||
|
||||
'@codemirror/[email protected]2.4':
|
||||
resolution: {integrity: sha512-1q4PaT+o6PbgpkJt4Q8Fv5XJxTy4FUZ4MWETtyiDw3J0Pyr9E2vqcKL+k9wcvjNTIsauxvE7OfmWj3FRPHQ76A==}
|
||||
'@codemirror/[email protected]3.1':
|
||||
resolution: {integrity: sha512-zfvU2r3TyylQLMj/te+5CnQ0rSqZi8B1oTCBbQkagcLW4Xc6imab6qN+pEJhsuNOLaJqiRyBXp9UhWA1DhVvDw==}
|
||||
|
||||
'@codemirror/[email protected]':
|
||||
resolution: {integrity: sha512-28/+iWLYxKxsvGYhSYL7zaCZqLz5+FFFDq9tVsvGv9kv8RY4fFAchJ5WX9M3YrrRlTIsECjsXPqeNgnSmNP2dg==}
|
||||
@@ -230,43 +230,46 @@ packages:
|
||||
'@codemirror/[email protected]':
|
||||
resolution: {integrity: sha512-kAz+AncRtKuIknedxT1bq4XwXv4UowhbkHU1myPrtVb/jZtImWuV5BXzv5vK6i3kYACsdiZiQKFQQ5Mq7elW8w==}
|
||||
|
||||
'@codemirror/[email protected]':
|
||||
resolution: {integrity: sha512-sihaFrUzAsYBQsL9J2t69y8nfMQGwcYmggAZsk+kjPbjYZMyuf2hU8tUNTZ+P+isb6XRr8JE22TZlJxBoVdH1A==}
|
||||
'@codemirror/[email protected]':
|
||||
resolution: {integrity: sha512-JTFPF6xrfjt0mnfGPgzcLiMwYmvIyNaoizp8BYzv8FE8KrT6GBGDZ190rtt+O7Wke3gG/MFHyiY9YGNeimof4w==}
|
||||
|
||||
'@csstools/color-helpers@6.1.1':
|
||||
resolution: {integrity: sha512-gLNsunvwf3mCi5u5o46/Z/JcJMnhbHSaZ69rkgPzNM3J4s8hWwpPUQB6/tt0EDFyCiWzxANlx+2LJwpYj4zS1w==}
|
||||
'@codemirror/view@6.43.14':
|
||||
resolution: {integrity: sha512-hC45YnmJNR1rz42OF57BLoQNFMV12/13drnvibDsR2quNDmldcb4WvYVV81hFQg56i9lcqQMEDqilhX2QDcGQQ==}
|
||||
|
||||
'@csstools/[email protected]':
|
||||
resolution: {integrity: sha512-grhRy3OKmniaAEKXMjua5z/EODX0MSqBGjunw8+j/3HQjOnahs2AGhvEOIYVUWcU6ScApbhLhVrQTX8XqrMrow==}
|
||||
engines: {node: '>=20.19.0'}
|
||||
|
||||
'@csstools/[email protected].0':
|
||||
resolution: {integrity: sha512-XQKj5B7QiZcHiegCOCAzcAOJdhGgWOHbbu62h5e5mkHnn8lWcfiJhllkqWmxu5zWR9jucPHuo1iTB56P033hcg==}
|
||||
'@csstools/[email protected].3':
|
||||
resolution: {integrity: sha512-iex20d8CHVkyvg6B7UKV7uHnI2Bqo9g+EFfT9E0y+GvTvhZ/DwONJ+9aKb1dlqm0ZiGsL5RXjp0fCoJYnkeDjA==}
|
||||
engines: {node: '>=20.19.0'}
|
||||
peerDependencies:
|
||||
'@csstools/css-parser-algorithms': ^4.0.0
|
||||
'@csstools/css-tokenizer': ^4.0.0
|
||||
'@csstools/css-parser-algorithms': ^4.0.2
|
||||
'@csstools/css-tokenizer': ^4.0.2
|
||||
|
||||
'@csstools/[email protected].3':
|
||||
resolution: {integrity: sha512-y4LpL+lmpuyKDiEFq2PnZUVFdAjsoB/qQJod79yLNokXyW7jewi+/WJ69EfItj8A2unWtxXnGjw6LYXgXu5ZjA==}
|
||||
'@csstools/[email protected].6':
|
||||
resolution: {integrity: sha512-iiPQ3iRWwnJkeEn6RIu6SJPr7hYrLz6XZ9s/QZl+2/LI5KQVjpl2fdmDSZKuD4xP6GMmMPgHFFXg6k1Wkz0Trg==}
|
||||
engines: {node: '>=20.19.0'}
|
||||
peerDependencies:
|
||||
'@csstools/css-parser-algorithms': ^4.0.0
|
||||
'@csstools/css-tokenizer': ^4.0.0
|
||||
'@csstools/css-parser-algorithms': ^4.0.2
|
||||
'@csstools/css-tokenizer': ^4.0.2
|
||||
|
||||
'@csstools/[email protected].0':
|
||||
resolution: {integrity: sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==}
|
||||
'@csstools/[email protected].2':
|
||||
resolution: {integrity: sha512-40cSKyMvK+tq4qz6Awrlye2WGuOKt3FwPgtGg6KTfbHOWNw+Rk1rzbAtZnZ6IBhsY491HLRnDXwoyBAijmmILA==}
|
||||
engines: {node: '>=20.19.0'}
|
||||
peerDependencies:
|
||||
'@csstools/css-tokenizer': ^4.0.0
|
||||
'@csstools/css-tokenizer': ^4.0.2
|
||||
|
||||
'@csstools/[email protected]4':
|
||||
resolution: {integrity: sha512-HpbVXyrofRXpHpgkNIjU/3EWR4WJvOkO3emNK/L6X/mTJU7bGUI3AkkpoTNXznQLp0KRjLHELTGeKI5dIkI9JQ==}
|
||||
'@csstools/[email protected]5':
|
||||
resolution: {integrity: sha512-J0u7HkVl2nzSlhsiTOp4AmwcUQ3D+mGEEKfBy/7To5/y7F2OHwyLrXfrhR0SMgr4p5Lo+eaMVSeai24zUcBIxA==}
|
||||
peerDependencies:
|
||||
css-tree: ^3.2.1
|
||||
peerDependenciesMeta:
|
||||
css-tree:
|
||||
optional: true
|
||||
|
||||
'@csstools/[email protected].1':
|
||||
resolution: {integrity: sha512-bPlN9S9O1A0euCpEWE4qnvB5YDuyYVsUTrxSgmAM1Is0j4tICHoVyOVAXfWMP/kS9ZrjvyIXWV2PmomiAXXqOw==}
|
||||
'@csstools/[email protected].2':
|
||||
resolution: {integrity: sha512-OoKoR0f76dCY666JlcbhmVTs2drYj1GUXZTYTcbUgJjh9Nv41aFfZ21bPQTERm5+L5cBDo466NltB2lplS5GBw==}
|
||||
engines: {node: '>=20.19.0'}
|
||||
|
||||
'@esbuild/[email protected]':
|
||||
@@ -434,6 +437,15 @@ packages:
|
||||
'@noble/hashes':
|
||||
optional: true
|
||||
|
||||
'@exodus/[email protected]':
|
||||
resolution: {integrity: sha512-IcpW84uEn3N7ETtNZMlxKhfl6Pec8rUNGOTBtWbK1FKhJxIFAptZyVrvVRVBimAJxJCgc3PxepxkdWWG4DVzfA==}
|
||||
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
|
||||
peerDependencies:
|
||||
'@noble/hashes': ^1.8.0 || ^2.0.0
|
||||
peerDependenciesMeta:
|
||||
'@noble/hashes':
|
||||
optional: true
|
||||
|
||||
'@jridgewell/[email protected]':
|
||||
resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==}
|
||||
engines: {node: '>=6.0.0'}
|
||||
@@ -783,13 +795,13 @@ packages:
|
||||
'@ungap/[email protected]':
|
||||
resolution: {integrity: sha512-1mEZtMKPM09vDmQt5y7YvmN2+DFTP7Tg0EWXdic8/C6VRnpb33e4ghisCIE3WZjsE2N8mf+QV1Zqh7ZFYLWInQ==}
|
||||
|
||||
'@vitejs/[email protected].1':
|
||||
resolution: {integrity: sha512-yxLaQV9gkhS8ezJqCM6+ndU7mDY6gqAg75NQ+0IjwEI8IYOmQCgkRwHKVSfWXW076DsqMo0Dk+0FK1U+M5RgFw==}
|
||||
'@vitejs/[email protected].2':
|
||||
resolution: {integrity: sha512-fhJNGUdscQa4HkI0xFxwonWyhnVReCPDbXI7FH7jmzpnVCrFQI7PRrhWecO263610vOrs9tr2Y4xcf8uAdFxbw==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
peerDependencies:
|
||||
'@rolldown/plugin-babel': ^0.1.7 || ^0.2.0
|
||||
babel-plugin-react-compiler: ^1.0.0
|
||||
oxc-transform-react: ^0.145.0
|
||||
oxc-transform-react: ^0.152.0
|
||||
vite: ^8.0.0
|
||||
peerDependenciesMeta:
|
||||
'@rolldown/plugin-babel':
|
||||
@@ -877,9 +889,9 @@ packages:
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
|
||||
|
||||
data-urls@7.0.0:
|
||||
resolution: {integrity: sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==}
|
||||
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
|
||||
data-urls@8.0.0:
|
||||
resolution: {integrity: sha512-JQUCCK+/CAGbGGx618PlFZMXH81Jtj7t73CHv9dd/zVWvxgGT2zLFRjnKb+Ng46GIwcy+f7f88qd4duqllK+WQ==}
|
||||
engines: {node: ^22.14.0 || >=24.0.0}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
|
||||
@@ -1038,8 +1050,8 @@ packages:
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
|
||||
|
||||
[email protected].1:
|
||||
resolution: {integrity: sha512-FahmoPK5vbPc+jxV1iErMHmAZypCZ942NHF4+qqaWAuvaKKTBZxawnmAtrbGWLU7MtlxfqIP0qw6aSI+aWGtLg==}
|
||||
[email protected].2:
|
||||
resolution: {integrity: sha512-0FFE/jE1rppmVfUrJUgxqXjcwolZYIGtAgj1pTussMhNZxIxi7W/PvfWaMNroGi2B36X1IKHbexpZ9DhkoOPiQ==}
|
||||
engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0}
|
||||
peerDependencies:
|
||||
canvas: ^3.2.3
|
||||
@@ -1051,8 +1063,8 @@ packages:
|
||||
resolution: {integrity: sha512-Eeo8Ys1doU1z+x8AZsPpQu+p/QcZBI5PeOo7QGQdy2x2m0MU/hYagBbGOmXwr5KVbEfVuWv9LpnQWeehogurjg==}
|
||||
hasBin: true
|
||||
|
||||
[email protected]8.10:
|
||||
resolution: {integrity: sha512-/B6p9eY9DX7aHBfpkHdpirDTZ5QH9xTwL9y837PWuzuv41O5jFdqNQwVQEQsimY0/iVNkwBY4+4hMfhQ7d4Dbw==}
|
||||
[email protected]9.0:
|
||||
resolution: {integrity: sha512-v6Tznz3zJ7u3niRCoDTsumM2+HA2XXcCu+WAacCeHD2z3p9A9Ks987o5FzfTGBN0e8A0vjEgIDvLbICrXpdw/Q==}
|
||||
hasBin: true
|
||||
|
||||
[email protected]:
|
||||
@@ -1135,10 +1147,6 @@ packages:
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-0JNhgFoPvP6U6lE/UdVsSq99tn6DhjjpAj5MxG49ewd2mOBVtwWYIT8ClyABhq198aXXODMU6Ox8DrGy/CpTZQ==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==}
|
||||
engines: {node: 20 || >=22}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==}
|
||||
engines: {node: 20 || >=22}
|
||||
@@ -1319,13 +1327,13 @@ packages:
|
||||
resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
[email protected]3.0:
|
||||
resolution: {integrity: sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==}
|
||||
[email protected]4.0:
|
||||
resolution: {integrity: sha512-T9r+MZkTECl2+oUcZ26YgZzTrYidTsIhYYkJix+6iDOymOb1FB2RLLSOxRGnZGv5DStt+aduIVbt+E8WygXYpg==}
|
||||
engines: {node: '>=20'}
|
||||
hasBin: true
|
||||
|
||||
[email protected]3.0:
|
||||
resolution: {integrity: sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==}
|
||||
[email protected]4.0:
|
||||
resolution: {integrity: sha512-kVzTnYFYEyQ9RGzBjNCd2+yEqWmVbxl3AHPynv3qQ6JTjv4a+kwRcnqQwIpuP3NnrnIrE/Q6wWAIAvMfIE2/Yg==}
|
||||
engines: {node: '>=20'}
|
||||
hasBin: true
|
||||
|
||||
@@ -1457,6 +1465,10 @@ packages:
|
||||
resolution: {integrity: sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==}
|
||||
engines: {node: '>=20'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-BcDSDW+SIGWVOns1Zm6Yg91q9AEj7eaID0QDY9oIqJD1A8dscNwMcl9Sms2DeuWCNov1X0/oXanHGYQBSLwZsg==}
|
||||
engines: {node: ^22.14.0 || >=24.0.0}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==}
|
||||
|
||||
@@ -1468,8 +1480,8 @@ packages:
|
||||
engines: {node: '>=16.20.0'}
|
||||
hasBin: true
|
||||
|
||||
[email protected]0.2:
|
||||
resolution: {integrity: sha512-/y4/bH9YNU5hi9NIrpOuvGXFcxrj3CMrV+/AYpowAYTpHn8gX/XPFjNy766FPoYY0miQhdW977JFWKGNhBdwyQ==}
|
||||
[email protected]1.2:
|
||||
resolution: {integrity: sha512-u4UB2/IrKdU6lFxumHmmo1a3fCQO5tzQllRorfoRS63txhrB7xTpSn1PftwC4qEHkOaqP95fCWW4lJzwErwzhQ==}
|
||||
engines: {node: '>=22.19.0'}
|
||||
|
||||
[email protected]:
|
||||
@@ -1505,8 +1517,8 @@ packages:
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==}
|
||||
|
||||
[email protected].2:
|
||||
resolution: {integrity: sha512-SQr1x6W5vVSbROg7vsyXIaxK9b0G7zsT68acdWWRmnBUsgDieLCRG+Rep9WdZgcposvv/GSnr4GUUBqB3vXq6w==}
|
||||
[email protected].3:
|
||||
resolution: {integrity: sha512-cTAldKPImjg6c+gk48U19POPn3GCBzZwpdsN8ZMEEcbpes+6/wvfqUd0C2y3qYY4wsj8PwgFwrZ/1jBPVttMSg==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
hasBin: true
|
||||
peerDependencies:
|
||||
@@ -1607,14 +1619,14 @@ packages:
|
||||
resolution: {integrity: sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==}
|
||||
engines: {node: '>=20'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==}
|
||||
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-TEZA+Zqxin7Jjsm2cjRohCmen5awh+hT6Zi3VZdqZlNRk7zvOI/9WpBFg/DWlA56bWnzwm6DuB8NS0EsxQH9uQ==}
|
||||
engines: {node: ^22.14.0 || >=24.0.0}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-IhLIHNcBrzIixC5XXsbr4DxdjajyA5vQ5RwGZMtrUp+GbtE5Nrsge9QKau7jXrC0aTXLYGV0xiW7Tb4YS2aomw==}
|
||||
engines: {node: ^22.14.0 || >=24.0.0}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-Tb2FUhB4vUsGQlfSquQLYkApkuPAFQXGFzxWKHHumVz2dK+X1RUm/HnID4+TfIGYJ1kTcwOaCk/buYCEJr6YjQ==}
|
||||
engines: {node: '>=20.11'}
|
||||
@@ -1632,15 +1644,15 @@ packages:
|
||||
|
||||
snapshots:
|
||||
|
||||
'@asamuzakjp/css-color@7.0.1':
|
||||
'@asamuzakjp/css-color@7.1.3':
|
||||
dependencies:
|
||||
'@csstools/css-calc': 3.4.0(@csstools/[email protected].0(@csstools/[email protected].1))(@csstools/[email protected].1)
|
||||
'@csstools/css-color-parser': 4.2.3(@csstools/[email protected].0(@csstools/[email protected].1))(@csstools/[email protected].1)
|
||||
'@csstools/css-parser-algorithms': 4.0.0(@csstools/[email protected].1)
|
||||
'@csstools/css-tokenizer': 4.0.1
|
||||
'@csstools/css-calc': 3.4.3(@csstools/[email protected].2(@csstools/[email protected].2))(@csstools/[email protected].2)
|
||||
'@csstools/css-color-parser': 4.2.6(@csstools/[email protected].2(@csstools/[email protected].2))(@csstools/[email protected].2)
|
||||
'@csstools/css-parser-algorithms': 4.0.2(@csstools/[email protected].2)
|
||||
'@csstools/css-tokenizer': 4.0.2
|
||||
lru-cache: 11.5.3
|
||||
|
||||
'@asamuzakjp/[email protected].1':
|
||||
'@asamuzakjp/[email protected].4':
|
||||
dependencies:
|
||||
bidi-js: 1.1.0
|
||||
css-tree: 3.2.1
|
||||
@@ -1659,7 +1671,7 @@ snapshots:
|
||||
|
||||
'@babel/[email protected]': {}
|
||||
|
||||
'@babel/[email protected].6':
|
||||
'@babel/[email protected].7':
|
||||
dependencies:
|
||||
'@babel/types': 8.0.6
|
||||
|
||||
@@ -1676,22 +1688,22 @@ snapshots:
|
||||
|
||||
'@codemirror/[email protected]':
|
||||
dependencies:
|
||||
'@codemirror/language': 6.12.4
|
||||
'@codemirror/language': 6.13.1
|
||||
'@codemirror/state': 6.7.6
|
||||
'@codemirror/view': 6.43.13
|
||||
'@codemirror/view': 6.43.14
|
||||
'@lezer/common': 1.5.2
|
||||
|
||||
'@codemirror/[email protected]':
|
||||
dependencies:
|
||||
'@codemirror/language': 6.12.4
|
||||
'@codemirror/language': 6.13.1
|
||||
'@codemirror/state': 6.7.6
|
||||
'@codemirror/view': 6.43.13
|
||||
'@codemirror/view': 6.43.14
|
||||
'@lezer/common': 1.5.2
|
||||
|
||||
'@codemirror/[email protected]':
|
||||
dependencies:
|
||||
'@codemirror/autocomplete': 6.20.3
|
||||
'@codemirror/language': 6.12.4
|
||||
'@codemirror/language': 6.13.1
|
||||
'@codemirror/state': 6.7.6
|
||||
'@lezer/common': 1.5.2
|
||||
'@lezer/css': 1.3.8
|
||||
@@ -1699,7 +1711,7 @@ snapshots:
|
||||
'@codemirror/[email protected]':
|
||||
dependencies:
|
||||
'@codemirror/autocomplete': 6.20.3
|
||||
'@codemirror/language': 6.12.4
|
||||
'@codemirror/language': 6.13.1
|
||||
'@codemirror/state': 6.7.6
|
||||
'@lezer/common': 1.5.2
|
||||
'@lezer/go': 1.0.1
|
||||
@@ -1709,60 +1721,60 @@ snapshots:
|
||||
'@codemirror/autocomplete': 6.20.3
|
||||
'@codemirror/lang-css': 6.3.1
|
||||
'@codemirror/lang-javascript': 6.2.5
|
||||
'@codemirror/language': 6.12.4
|
||||
'@codemirror/language': 6.13.1
|
||||
'@codemirror/state': 6.7.6
|
||||
'@codemirror/view': 6.43.13
|
||||
'@codemirror/view': 6.43.14
|
||||
'@lezer/common': 1.5.2
|
||||
'@lezer/css': 1.3.8
|
||||
'@lezer/html': 1.3.13
|
||||
|
||||
'@codemirror/[email protected]':
|
||||
dependencies:
|
||||
'@codemirror/language': 6.12.4
|
||||
'@codemirror/language': 6.13.1
|
||||
'@lezer/java': 1.1.4
|
||||
|
||||
'@codemirror/[email protected]':
|
||||
dependencies:
|
||||
'@codemirror/autocomplete': 6.20.3
|
||||
'@codemirror/language': 6.12.4
|
||||
'@codemirror/language': 6.13.1
|
||||
'@codemirror/lint': 6.9.7
|
||||
'@codemirror/state': 6.7.6
|
||||
'@codemirror/view': 6.43.13
|
||||
'@codemirror/view': 6.43.14
|
||||
'@lezer/common': 1.5.2
|
||||
'@lezer/javascript': 1.5.5
|
||||
|
||||
'@codemirror/[email protected]':
|
||||
dependencies:
|
||||
'@codemirror/language': 6.12.4
|
||||
'@codemirror/language': 6.13.1
|
||||
'@lezer/json': 1.0.3
|
||||
|
||||
'@codemirror/[email protected]':
|
||||
dependencies:
|
||||
'@codemirror/autocomplete': 6.20.3
|
||||
'@codemirror/lang-html': 6.4.12
|
||||
'@codemirror/language': 6.12.4
|
||||
'@codemirror/language': 6.13.1
|
||||
'@codemirror/state': 6.7.6
|
||||
'@codemirror/view': 6.43.13
|
||||
'@codemirror/view': 6.43.14
|
||||
'@lezer/common': 1.5.2
|
||||
'@lezer/markdown': 1.7.2
|
||||
|
||||
'@codemirror/[email protected]':
|
||||
dependencies:
|
||||
'@codemirror/autocomplete': 6.20.3
|
||||
'@codemirror/language': 6.12.4
|
||||
'@codemirror/language': 6.13.1
|
||||
'@codemirror/state': 6.7.6
|
||||
'@lezer/common': 1.5.2
|
||||
'@lezer/python': 1.1.19
|
||||
|
||||
'@codemirror/[email protected]':
|
||||
dependencies:
|
||||
'@codemirror/language': 6.12.4
|
||||
'@codemirror/language': 6.13.1
|
||||
'@lezer/rust': 1.0.3
|
||||
|
||||
'@codemirror/[email protected]':
|
||||
dependencies:
|
||||
'@codemirror/autocomplete': 6.20.3
|
||||
'@codemirror/language': 6.12.4
|
||||
'@codemirror/language': 6.13.1
|
||||
'@codemirror/state': 6.7.6
|
||||
'@lezer/common': 1.5.2
|
||||
'@lezer/highlight': 1.2.5
|
||||
@@ -1771,26 +1783,27 @@ snapshots:
|
||||
'@codemirror/[email protected]':
|
||||
dependencies:
|
||||
'@codemirror/autocomplete': 6.20.3
|
||||
'@codemirror/language': 6.12.4
|
||||
'@codemirror/language': 6.13.1
|
||||
'@codemirror/state': 6.7.6
|
||||
'@codemirror/view': 6.43.13
|
||||
'@codemirror/view': 6.43.14
|
||||
'@lezer/common': 1.5.2
|
||||
'@lezer/xml': 1.0.6
|
||||
|
||||
'@codemirror/[email protected]':
|
||||
dependencies:
|
||||
'@codemirror/autocomplete': 6.20.3
|
||||
'@codemirror/language': 6.12.4
|
||||
'@codemirror/language': 6.13.1
|
||||
'@codemirror/state': 6.7.6
|
||||
'@lezer/common': 1.5.2
|
||||
'@lezer/highlight': 1.2.5
|
||||
'@lezer/lr': 1.4.10
|
||||
'@lezer/yaml': 1.0.4
|
||||
|
||||
'@codemirror/[email protected]2.4':
|
||||
'@codemirror/[email protected]3.1':
|
||||
dependencies:
|
||||
'@codemirror/state': 6.7.6
|
||||
'@codemirror/view': 6.43.13
|
||||
'@codemirror/streamparser': 6.0.0
|
||||
'@codemirror/view': 6.43.14
|
||||
'@lezer/common': 1.5.2
|
||||
'@lezer/highlight': 1.2.5
|
||||
'@lezer/lr': 1.4.10
|
||||
@@ -1799,49 +1812,51 @@ snapshots:
|
||||
'@codemirror/[email protected]':
|
||||
dependencies:
|
||||
'@codemirror/state': 6.7.6
|
||||
'@codemirror/view': 6.43.13
|
||||
'@codemirror/view': 6.43.14
|
||||
crelt: 1.0.7
|
||||
|
||||
'@codemirror/[email protected]':
|
||||
dependencies:
|
||||
'@codemirror/state': 6.7.6
|
||||
'@codemirror/view': 6.43.13
|
||||
'@codemirror/view': 6.43.14
|
||||
crelt: 1.0.7
|
||||
|
||||
'@codemirror/[email protected]':
|
||||
dependencies:
|
||||
'@marijn/find-cluster-break': 1.0.4
|
||||
|
||||
'@codemirror/[email protected]':
|
||||
'@codemirror/[email protected]': {}
|
||||
|
||||
'@codemirror/[email protected]':
|
||||
dependencies:
|
||||
'@codemirror/state': 6.7.6
|
||||
crelt: 1.0.7
|
||||
style-mod: 4.1.4
|
||||
w3c-keyname: 2.2.8
|
||||
|
||||
'@csstools/[email protected].1': {}
|
||||
'@csstools/[email protected].2': {}
|
||||
|
||||
'@csstools/[email protected].0(@csstools/[email protected].0(@csstools/[email protected].1))(@csstools/[email protected].1)':
|
||||
'@csstools/[email protected].3(@csstools/[email protected].2(@csstools/[email protected].2))(@csstools/[email protected].2)':
|
||||
dependencies:
|
||||
'@csstools/css-parser-algorithms': 4.0.0(@csstools/[email protected].1)
|
||||
'@csstools/css-tokenizer': 4.0.1
|
||||
'@csstools/css-parser-algorithms': 4.0.2(@csstools/[email protected].2)
|
||||
'@csstools/css-tokenizer': 4.0.2
|
||||
|
||||
'@csstools/[email protected].3(@csstools/[email protected].0(@csstools/[email protected].1))(@csstools/[email protected].1)':
|
||||
'@csstools/[email protected].6(@csstools/[email protected].2(@csstools/[email protected].2))(@csstools/[email protected].2)':
|
||||
dependencies:
|
||||
'@csstools/color-helpers': 6.1.1
|
||||
'@csstools/css-calc': 3.4.0(@csstools/[email protected].0(@csstools/[email protected].1))(@csstools/[email protected].1)
|
||||
'@csstools/css-parser-algorithms': 4.0.0(@csstools/[email protected].1)
|
||||
'@csstools/css-tokenizer': 4.0.1
|
||||
'@csstools/color-helpers': 6.1.2
|
||||
'@csstools/css-calc': 3.4.3(@csstools/[email protected].2(@csstools/[email protected].2))(@csstools/[email protected].2)
|
||||
'@csstools/css-parser-algorithms': 4.0.2(@csstools/[email protected].2)
|
||||
'@csstools/css-tokenizer': 4.0.2
|
||||
|
||||
'@csstools/[email protected].0(@csstools/[email protected].1)':
|
||||
'@csstools/[email protected].2(@csstools/[email protected].2)':
|
||||
dependencies:
|
||||
'@csstools/css-tokenizer': 4.0.1
|
||||
'@csstools/css-tokenizer': 4.0.2
|
||||
|
||||
'@csstools/[email protected]4([email protected])':
|
||||
'@csstools/[email protected]5([email protected])':
|
||||
optionalDependencies:
|
||||
css-tree: 3.2.1
|
||||
|
||||
'@csstools/[email protected].1': {}
|
||||
'@csstools/[email protected].2': {}
|
||||
|
||||
'@esbuild/[email protected]':
|
||||
optional: true
|
||||
@@ -1923,6 +1938,8 @@ snapshots:
|
||||
|
||||
'@exodus/[email protected]': {}
|
||||
|
||||
'@exodus/[email protected]': {}
|
||||
|
||||
'@jridgewell/[email protected]': {}
|
||||
|
||||
'@jridgewell/[email protected]': {}
|
||||
@@ -2188,19 +2205,19 @@ snapshots:
|
||||
|
||||
'@ungap/[email protected]': {}
|
||||
|
||||
'@vitejs/[email protected].1([email protected].2([email protected]))':
|
||||
'@vitejs/[email protected].2([email protected].3([email protected]))':
|
||||
dependencies:
|
||||
'@rolldown/pluginutils': 1.0.1
|
||||
vite: 8.3.2([email protected])
|
||||
vite: 8.3.3([email protected])
|
||||
|
||||
'@vitest/[email protected]([email protected].2([email protected]))':
|
||||
'@vitest/[email protected]([email protected].3([email protected]))':
|
||||
dependencies:
|
||||
'@jridgewell/trace-mapping': 0.3.31
|
||||
'@vitest/spy': 5.0.3
|
||||
estree-walker: 3.0.3
|
||||
magic-string: 1.4.2
|
||||
optionalDependencies:
|
||||
vite: 8.3.2([email protected])
|
||||
vite: 8.3.3([email protected])
|
||||
|
||||
'@vitest/[email protected]': {}
|
||||
|
||||
@@ -2236,11 +2253,11 @@ snapshots:
|
||||
dependencies:
|
||||
'@codemirror/autocomplete': 6.20.3
|
||||
'@codemirror/commands': 6.11.1
|
||||
'@codemirror/language': 6.12.4
|
||||
'@codemirror/language': 6.13.1
|
||||
'@codemirror/lint': 6.9.7
|
||||
'@codemirror/search': 6.7.2
|
||||
'@codemirror/state': 6.7.6
|
||||
'@codemirror/view': 6.43.13
|
||||
'@codemirror/view': 6.43.14
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
@@ -2257,10 +2274,10 @@ snapshots:
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
data-urls@7.0.0:
|
||||
data-urls@8.0.0:
|
||||
dependencies:
|
||||
whatwg-mimetype: 5.0.0
|
||||
whatwg-url: 16.0.1
|
||||
whatwg-url: 17.1.2
|
||||
transitivePeerDependencies:
|
||||
- '@noble/hashes'
|
||||
|
||||
@@ -2484,27 +2501,27 @@ snapshots:
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected].1:
|
||||
[email protected].2:
|
||||
dependencies:
|
||||
'@asamuzakjp/css-color': 7.0.1
|
||||
'@asamuzakjp/dom-selector': 9.2.1
|
||||
'@asamuzakjp/css-color': 7.1.3
|
||||
'@asamuzakjp/dom-selector': 9.2.4
|
||||
'@bramus/specificity': 2.4.2
|
||||
'@csstools/css-syntax-patches-for-csstree': 1.1.14([email protected])
|
||||
'@exodus/bytes': 1.15.1
|
||||
'@csstools/css-syntax-patches-for-csstree': 1.1.15([email protected])
|
||||
'@exodus/bytes': 1.16.0
|
||||
css-tree: 3.2.1
|
||||
data-urls: 7.0.0
|
||||
data-urls: 8.0.0
|
||||
decimal.js: 10.6.0
|
||||
html-encoding-sniffer: 7.0.0
|
||||
is-potential-custom-element-name: 1.0.1
|
||||
lru-cache: 11.5.2
|
||||
lru-cache: 11.5.3
|
||||
parse5: 8.0.1
|
||||
saxes: 6.0.0
|
||||
tough-cookie: 6.0.2
|
||||
undici: 8.10.2
|
||||
undici: 8.11.2
|
||||
w3c-xmlserializer: 6.0.0
|
||||
webidl-conversions: 8.0.1
|
||||
whatwg-mimetype: 5.0.0
|
||||
whatwg-url: 17.1.2
|
||||
whatwg-url: 17.2.0
|
||||
xml-name-validator: 5.0.0
|
||||
transitivePeerDependencies:
|
||||
- '@noble/hashes'
|
||||
@@ -2513,7 +2530,7 @@ snapshots:
|
||||
dependencies:
|
||||
commander: 8.3.0
|
||||
|
||||
[email protected]8.10:
|
||||
[email protected]9.0:
|
||||
dependencies:
|
||||
commander: 15.0.0
|
||||
|
||||
@@ -2574,8 +2591,6 @@ snapshots:
|
||||
devlop: 1.1.0
|
||||
highlight.js: 11.11.2
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
@@ -2982,11 +2997,11 @@ snapshots:
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]3.0: {}
|
||||
[email protected]4.0: {}
|
||||
|
||||
[email protected]3.0:
|
||||
[email protected]4.0:
|
||||
dependencies:
|
||||
playwright-core: 1.63.0
|
||||
playwright-core: 1.64.0
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
@@ -3182,6 +3197,10 @@ snapshots:
|
||||
dependencies:
|
||||
punycode: 2.3.1
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
punycode: 2.3.1
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
@@ -3209,7 +3228,7 @@ snapshots:
|
||||
'@typescript/typescript-win32-arm64': 7.0.2
|
||||
'@typescript/typescript-win32-x64': 7.0.2
|
||||
|
||||
[email protected]0.2: {}
|
||||
[email protected]1.2: {}
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
@@ -3269,7 +3288,7 @@ snapshots:
|
||||
'@types/unist': 3.0.3
|
||||
vfile-message: 4.0.3
|
||||
|
||||
[email protected].2([email protected]):
|
||||
[email protected].3([email protected]):
|
||||
dependencies:
|
||||
lightningcss: 1.33.0
|
||||
picomatch: 4.0.7
|
||||
@@ -3280,10 +3299,10 @@ snapshots:
|
||||
esbuild: 0.28.2
|
||||
fsevents: 2.3.3
|
||||
|
||||
[email protected]([email protected].1)([email protected].2([email protected])):
|
||||
[email protected]([email protected].2)([email protected].3([email protected])):
|
||||
dependencies:
|
||||
'@types/chai': 5.2.3
|
||||
'@vitest/mocker': 5.0.3([email protected].2([email protected]))
|
||||
'@vitest/mocker': 5.0.3([email protected].3([email protected]))
|
||||
chai: 6.2.2
|
||||
es-module-lexer: 2.3.2
|
||||
expect-type: 1.4.0
|
||||
@@ -3294,10 +3313,10 @@ snapshots:
|
||||
tinybench: 6.1.4
|
||||
tinyexec: 1.3.0
|
||||
tinyglobby: 0.2.17
|
||||
vite: 8.3.2([email protected])
|
||||
vite: 8.3.3([email protected])
|
||||
why-is-node-running: 3.2.1
|
||||
optionalDependencies:
|
||||
jsdom: 30.1.1
|
||||
jsdom: 30.1.2
|
||||
transitivePeerDependencies:
|
||||
- msw
|
||||
|
||||
@@ -3313,7 +3332,7 @@ snapshots:
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
whatwg-url@16.0.1:
|
||||
whatwg-url@17.1.2:
|
||||
dependencies:
|
||||
'@exodus/bytes': 1.15.1
|
||||
tr46: 6.0.0
|
||||
@@ -3321,10 +3340,10 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- '@noble/hashes'
|
||||
|
||||
whatwg-url@17.1.2:
|
||||
whatwg-url@17.2.0:
|
||||
dependencies:
|
||||
'@exodus/bytes': 1.15.1
|
||||
tr46: 6.0.0
|
||||
'@exodus/bytes': 1.16.0
|
||||
tr46: 7.0.0
|
||||
webidl-conversions: 8.0.1
|
||||
transitivePeerDependencies:
|
||||
- '@noble/hashes'
|
||||
|
||||
@@ -145,8 +145,8 @@ describe("what counts as an input a person performs", () => {
|
||||
expect(c.roots.map((r) => r.kind)).toEqual(["dom-event"]);
|
||||
});
|
||||
|
||||
it("leaves the environment's own events out", () => {
|
||||
const c = only(`export function A() { window.addEventListener("resize", () => {}); return null; }`);
|
||||
it.each(["resize", "pageshow"])("leaves the environment's %s event out", (event) => {
|
||||
const c = only(`export function A() { window.addEventListener("${event}", () => {}); return null; }`);
|
||||
expect(c.roots).toEqual([]);
|
||||
expect(c.nonUser.map((n) => n.why)).toEqual(["RUNTIME_EVENT"]);
|
||||
});
|
||||
|
||||
@@ -56,11 +56,13 @@ export const ACTIONS: UIAction[] = [
|
||||
{ id: "chart.data", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "chrome.policy", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "chrome.rail", kind: "view", target: "none", proof: "browser" },
|
||||
{ id: "chrome.more", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "appearance.theme", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "appearance.nav-rail", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "appearance.graphics", 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.more", kind: "repeatable", target: "none", proof: "interaction" },
|
||||
{ id: "chrome.settings", kind: "navigation", target: "none", proof: "interaction" },
|
||||
{ id: "browser.open", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "browser.back", kind: "navigation", target: "none", proof: "interaction" },
|
||||
@@ -70,7 +72,11 @@ export const ACTIONS: UIAction[] = [
|
||||
{ id: "tool.copy-output", kind: "shell-native", target: "none", proof: "interaction" },
|
||||
// The way out of a window whose assets an update replaced underneath it.
|
||||
{ id: "chrome.reload", kind: "navigation", target: "none", proof: "interaction" },
|
||||
{ id: "chrome.changed-files", kind: "kernel-mutation", target: "none", proof: "interaction" },
|
||||
{ id: "chrome.fold-work", kind: "kernel-mutation", target: "none", proof: "interaction" },
|
||||
{ id: "changes.open", kind: "navigation", target: "none", proof: "interaction" },
|
||||
{ id: "chrome.receipt", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "chrome.receipt-line", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "chrome.keep-awake", 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" },
|
||||
@@ -244,6 +250,10 @@ export const ACTIONS: UIAction[] = [
|
||||
{ id: "extensions.finish", kind: "navigation", target: "none", proof: "interaction" },
|
||||
{ id: "extensions.add", kind: "navigation", target: "none", proof: "interaction" },
|
||||
{ id: "extensions.pick-folder", kind: "shell-native", target: "none", proof: "interaction" },
|
||||
{ id: "folders.pick", kind: "shell-native", target: "none", proof: "interaction" },
|
||||
{ id: "folders.add", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
|
||||
{ id: "folders.remove", kind: "kernel-mutation", target: "entity", proof: "authority-effect" },
|
||||
{ id: "folders.retry", kind: "repeatable", target: "none", proof: "interaction" },
|
||||
{ id: "extensions.export", kind: "repeatable", target: "none", proof: "interaction" },
|
||||
{ id: "extensions.enabled", kind: "kernel-mutation", target: "entity", proof: "authority-effect" },
|
||||
{ id: "extensions.remove", kind: "destructive", target: "entity", proof: "authority-effect" },
|
||||
@@ -357,6 +367,11 @@ export const ACTIONS: UIAction[] = [
|
||||
{ id: "provider.model-check-all", kind: "repeatable", target: "none", proof: "interaction" },
|
||||
{ id: "provider.model-check", kind: "repeatable", target: "entity", proof: "interaction" },
|
||||
{ id: "provider.add", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
|
||||
{ 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-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" },
|
||||
{ id: "provider.select", kind: "view", target: "entity", proof: "interaction" },
|
||||
{ id: "provider.move", kind: "view", target: "entity", proof: "interaction" },
|
||||
@@ -415,6 +430,7 @@ export const ACTIONS: UIAction[] = [
|
||||
{ id: "notify.approval", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
|
||||
{ id: "notify.ask", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
|
||||
{ id: "notify.feedback-reply", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
|
||||
{ id: "notify.sound", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
|
||||
{ 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" },
|
||||
@@ -460,6 +476,9 @@ export const ACTIONS: UIAction[] = [
|
||||
{ id: "workspace.isolate", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
|
||||
{ id: "reasoning.effort", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
|
||||
{ id: "workbench.new-browser", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "workbench.launch-files", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "workbench.launch-changes", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "workbench.launch-browser", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "workbench.tab", kind: "view", target: "entity", proof: "interaction" },
|
||||
{ id: "workbench.close", kind: "view", target: "entity", proof: "interaction" },
|
||||
{ id: "workbench.mode", kind: "view", target: "none", proof: "interaction" },
|
||||
@@ -512,6 +531,8 @@ export const ACTIONS: UIAction[] = [
|
||||
// The kernel announces the change on the stream, so no rebuild stands between
|
||||
// the click and the canonical change.
|
||||
{ id: "currency.mode", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
|
||||
{ id: "ask-timeout.enabled", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
|
||||
{ id: "ask-timeout.seconds", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
|
||||
{ id: "progress-watch.pause", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
|
||||
{ id: "progress-watch.rounds", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
|
||||
{ id: "progress-watch.token-multiple", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
|
||||
|
||||
@@ -523,7 +523,8 @@
|
||||
},
|
||||
{
|
||||
"kind": "turn_done",
|
||||
"checkpointTurn": 0
|
||||
"checkpointTurn": 0,
|
||||
"workDurationMs": 1
|
||||
}
|
||||
],
|
||||
"replay": [
|
||||
@@ -631,7 +632,8 @@
|
||||
},
|
||||
{
|
||||
"kind": "turn_done",
|
||||
"checkpointTurn": 0
|
||||
"checkpointTurn": 0,
|
||||
"workDurationMs": 1
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -693,7 +695,8 @@
|
||||
},
|
||||
{
|
||||
"kind": "turn_done",
|
||||
"checkpointTurn": 0
|
||||
"checkpointTurn": 0,
|
||||
"workDurationMs": 1
|
||||
}
|
||||
],
|
||||
"replay": [
|
||||
@@ -742,7 +745,8 @@
|
||||
},
|
||||
{
|
||||
"kind": "turn_done",
|
||||
"checkpointTurn": 0
|
||||
"checkpointTurn": 0,
|
||||
"workDurationMs": 1
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -896,7 +900,8 @@
|
||||
},
|
||||
{
|
||||
"kind": "turn_done",
|
||||
"checkpointTurn": 0
|
||||
"checkpointTurn": 0,
|
||||
"workDurationMs": 1
|
||||
}
|
||||
],
|
||||
"replay": [
|
||||
@@ -1025,7 +1030,8 @@
|
||||
},
|
||||
{
|
||||
"kind": "turn_done",
|
||||
"checkpointTurn": 0
|
||||
"checkpointTurn": 0,
|
||||
"workDurationMs": 1
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -94,6 +94,7 @@ export const EN: Record<string, string> = {
|
||||
"复制这段代码": "Copy this code",
|
||||
"复制路径": "Copy path",
|
||||
"思考 {chars}": "Thought · {chars}",
|
||||
"耗时 {t}": "Worked for {t}",
|
||||
"思考 {secs} 秒 · {chars}": "Thought for {secs}s · {chars}",
|
||||
"{n} 字": "{n} chars",
|
||||
"{n} 张图片": "{n} images",
|
||||
@@ -293,7 +294,12 @@ export const EN: Record<string, string> = {
|
||||
"新建会话": "Start a new session",
|
||||
"空会话": "Empty session",
|
||||
"{n} 轮": "{n} turns",
|
||||
"还有 {n} 个 · 全部显示": "{n} more · show all",
|
||||
"{n} / {total} 会话": "{n} / {total} sessions",
|
||||
"显示更多(还有 {n} 个)": "Show more ({n} left)",
|
||||
"还有 {n} 个较早的会话 · 用搜索查找": "{n} older conversations · use search to find them",
|
||||
"还有 {n} 个较早的会话,在那台机器上查看": "{n} older conversations · see them on that machine",
|
||||
"正在加载…": "Loading…",
|
||||
"加载失败 · 重试": "Failed to load · retry",
|
||||
"打开或新建项目…": "Open or start a project…",
|
||||
"文件夹的完整路径": "Full path to the folder",
|
||||
"尚无文件夹": "No folders yet",
|
||||
@@ -522,7 +528,9 @@ export const EN: Record<string, string> = {
|
||||
"凭据未通过,尚未确认": "Credentials were rejected; availability is still unknown",
|
||||
"遇到限流,尚未确认": "Rate limited; availability is still unknown",
|
||||
"网络异常,尚未确认": "Network error; availability is still unknown",
|
||||
"地址跳转到了另一个主机,已拒绝,尚未确认": "The address redirected to another host and was refused; availability is still unknown",
|
||||
"验证超时,尚未确认": "Verification timed out; availability is still unknown",
|
||||
"本机服务没有在时限内回答,尚未确认;模型若刚启动可能还在加载,稍后再验证一次": "The local service did not answer in time, so this is unconfirmed. A model that was just started may still be loading; verify again shortly.",
|
||||
"请求被拒绝,尚未确认": "Request rejected; availability is still unknown",
|
||||
"尚未确认": "Availability is still unknown",
|
||||
"读图": "Reads images",
|
||||
@@ -537,6 +545,11 @@ export const EN: Record<string, string> = {
|
||||
"记的是 {had},但它答的是 {got}。": "Recorded as {had}, but it answers as {got}.",
|
||||
// 收工回执
|
||||
"没有未经验证的部分": "nothing left unverified",
|
||||
"等待超时,这个问题已被跳过": "Nobody answered in time; this question was skipped",
|
||||
"有未验证的改动": "Unverified changes",
|
||||
"这一轮自己说明了风险或未验证项": "This turn declared risks or unverified items",
|
||||
"收起交付验收": "Collapse the delivery receipt",
|
||||
"点击查看交付验收:改了什么、哪些没有验证": "Click to see the delivery receipt: what changed and what was not verified",
|
||||
"未验证": "not verified",
|
||||
"交付验收": "delivery receipt",
|
||||
"留空时按这个顺序尝试:先 ssh-agent 持有的密钥,再 ~/.ssh 下的默认密钥;只有填了下面的环境变量或该机器要求交互时,才会走密码。": "Left blank, it tries in this order: keys held by ssh-agent, then the default keys under ~/.ssh. A password is only used when one of the variables below is set, or the machine asks interactively.",
|
||||
@@ -550,7 +563,9 @@ export const EN: Record<string, string> = {
|
||||
"通知": "Notifications",
|
||||
"只在你没盯着窗口的时候有用:一轮跑完、停下来等批准、模型反过来问你、反馈有了回复,都可以让系统提醒一次。": "Only useful while you are not watching: a turn finishing, a stop for approval, the model asking you something, or a reply to your feedback can each raise one system alert.",
|
||||
"发送系统通知": "Send system notifications",
|
||||
"窗口在后台时由系统弹出提醒。立即生效,正在跑的会话下一次事件就按新设置来": "The system raises the alert while this window is in the background. Immediate: a running session follows the new setting from its next event.",
|
||||
"默认关闭。开启后,无论窗口是否在前台都会由系统弹出提醒;声音由系统决定。立即生效,正在跑的会话下一次事件就按新设置来": "Off by default. When on, the system raises the alert whether or not this window is in front; the sound is whatever the system decides. Immediate: a running session follows the new setting from its next event.",
|
||||
"播放提示音": "Play a sound",
|
||||
"默认关闭。下面选中的情形出现时放一声短提示,不依赖系统通知,也不受系统通知静音的影响。窗口缩小、在后台时也会响": "Off by default. Plays a short chime for the occasions selected below, independent of system notifications and of their muting. It also sounds when the window is minimised or in the background.",
|
||||
"这一轮结束时": "When the turn ends",
|
||||
"回答写完、或者这一轮失败了": "The answer is written, or the turn failed",
|
||||
"有操作等待批准时": "When something waits for approval",
|
||||
@@ -999,8 +1014,10 @@ export const EN: Record<string, string> = {
|
||||
"上一处": "Previous match",
|
||||
"下一处": "Next match",
|
||||
"关闭查找": "Close find",
|
||||
"回合结束时给出回执": "End a turn with a receipt",
|
||||
"列出这一轮改了什么、验了什么、哪些没有验;无话可说时不出现。下一轮起生效": "Lists what this turn changed, what it verified and what went unverified; absent when there is nothing to say. Takes effect from the next turn",
|
||||
"交付验收卡片": "Delivery receipt card",
|
||||
"每一轮结束后,主机会在会话末尾列出这一轮改了什么、验证了什么、哪些没有验证。记录始终保存,这里只决定是否显示。": "At the end of each turn the host lists what the turn changed, what it verified and what it did not. The record is always kept; this only decides whether it is shown.",
|
||||
"显示交付验收卡片": "Show the delivery receipt card",
|
||||
"默认不显示;打开后,当前会话和已有会话里的卡片都会出现。终端界面读同一项设置,下次启动时生效": "Off by default. Turning it on shows the cards in the current session and in earlier ones. The terminal reads the same setting and picks it up the next time it starts",
|
||||
"任务运行时阻止系统休眠": "Keep the computer awake while tasks run",
|
||||
"有会话正在运行时,电脑不会自动进入睡眠(电池供电时也一样);屏幕仍按系统设置熄灭,全部空闲后恢复。合上笔记本盖子不受此项控制": "While a session is running the computer will not go to sleep on its own, on battery power too; the screen still turns off as the system says, and normal sleep resumes once everything is idle. Closing a laptop lid is not controlled by this",
|
||||
"改写这条消息": "Rewrite this message",
|
||||
@@ -1036,6 +1053,9 @@ export const EN: Record<string, string> = {
|
||||
"确定": "OK",
|
||||
"重试": "Retry",
|
||||
"设置": "Settings",
|
||||
"还没有输出,命令仍在运行": "No output yet; the command is still running",
|
||||
"已运行 {time}": "running for {time}",
|
||||
"这条命令自带的超时上限 {n} 秒": "this command's own timeout is {n}s",
|
||||
"专注": "Focus",
|
||||
"退出专注": "Leave focus",
|
||||
"专注:收起两侧栏": "Focus: put the side columns away",
|
||||
@@ -1215,6 +1235,22 @@ export const EN: Record<string, string> = {
|
||||
"分离状态": "detached",
|
||||
"当前 Git 分支:{branch}": "Current Git branch: {branch}",
|
||||
"{n} 个变更": "{n} changes",
|
||||
"1 个文件已更改": "1 file changed",
|
||||
"{n} 个文件已更改": "{n} files changed",
|
||||
"{label},新增 {up} 行,删除 {down} 行,点击查看文件改动": "{label}, {up} lines added, {down} removed. Open file changes",
|
||||
"{label},点击查看文件改动": "{label}. Open file changes",
|
||||
"查看文件改动": "View file changes",
|
||||
"文件改动": "File changes",
|
||||
"在 Git 工作区里,输入框上方显示当前有多少文件已更改,点击查看。关闭后,数量回到分支标签旁。": "In a Git workspace, show how many files have changed above the input box; click to view them. When off, the count stays beside the branch chip.",
|
||||
"在输入框上方显示文件改动": "Show file changes above the input box",
|
||||
"折叠中间过程": "Fold the work in between",
|
||||
"一轮结束后,把工具调用、思考和中间的文字收成一行,只留最后一句话;点那一行可以展开。运行中的一轮始终展开。": "When a turn ends, tool calls, thinking and the sentences in between are gathered into one line and only the last sentence stays out; click the line to open it. The turn that is running stays open.",
|
||||
"折叠一轮里的中间过程": "Fold the work inside a turn",
|
||||
"默认关闭;出错的步骤会在那一行上标出来": "Off by default; a step that failed is marked on that line",
|
||||
"中间过程": "Work in between",
|
||||
"思考与中间文字": "Thinking and notes",
|
||||
"文字 {count}": "Notes: {count}",
|
||||
"默认开启;数量来自 Git 工作区,与本轮对话是否改过文件无关": "On by default. The count comes from the Git work tree, whether or not this conversation edited the files",
|
||||
"当前工作区 · {n} 个本地变更": "Current workspace · {n} local changes",
|
||||
"复制回复": "Copy response",
|
||||
"快速": "Fast",
|
||||
@@ -1229,6 +1265,8 @@ export const EN: Record<string, string> = {
|
||||
"正文行宽": "Body line width",
|
||||
"适宜阅读": "Reading",
|
||||
"铺满窗口": "Full width",
|
||||
"连输入框一起铺满": "Full width, composer too",
|
||||
"正文和输入框都跟随窗口宽度;输入框很宽时一行不易回到行首": "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",
|
||||
"行宽只管正文:代码、命令与工具输出始终占满可用宽度。": "Line width applies to prose only — code, commands and tool output always use the width available.",
|
||||
@@ -1395,10 +1433,13 @@ export const EN: Record<string, string> = {
|
||||
"后台任务已结束:{name}": "Background job finished: {name}",
|
||||
"后台任务已终止:{name}": "Background job killed: {name}",
|
||||
"后台任务 {name} 失败,需要处理": "Background job {name} failed: needs attention",
|
||||
"被信号 {signal} 终止": "Ended by signal {signal}",
|
||||
"退出码 {code}": "Exit code {code}",
|
||||
"已恢复 {n} 条未完成的指令。待发送已暂停,请先在输入框上方的队列里查看,再点“继续派发”": "Recovered {n} unfinished instruction(s). Sending is paused: review them in the queue above the input box, then press Release",
|
||||
"待发送已暂停,这条消息已排入队列,点“继续派发”后才会发送": "Sending is paused. This message is queued and goes out once you press Release",
|
||||
"引导没有生效:这一轮在处理它之前就结束了。如果仍然需要,请再发送一次:": "Guidance was not applied because the turn ended before it could be processed. Send it again if it is still needed:",
|
||||
"费用显示币种已设为 {mode}": "Cost display currency set to {mode}",
|
||||
"配置文件里的提问等待时长无效(须为 0 或 10 到 86400 之间的整数),提问现在会一直等你回答;到设置里重新设一个": "The question wait time in the config file is not valid (it must be a whole number, 0 or 10 to 86400), so questions wait for you as long as it takes; set it again in Settings.",
|
||||
"模型卡在重复输出同一段文字,这一轮已停止;可以重试、补充引导,或换一个供应商/模型": "The model is stuck repeating the same text; this turn was stopped. Try again, add guidance, or switch provider/model.",
|
||||
"扩展 {ext} 的配套后台程序没有运行,该扩展本次(在 {point})已被跳过;到「工具与集成」里查看并启动它,或停用该扩展": "The companion background program of extension {ext} is not running, so the extension was skipped this time (at {point}). Open Tools and integrations to check and start it, or disable the extension.",
|
||||
"有 {n} 条权限规则没有对应的工具,匹配不到任何调用,因此起不到限制作用(如「{list}」里的 {rule});到「设置 → 权限」里删除或改写,命令要写成 Bash(命令:*)": "{n} permission rule(s) name no tool, so they match no call and restrict nothing (for example {rule} in {list}). Delete or rewrite them under Settings > Permissions; a shell command is written Bash(command:*).",
|
||||
|
||||
@@ -18,6 +18,7 @@ export const EN_COMPOSER: Record<string, string> = {
|
||||
"正在停止…": "Stopping…",
|
||||
"正在发送…": "Sending…",
|
||||
"描述任务、问题或要改的内容…": "Describe a task, question, or change…",
|
||||
"描述任务…": "Describe a task…",
|
||||
"任务输入": "Task input",
|
||||
"命令与技能": "Commands and skills",
|
||||
"正在加载命令与技能…": "Loading commands and skills…",
|
||||
@@ -61,7 +62,7 @@ export const EN_COMPOSER: Record<string, string> = {
|
||||
"工作区外的文件引用不到 · 这里放不下": "A file outside the workspace cannot be referenced — this drop has nowhere to go",
|
||||
"图片": "image",
|
||||
"图片不可用": "Image unavailable",
|
||||
"文件": "file",
|
||||
"文件": "Files",
|
||||
"粘贴的文本": "pasted text",
|
||||
"{n} 行 · 展开到输入框": "{n} lines · put it back in the box",
|
||||
"添加附件": "Add an attachment",
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
// code adds one here. i18n.test.ts is what stops one from being forgotten.
|
||||
|
||||
export const EN_KERNEL: Record<string, string> = {
|
||||
"请先打开一个本机会话,再读取本机设置": "Open a local session to read this machine’s settings",
|
||||
"该路径指向网络上的另一台机器,不在当前工作区内。文件工具只按路径写法拒绝它,不会去访问;工作区本身在网络共享上时,其下的路径可用": "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",
|
||||
"内核繁忙,这次操作可能没有被收到,重试前请先确认": "The kernel is busy; the action may not have been received - check before retrying",
|
||||
@@ -86,6 +87,14 @@ export const EN_KERNEL: Record<string, string> = {
|
||||
"未能导出该插件:{detail}": "That plugin could not be exported: {detail}",
|
||||
"无法解析本次安装请求:{detail}": "That install request could not be read: {detail}",
|
||||
"安装失败:{detail}": "It could not be installed: {detail}",
|
||||
"无法读取这个来源:{detail}": "That source could not be read: {detail}",
|
||||
"这个来源里没有可安装的内容:{detail}": "There is nothing installable in this source: {detail}",
|
||||
"来源里的清单没有通过校验:{detail}": "The manifest in the source did not validate: {detail}",
|
||||
"来源与所选类型不符:{detail}": "The source does not match the chosen kind: {detail}",
|
||||
"这个插件没有 Reasonix 能运行的能力:{detail}": "This plugin has no capability Reasonix can run: {detail}",
|
||||
"来源要求先登录或授权:{detail}": "The source requires signing in or authorisation first: {detail}",
|
||||
"找不到运行它所需的命令或运行时:{detail}": "The command or runtime it needs was not found: {detail}",
|
||||
"目标已经存在:{detail}": "The target already exists: {detail}",
|
||||
"这不是有效的社区包名称": "That is not a community package name",
|
||||
"社区市场中没有这个已审核的包": "The community market has no approved package by that name",
|
||||
"无法连接社区市场:{detail}": "The community market could not be reached: {detail}",
|
||||
@@ -120,6 +129,18 @@ export const EN_KERNEL: Record<string, string> = {
|
||||
"本机的 sandbox-exec 不可用,命令将不受限制地运行": "sandbox-exec is unusable on this machine, so commands run unconfined",
|
||||
"Windows 上尚无操作系统级沙箱,命令将不受限制地运行": "Windows has no OS-level sandbox here yet, so commands run unconfined",
|
||||
"该平台尚无可用的沙箱后端,命令将不受限制地运行": "This platform has no usable sandbox backend yet, so commands run unconfined",
|
||||
"当前还有任务在运行,结束后才能更改工作区文件夹": "Finish the current work before changing the workspace folders.",
|
||||
"这台服务器没有开启工作区文件夹编辑": "Workspace folder editing is not enabled on this server.",
|
||||
"请填写文件夹路径": "A folder path is required.",
|
||||
"没有可用的当前模型,无法应用文件夹更改": "There is no current model to apply the folder change on.",
|
||||
"需要一个已存在的文件夹的绝对路径": "Use the absolute path of a folder that exists.",
|
||||
"这个文件夹已经在当前工作区里": "This folder is already inside the current workspace.",
|
||||
"这个文件夹范围太大(磁盘根目录,或包含你的主目录或 Reasonix 数据目录)": "This folder is too broad: it is a drive root or contains your home or Reasonix data folder.",
|
||||
"这个文件夹含有或位于密钥、凭据或 Reasonix 自己的数据目录里,不能授予写入权限": "This folder holds or sits inside keys, credentials or Reasonix's own data, so it cannot be granted write access.",
|
||||
"这个文件夹不属于当前用户,不能授予写入权限": "This folder is not owned by the current user, so it cannot be granted write access.",
|
||||
"这个文件夹和已有的可写位置(临时目录、工具缓存或另一个额外文件夹)重叠": "This folder overlaps a location that is already writable (temp, a tool cache or another extra folder).",
|
||||
"没有这个预设": "No curated preset has this id.",
|
||||
"这个预设会创建多个来源,名称由预设决定": "This preset creates several sources and keeps its own names.",
|
||||
"本机没有操作系统沙箱,「关进沙箱」一档无法保存": "This machine has no OS sandbox, so the jailed mode cannot be saved",
|
||||
|
||||
"该文件夹仍有 {n} 个打开的面板,请先关闭再移除": "This folder still has {n} open panes — close them before removing it",
|
||||
@@ -167,6 +188,7 @@ export const EN_KERNEL: Record<string, string> = {
|
||||
"读不到配置文件,没法安全地选择密钥存放位置,请检查配置后重试": "The configuration could not be read, so there is no safe place to store the key; check the config and try again",
|
||||
"请填写接口地址": "Enter an endpoint address",
|
||||
"无法识别「{kind}」这种接入方式": "“{kind}” is not a protocol this knows",
|
||||
"这个来源固定了为当前协议写的请求地址或认证方式,不能直接换协议:请先在配置文件里去掉 request_url、chat_url 和 auth_header": "This source pins a request URL or auth header written for its current protocol, so its protocol cannot be changed here: remove request_url, chat_url and auth_header from the config file first",
|
||||
"请至少选择一个模型": "Pick at least one model",
|
||||
"默认模型「{model}」不在已选择的模型中": "The default model “{model}” is not among the ones you picked",
|
||||
"显示名称最多 {max} 个字符": "A display name can be at most {max} characters",
|
||||
@@ -244,6 +266,8 @@ export const EN_KERNEL: Record<string, string> = {
|
||||
"The provider errored (HTTP {status}) — nothing you typed is wrong; try again shortly",
|
||||
"该地址在限定时间内没有响应。请检查网络或代理,或稍后重试":
|
||||
"That address did not answer in time. Check the network or proxy, or try again shortly",
|
||||
"该地址跳转到了 {target},已拒绝,以免 key 被发到别处。如果信任 {target},请把服务地址改成 {target}":
|
||||
"That address redirected to {target} and was refused so the key is not sent elsewhere. If you trust {target}, change the address to {target}",
|
||||
"检查失败,没有具体原因": "The check failed and gave no reason",
|
||||
"无法连接该地址。请检查网络是否通畅,以及地址是否有误":
|
||||
"Cannot reach that address. Check the network, or whether the address has a typo",
|
||||
@@ -347,6 +371,7 @@ export const EN_KERNEL: Record<string, string> = {
|
||||
"该问题已有其他答案": "That question already has a different answer",
|
||||
"状态图标设置未能保存:{detail}": "The status icon settings were not saved: {detail}",
|
||||
"内置浏览器的消息格式不正确,本次回传被丢弃": "The built-in browser sent malformed messages, and they were dropped",
|
||||
"这个内核没有窗口外壳,没有可以指出的日志目录": "This kernel has no window shell, so there is no log directory to point to",
|
||||
"这个 Studio 不是安装版(从源码启动),没有可以查看或切换的版本":
|
||||
"This Studio is not an installed build (it runs from source), so it has no version to show or change",
|
||||
"版本固定未能保存:{detail}": "The version hold was not saved: {detail}",
|
||||
@@ -375,10 +400,16 @@ export const EN_KERNEL: Record<string, string> = {
|
||||
"无法读取工作区文件列表": "The workspace file list could not be read",
|
||||
"该文件不是可编辑文本或超过大小限制": "The file is not editable text or exceeds the size limit",
|
||||
"不支持这个币种,只能选自动、CNY 或 USD:{detail}": "That currency is not supported; choose Auto, CNY or USD: {detail}",
|
||||
"运行时已更换,费用显示币种未保存,请重试": "The runtime changed; the display currency was not saved. Try again.",
|
||||
"费用显示币种未能保存:{detail}": "The display currency was not saved: {detail}",
|
||||
"文件改动显示设置未能保存:{detail}": "The file-changes display setting was not saved: {detail}",
|
||||
"折叠中间过程的设置未能保存:{detail}": "The fold-the-work setting was not saved: {detail}",
|
||||
"无进展设置未能保存:{detail}": "The progress settings were not saved: {detail}",
|
||||
"提问等待时长未能保存:{detail}": "The question wait time was not saved: {detail}",
|
||||
"等待时长超出允许范围,未做任何修改:{detail}": "That wait time is out of range; nothing was changed: {detail}",
|
||||
"自动归档设置未能保存:{detail}": "The auto-archive settings were not saved: {detail}",
|
||||
"天数超出允许范围,未做任何修改:{detail}": "The number of days is out of range; nothing was changed: {detail}",
|
||||
"交付验收卡片的设置未能保存:{detail}": "The receipt card setting was not saved: {detail}",
|
||||
"主目录或磁盘根目录不能整体信任,请打开具体的项目文件夹": "A home directory or drive root cannot be trusted as a whole - open a specific project folder",
|
||||
"未能记下对此文件夹的信任决定:{detail}": "The trust decision for this folder was not saved: {detail}",
|
||||
"该数值超出允许范围,未做任何修改:{detail}": "That value is out of range; nothing was changed: {detail}",
|
||||
|
||||
@@ -70,6 +70,13 @@ export const EN_SETTINGS: Record<string, string> = {
|
||||
"其他分区与当前会话不受影响;关闭设置后重新打开可重试。":
|
||||
"Other sections and your session are unaffected; close Settings and reopen to try again.",
|
||||
"操作未完成": "That did not go through",
|
||||
"{name} 的删除没有完整完成": "Removing {name} did not fully complete",
|
||||
"已删除 {name}": "Removed {name}",
|
||||
"已改用 {to}:{roles}": "Now using {to} for: {roles}",
|
||||
"已清空,对应功能已关闭:{roles}": "Cleared, so these are off: {roles}",
|
||||
"恢复审查": "Recovery review",
|
||||
"小型分类": "Small classifications",
|
||||
"顾问": "Advisor",
|
||||
|
||||
// ── 设置:会话 ───────────────────────────────────────────────────
|
||||
"决定任务完成的判定标准。切换立即生效,不会重建运行时。": "Sets what counts as finished. Switching takes effect immediately and does not rebuild the runtime.",
|
||||
@@ -245,6 +252,20 @@ export const EN_SETTINGS: Record<string, string> = {
|
||||
"移除 {path} 的写入权限": "Stop allowing writes to {path}",
|
||||
"添加可写目录,例如 /tmp/scratch": "Open one more writable directory, e.g. /tmp/scratch",
|
||||
"额外可写目录": "Also writable",
|
||||
"此项目的额外文件夹": "Extra folders for this project",
|
||||
"agent 默认只能修改当前项目的文件夹。在这里添加的文件夹也允许写入,只对本项目生效,记在你的用户目录里,不会写进仓库;上面的「额外可写目录」则对所有项目生效。":
|
||||
"The agent can only modify the current project's folder by default. A folder added here is writable too. It applies to this project only and is kept in your user directory, never in the repository; \"Also writable\" above applies to every project.",
|
||||
"「完全放行」只决定是否询问,不会扩大可写范围;要让 agent 修改另一个文件夹,请在这里添加。":
|
||||
"\"Allow everything\" only decides whether to ask; it does not widen where writes can land. To let the agent change another folder, add it here.",
|
||||
"项目文件夹": "Project folders",
|
||||
"添加后,agent 将能修改并删除该文件夹里的任何文件。": "Once added, the agent can change and delete any file in that folder.",
|
||||
"无法读取这个项目的额外文件夹": "Could not read this project's extra folders.",
|
||||
"移除后,已经打开的窗格要等重建后才会收窄可写范围。": "After a removal, panes that are already open narrow their writable area only when they are rebuilt.",
|
||||
"当前项目": "This project",
|
||||
"移除文件夹 {path}": "Remove folder {path}",
|
||||
"要添加的文件夹路径": "Folder path to add",
|
||||
"绝对路径,例如 /Users/you/code/shared-lib": "Absolute path, for example /Users/you/code/shared-lib",
|
||||
"还有 {n} 个窗格正在运行,它们会在下次重建时采用这个改动。": "{n} panes are still running and will take this change at their next rebuild.",
|
||||
"实际可写": "Actually writable",
|
||||
"命令执行方式": "How commands run",
|
||||
"启用沙箱后,命令无法写入清单之外的位置。上方的可写目录清单由操作系统实施,不依赖 agent 自觉遵守。":
|
||||
@@ -725,8 +746,24 @@ export const EN_SETTINGS: Record<string, string> = {
|
||||
"模型可承载的输入、工具结果与输出总量。": "The total input, tool results and output the model can hold.",
|
||||
"单轮生成上限;留空使用内核的模型默认值。": "The per-turn generation limit; leave blank to use the runtime's model default.",
|
||||
"高级连接选项": "Advanced connection options",
|
||||
"从常用服务开始": "Start from a common service",
|
||||
"留空就用预设自带的名称;重名时内核会自动编号。": "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",
|
||||
"预设自带的名称": "The preset's own name",
|
||||
"搜索常用服务": "Search services",
|
||||
"内核没有提供常用服务预设,可以在下面手动填写。": "The kernel offers no service presets; fill the form in below.",
|
||||
"常用服务列表没能读到:{detail}": "The list of common services could not be read: {detail}",
|
||||
"思考控制、代理与中转站自定义参数": "Thinking controls, proxy and gateway parameters",
|
||||
"可选": "Optional",
|
||||
"必填": "Required",
|
||||
"日志与异常退出记录": "Logs and abnormal-exit records",
|
||||
"出现问题或闪退后,反馈时附上这个目录里的文件会更容易查清原因;这里只显示位置,不会上传任何内容。": "After a problem or a crash, attaching the files in this folder to a report makes the cause easier to find. This only shows the location; nothing is uploaded.",
|
||||
"还有更早的记录没有列出,请在上面的文件夹里查看。": "Older records are not listed; look in the folder above.",
|
||||
"复制日志目录": "Copy the log folder",
|
||||
"没有留下异常退出的记录。": "No abnormal-exit record has been left.",
|
||||
"内核异常退出时留下的记录,位于上面目录里的 crash 文件夹:": "Records left when the kernel exited abnormally, in the crash folder inside the directory above:",
|
||||
"还有 {n} 项": "{n} more",
|
||||
"发送思考控制": "Send thinking controls",
|
||||
"允许当前协议发送思考控制字段;模型是否真的思考仍由模型决定。": "Allow this protocol to send its thinking-control fields. The model still decides whether it actually reasons.",
|
||||
"当前协议没有思考控制字段,切换模型时仍可在推理强度菜单查看支持情况。": "This protocol has no thinking-control field. Model support is still shown in the reasoning-effort menu.",
|
||||
@@ -938,7 +975,17 @@ export const EN_SETTINGS: Record<string, string> = {
|
||||
"输入 token 上限": "Input token backstop",
|
||||
"与模型无关的后备:自上次有进展以来的累计输入达到上下文窗口的这个倍数时同样提示。默认 {n} 倍。": "A model-independent backstop: the same notice once the input spent since the last progress reaches this many context windows. Default {n}×.",
|
||||
"倍窗口": "× window",
|
||||
"主题包过大,请控制在 {limit} MB 以内": "The theme pack is too large; keep it within {limit} MB.",
|
||||
"请输入 1 到 {n} 之间的整数。": "Enter a whole number from 1 to {n}.",
|
||||
"提问等待超时": "Question wait time",
|
||||
"长任务中途遇到提问时,不必一直停在那里等人。": "A long task need not stop at a question and wait for someone.",
|
||||
"提问超时后自动跳过": "Skip a question nobody answers",
|
||||
"默认关闭:提问一直等你回答。开启后,超过下面的时间没有回答,这个问题会被跳过,任务继续;模型会被告知这是超时而不是你的回答,并按自己的假设往下做(全自动或目标模式下尤其如此)。终端界面读同一项设置,用 /ask-timeout 修改。": "Off by default: a question waits for you. When on, a question left unanswered for the time below is skipped and the task goes on; the model is told it was a timeout, not your answer, and carries on under its own assumption (especially in full-auto or goal runs). The terminal reads the same setting; change it there with /ask-timeout.",
|
||||
"等待时长": "Wait time",
|
||||
"配置文件里的提问等待时长无效(须为 0 或 {min} 到 {max} 之间),现在按“一直等待”处理:{path}": "The question wait in the config file is not valid (it must be 0 or between {min} and {max}), so questions wait as long as it takes: {path}",
|
||||
"从内核发出问题时开始计时。默认 {n} 秒。": "Counted from when the kernel asks the question. Default {n} seconds.",
|
||||
"请输入 {min} 到 {max} 之间的整数。": "Enter a whole number from {min} to {max}.",
|
||||
"无法读取提问等待设置。": "The question wait setting could not be read.",
|
||||
"无法读取无进展设置。": "Could not read the progress settings.",
|
||||
"自动归档不活跃的对话": "Auto-archive inactive conversations",
|
||||
"归档只是把对话移出常用列表,内容不动,随时可在“已归档”里恢复。置顶的、正在运行的、等待你回答或批准的对话不会被归档。只处理已打开会话窗格的文件夹;导入的旧会话按原有的最后活动时间计算,开启后可能一次归档较多。": "Archiving only moves a conversation out of the everyday list; nothing is deleted and you can restore it from Archived at any time. Pinned conversations, running ones and ones waiting for your answer or approval are never archived. Only folders with an open session pane are swept, and imported older conversations count from their original last activity, so enabling this can archive many at once.",
|
||||
@@ -951,7 +998,7 @@ export const EN_SETTINGS: Record<string, string> = {
|
||||
"已归档": "Archived",
|
||||
"已自动归档 {n} 个会话,可在“已归档”中恢复。": "{n} conversations were archived automatically. You can restore them from Archived.",
|
||||
"图标栏": "Icon rail",
|
||||
"会话侧栏左边的一列图标,直达用量、工具、扩展、记忆、远程、反馈、账号和设置。窗口窄到手机宽度时不显示。": "A column of icons left of the session sidebar that opens usage, tools, extensions, memory, remote, feedback, account and settings. It is not drawn when the window is as narrow as a phone.",
|
||||
"会话侧栏左边的一列图标,直达会话、用量、文件、工具、扩展、记忆、远程、反馈、账号和设置。窗口窄到手机宽度时不显示。": "A column of icons left of the session sidebar that opens conversation, usage, files, tools, extensions, memory, remote, feedback, account and settings. It is not drawn when the window is as narrow as a phone.",
|
||||
"始终显示": "Always",
|
||||
"仅侧栏收起时显示": "Only when the sidebar is collapsed",
|
||||
"不显示": "Never",
|
||||
|
||||
@@ -24,6 +24,8 @@ export const EN_TRAJECTORY: Record<string, string> = {
|
||||
"系统": "System",
|
||||
"信号轨": "Signal rail",
|
||||
"与下方共用时间轴": "Shares the timeline below",
|
||||
"没有时长、也不是工具调用的运行记录(最多前 8 条),按发生的时刻摆在时间轴上,所以位置就是时间,无法拖动;点一个看它的记录。":
|
||||
"Records of the run that have no duration and are not tool calls (the first 8 at most), placed on the timeline at the moment they happened. Their position is their time, so they cannot be dragged; click one to see its record.",
|
||||
"活动回合": "Activity rounds",
|
||||
"点击一行查看记录": "Select a row to inspect it",
|
||||
"模型回合": "Model round",
|
||||
|
||||
@@ -95,7 +95,7 @@ export const EN_WINDOW: Record<string, string> = {
|
||||
"Reasonix 内置 Browser": "Reasonix built-in Browser",
|
||||
"部分网站可能禁止嵌入;此时可在外部浏览器打开。": "Some sites block embedding; open them in your external browser instead.",
|
||||
"正在打开网页…": "Opening page…",
|
||||
"关闭内置 Browser": "Close the built-in Browser",
|
||||
"收起工作台": "Collapse the workbench",
|
||||
"打开内置 Browser": "Open the built-in Browser",
|
||||
"在上方地址栏输入网址,即可在当前任务旁浏览资料。": "Enter an address above to browse beside the current task.",
|
||||
"浏览内容不会替换或中断当前会话。部分网站禁止嵌入时,可使用右上角的外部打开。": "Browsing will not replace or interrupt the current task. If a site blocks embedding, use Open externally in the toolbar.",
|
||||
|
||||
@@ -43,6 +43,15 @@ export function tokens(n: number): string {
|
||||
return `${decimals(n / 1_000_000, 1)}M`;
|
||||
}
|
||||
|
||||
/** elapsed is a run time said compactly: seconds under a minute, then minutes
|
||||
* and hours, a zero unit left out. It matches the terminal's wording. */
|
||||
export function elapsed(ms: number): string {
|
||||
const total = Math.round(ms / 1000);
|
||||
if (total < 60) return `${total}s`;
|
||||
const parts = [[Math.floor(total / 3600), "h"], [Math.floor((total % 3600) / 60), "m"], [total % 60, "s"]] as const;
|
||||
return parts.filter(([n]) => n > 0).map(([n, u]) => `${n}${u}`).join(" ");
|
||||
}
|
||||
|
||||
/** seconds spells a millisecond duration. One place by default — the callers
|
||||
* that used to pick their own each picked a different one. */
|
||||
export const seconds = (ms: number, digits = 1) => `${decimals(ms / 1000, digits)}s`;
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { t } from ".";
|
||||
|
||||
export const JOB_FINISHED = "job_finished";
|
||||
export const JOB_KILLED = "job_killed";
|
||||
export const JOB_FAILED = "job_failed";
|
||||
@@ -16,10 +18,15 @@ export function jobNoticeVars(detail: string | undefined): { name: string } | un
|
||||
}
|
||||
}
|
||||
|
||||
// How a failed process ended, worded from the typed fields the producer wrote:
|
||||
// a signal first, since an exit code is then only the shell's echo of it. The
|
||||
// job function's own error text stands in when the producer did not type it.
|
||||
export function jobErrorText(detail: string | undefined): string | undefined {
|
||||
if (!detail) return undefined;
|
||||
try {
|
||||
const p = JSON.parse(detail) as { error?: unknown };
|
||||
const p = JSON.parse(detail) as { error?: unknown; exitCode?: unknown; signal?: unknown };
|
||||
if (typeof p.signal === "number" && p.signal > 0) return t("被信号 {signal} 终止", { signal: p.signal });
|
||||
if (typeof p.exitCode === "number") return t("退出码 {code}", { code: p.exitCode });
|
||||
return typeof p.error === "string" && p.error !== "" ? p.error : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
|
||||
@@ -24,6 +24,12 @@ const coded = (message: string, code: string, params?: Record<string, string | n
|
||||
// upstream of this — dotted codes, sentinels, typed families — buys nothing if
|
||||
// the last hop prints the English that rode along for the log.
|
||||
describe("what a reader is told a refusal was", () => {
|
||||
it("asks for a currency retry when its runtime owner changed", () => {
|
||||
const refusal = coded("fixture fallback", "display_currency.runtime_changed");
|
||||
expect(reason(refusal)).toBe("运行时已更换,费用显示币种未保存,请重试");
|
||||
localStorage.setItem(STORAGE, "en"); boot();
|
||||
expect(reason(refusal)).toBe("The runtime changed; the display currency was not saved. Try again.");
|
||||
});
|
||||
it("renders shell refusal causes in Chinese and English", () => {
|
||||
for (const code of ["shell.destructive_target", "shell.analysis_unknown", "shell.delete_sequence", "shell.delete_nonliteral", "shell.delete_option", "shell.syntax_error", "shell.parser_unavailable", "shell.parser_timeout", "shell.command_line_too_long"]) {
|
||||
localStorage.setItem(STORAGE, "zh"); boot();
|
||||
|
||||
@@ -60,6 +60,7 @@ const SAID: Record<string, string> = {
|
||||
"provider.name_taken": "已经有名为「{name}」的连接了,换一个名称",
|
||||
"provider.config_unreadable": "读不到配置文件,没法安全地选择密钥存放位置,请检查配置后重试",
|
||||
"provider.endpoint_required": "请填写接口地址",
|
||||
"provider.kind_pinned_endpoint": "这个来源固定了为当前协议写的请求地址或认证方式,不能直接换协议:请先在配置文件里去掉 request_url、chat_url 和 auth_header",
|
||||
"provider.kind_unsupported": "无法识别「{kind}」这种接入方式",
|
||||
"provider.no_models_picked": "请至少选择一个模型",
|
||||
"provider.default_not_selected": "默认模型「{model}」不在已选择的模型中",
|
||||
@@ -221,6 +222,7 @@ const SAID: Record<string, string> = {
|
||||
"provider.probe.upstream_error": "服务商返回错误(HTTP {status}),与填写内容无关,请稍后重试",
|
||||
"provider.probe.timeout": "该地址在限定时间内没有响应。请检查网络或代理,或稍后重试",
|
||||
"provider.probe.unreachable": "无法连接该地址。请检查网络是否通畅,以及地址是否有误",
|
||||
"provider.probe.redirect_refused": "该地址跳转到了 {target},已拒绝,以免 key 被发到别处。如果信任 {target},请把服务地址改成 {target}",
|
||||
"provider.probe.failed": "检查失败,没有具体原因",
|
||||
"provider.probe.decision_path_not_found": "地址可以连通,但没有 System One 决策接口。请填写服务的基地址,不要带 /v1/systemone,Reasonix 会自己在后面追加",
|
||||
"provider.probe.decision_not_compatible": "该地址有响应,但返回的不是 System One 的决策结果。请确认这是决策协议服务的基地址",
|
||||
@@ -278,6 +280,7 @@ const SAID: Record<string, string> = {
|
||||
"session.bind_failed": "接管该会话失败,请重新打开窗口",
|
||||
"session.has_open_pane": "该会话仍有打开的面板,请先关闭",
|
||||
"session.outside_workspace": "该路径不属于任何已知的工作区",
|
||||
"settings.local_runtime_required": "请先打开一个本机会话,再读取本机设置",
|
||||
"hub.no_runtime_open": "尚未打开任何会话",
|
||||
|
||||
// ── 远程 ─────────────────────────────────────────────────────────
|
||||
@@ -350,7 +353,13 @@ const SAID: Record<string, string> = {
|
||||
"browser_tools.save_failed": "内置浏览器设置未能保存:{detail}",
|
||||
"browser_tools.no_enabled": "本次请求未说明开关状态,未做任何修改",
|
||||
"display_currency.invalid": "不支持这个币种,只能选自动、CNY 或 USD:{detail}",
|
||||
"display_currency.runtime_changed": "运行时已更换,费用显示币种未保存,请重试",
|
||||
"display_currency.save_failed": "费用显示币种未能保存:{detail}",
|
||||
"changed_files.save_failed": "文件改动显示设置未能保存:{detail}",
|
||||
"fold_work.save_failed": "折叠中间过程的设置未能保存:{detail}",
|
||||
"ask_timeout.save_failed": "提问等待时长未能保存:{detail}",
|
||||
"ask_timeout.out_of_range": "等待时长超出允许范围,未做任何修改:{detail}",
|
||||
"receipt.save_failed": "交付验收卡片的设置未能保存:{detail}",
|
||||
"progress_watch.save_failed": "无进展设置未能保存:{detail}",
|
||||
"auto_archive.save_failed": "自动归档设置未能保存:{detail}",
|
||||
"auto_archive.out_of_range": "天数超出允许范围,未做任何修改:{detail}",
|
||||
@@ -372,6 +381,14 @@ const SAID: Record<string, string> = {
|
||||
"plugin.export_failed": "未能导出该插件:{detail}",
|
||||
"install.request_unreadable": "无法解析本次安装请求:{detail}",
|
||||
"install.failed": "安装失败:{detail}",
|
||||
"install.source_unreadable": "无法读取这个来源:{detail}",
|
||||
"install.manifest_missing": "这个来源里没有可安装的内容:{detail}",
|
||||
"install.manifest_invalid": "来源里的清单没有通过校验:{detail}",
|
||||
"install.unsupported_kind": "来源与所选类型不符:{detail}",
|
||||
"install.no_compatible_capabilities": "这个插件没有 Reasonix 能运行的能力:{detail}",
|
||||
"install.auth_required": "来源要求先登录或授权:{detail}",
|
||||
"install.binary_missing": "找不到运行它所需的命令或运行时:{detail}",
|
||||
"install.already_exists": "目标已经存在:{detail}",
|
||||
"install.bad_answer": "无法解析安装器的响应:{detail}",
|
||||
"market.bad_slug": "这不是有效的社区包名称",
|
||||
"market.not_found": "社区市场中没有这个已审核的包",
|
||||
@@ -406,6 +423,18 @@ const SAID: Record<string, string> = {
|
||||
"sandbox.no_sandbox_exec": "本机的 sandbox-exec 不可用,命令将不受限制地运行",
|
||||
"sandbox.unsupported_on_windows": "Windows 上尚无操作系统级沙箱,命令将不受限制地运行",
|
||||
"sandbox.unsupported_platform": "该平台尚无可用的沙箱后端,命令将不受限制地运行",
|
||||
"workspace_roots.busy": "当前还有任务在运行,结束后才能更改工作区文件夹",
|
||||
"workspace_roots.editing_disabled": "这台服务器没有开启工作区文件夹编辑",
|
||||
"workspace_roots.path_required": "请填写文件夹路径",
|
||||
"workspace_roots.rebuild_unavailable": "没有可用的当前模型,无法应用文件夹更改",
|
||||
"workspace_roots.not_directory": "需要一个已存在的文件夹的绝对路径",
|
||||
"workspace_roots.covered_by_primary": "这个文件夹已经在当前工作区里",
|
||||
"workspace_roots.too_broad": "这个文件夹范围太大(磁盘根目录,或包含你的主目录或 Reasonix 数据目录)",
|
||||
"workspace_roots.protected": "这个文件夹含有或位于密钥、凭据或 Reasonix 自己的数据目录里,不能授予写入权限",
|
||||
"workspace_roots.not_owned": "这个文件夹不属于当前用户,不能授予写入权限",
|
||||
"workspace_roots.overlaps_write_root": "这个文件夹和已有的可写位置(临时目录、工具缓存或另一个额外文件夹)重叠",
|
||||
"provider.preset_unknown": "没有这个预设",
|
||||
"provider.preset_named_entries": "这个预设会创建多个来源,名称由预设决定",
|
||||
"sandbox.unavailable": "本机没有操作系统沙箱,「关进沙箱」一档无法保存",
|
||||
"remote.install_disabled": "{host} 上没有 reasonix,且本机已设置为不自动安装。请将安装方式改回「自动」,或自行在该机器上安装",
|
||||
"remote.npm_unavailable": "{host} 上无法运行 npm —— 通常是该机器未安装 Node.js。请安装 Node.js,或将安装方式改为「上传」",
|
||||
@@ -447,6 +476,7 @@ const SAID: Record<string, string> = {
|
||||
|
||||
// ── 版本:这个内核背后有没有一个可更新的 Studio ─────────────────
|
||||
"studio.no_install": "这个 Studio 不是安装版(从源码启动),没有可以查看或切换的版本",
|
||||
"studio.no_logs": "这个内核没有窗口外壳,没有可以指出的日志目录",
|
||||
"studio.pin_rejected": "版本固定未能保存:{detail}",
|
||||
"studio.notes_bad_version": "这不是一个已发布的版本号",
|
||||
"studio.notes_absent": "这个版本没有发布更新内容",
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { NOTICE_TEXT } from "./notices";
|
||||
import { NOTICE_VAR_CODES, leftoverDetail, noticeWording } from "./notice_wording";
|
||||
|
||||
const placeholders = (s: string) => [...s.matchAll(/\{(\w+)\}/g)].map((m) => m[1]);
|
||||
|
||||
const PAYLOAD: Record<string, string | undefined> = {
|
||||
extension_skipped: JSON.stringify({ extension: "e", point: "tool.before" }),
|
||||
inbox_recovered: JSON.stringify({ count: 2 }),
|
||||
permission_rules_dormant: JSON.stringify({ rules: [{ list: "ask", rule: "Foo(bar)" }] }),
|
||||
job_finished: JSON.stringify({ id: "j", label: "build" }),
|
||||
job_killed: JSON.stringify({ id: "j", label: "build" }),
|
||||
job_failed: JSON.stringify({ id: "j", label: "build" }),
|
||||
display_currency: "USD",
|
||||
compact_declined: undefined,
|
||||
compact_failed: "summary_failed",
|
||||
compact_held: "summary_failed",
|
||||
};
|
||||
|
||||
describe("notice wording", () => {
|
||||
const withSlots = Object.entries(NOTICE_TEXT).filter(([, s]) => placeholders(s).length > 0);
|
||||
|
||||
it.each(withSlots)("%s registers a reader for every placeholder it has", (code, sentence) => {
|
||||
expect(NOTICE_VAR_CODES, `${code} has placeholders but no variable reader in notice_wording.ts`).toContain(code);
|
||||
expect(code in PAYLOAD, `add a sample payload for ${code}`).toBe(true);
|
||||
const w = noticeWording(code, PAYLOAD[code]);
|
||||
expect(w).toBeDefined();
|
||||
for (const key of placeholders(sentence)) expect(w?.vars).toHaveProperty(key);
|
||||
});
|
||||
|
||||
it.each(NOTICE_VAR_CODES)("%s yields nothing, never a half-filled sentence, for an unreadable detail", (code) => {
|
||||
if (code === "display_currency" || code === "compact_declined") return;
|
||||
expect(noticeWording(code, "not json")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("words a placeholder-free code without variables", () => {
|
||||
expect(noticeWording("verification_stalled", "anything")?.vars).toBeUndefined();
|
||||
});
|
||||
|
||||
it("has no wording for an unknown code", () => {
|
||||
expect(noticeWording("some_future_code", "x")).toBeUndefined();
|
||||
expect(noticeWording(undefined, "x")).toBeUndefined();
|
||||
});
|
||||
|
||||
it.each([
|
||||
["extension_skipped", '{"extension":"","point":"p"}'],
|
||||
["extension_skipped", '{"extension":3,"point":"p"}'],
|
||||
["inbox_recovered", '{"count":"2"}'],
|
||||
["inbox_recovered", '{"count":0}'],
|
||||
["inbox_recovered", '{"count":1.5}'],
|
||||
["permission_rules_dormant", '{"rules":[]}'],
|
||||
["permission_rules_dormant", '{"rules":[{"list":"ask"}]}'],
|
||||
["permission_rules_dormant", '{"rules":[{"list":1,"rule":"x"}]}'],
|
||||
["job_finished", '{"label":"build"}'],
|
||||
["job_killed", '{"id":""}'],
|
||||
["job_failed", '{"id":5}'],
|
||||
["compact_failed", "no_such_reason"],
|
||||
])("%s reads valid JSON of the wrong shape (%s) as no payload", (code, detail) => {
|
||||
expect(noticeWording(code, detail)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("keeps the diagnostic only where the detail was not read as a payload", () => {
|
||||
expect(leftoverDetail("job_failed", '{"id":"j","error":"exit 2"}', { name: "j" })).toBe("exit 2");
|
||||
expect(leftoverDetail("job_finished", '{"id":"j"}', { name: "j" })).toBeUndefined();
|
||||
expect(leftoverDetail("compact_failed", "future_code", undefined)).toBe("future_code");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
import { t } from "./index";
|
||||
import { FOLD_WHY, NO_CODE_WHY } from "./compaction_why";
|
||||
import { EXTENSION_SKIPPED, extensionSkippedVars } from "./extension_skipped";
|
||||
import { INBOX_RECOVERED, inboxRecoveredVars } from "./inbox_recovered";
|
||||
import { JOB_FAILED, JOB_FINISHED, JOB_KILLED, jobErrorText, jobNoticeVars } from "./job_notice";
|
||||
import { NOTICE_TEXT } from "./notices";
|
||||
import { PERMISSION_RULES_DORMANT, permissionRulesDormantVars } from "./permission_rules_dormant";
|
||||
|
||||
export type NoticeVars = Record<string, string | number>;
|
||||
|
||||
const compactReason = (code: string) => (detail: string | undefined): NoticeVars | undefined => {
|
||||
const why = detail ? FOLD_WHY[detail] : code === "compact_declined" ? NO_CODE_WHY : undefined;
|
||||
return why ? { why: t(why) } : undefined;
|
||||
};
|
||||
|
||||
// Every code whose sentence has a placeholder reads its values from the detail
|
||||
// here; a placeholder code missing from this table fails notice_wording.test.ts.
|
||||
const VARS: Record<string, (detail: string | undefined) => NoticeVars | undefined> = {
|
||||
[EXTENSION_SKIPPED]: extensionSkippedVars,
|
||||
[INBOX_RECOVERED]: inboxRecoveredVars,
|
||||
[PERMISSION_RULES_DORMANT]: permissionRulesDormantVars,
|
||||
[JOB_FINISHED]: jobNoticeVars,
|
||||
[JOB_KILLED]: jobNoticeVars,
|
||||
[JOB_FAILED]: jobNoticeVars,
|
||||
display_currency: (detail) => ({ mode: detail || "auto" }),
|
||||
compact_declined: compactReason("compact_declined"),
|
||||
compact_failed: compactReason("compact_failed"),
|
||||
compact_held: compactReason("compact_held"),
|
||||
};
|
||||
|
||||
export const NOTICE_VAR_CODES: readonly string[] = Object.keys(VARS);
|
||||
|
||||
// The sentence for a kernel code in this window's language, with the values its
|
||||
// placeholders need. Undefined when the code has no sentence or its detail does
|
||||
// not carry the values, so the caller keeps the kernel's own English.
|
||||
export function noticeWording(code: string | undefined, detail: string | undefined): { text: string; vars?: NoticeVars } | undefined {
|
||||
const text = code ? NOTICE_TEXT[code] : undefined;
|
||||
if (!code || !text) return undefined;
|
||||
const read = VARS[code];
|
||||
if (!read) return /\{\w+\}/.test(text) ? undefined : { text };
|
||||
const vars = read(detail);
|
||||
return vars ? { text, vars } : undefined;
|
||||
}
|
||||
|
||||
// What of the detail is still worth drawing under the sentence: all of it when
|
||||
// nothing was read from it, only the job's error text when it was a payload.
|
||||
export function leftoverDetail(code: string | undefined, detail: string | undefined, vars: NoticeVars | undefined): string | undefined {
|
||||
if (!vars) return detail;
|
||||
return code === JOB_FAILED ? jobErrorText(detail) : undefined;
|
||||
}
|
||||
@@ -25,6 +25,7 @@ export const NOTICE_TEXT: Record<string, string> = {
|
||||
memory_migration_backup: "旧版记忆文件没能先备份,所以没有迁移,保持原样;详情里有具体原因",
|
||||
inbox_recovered: "已恢复 {n} 条未完成的指令。待发送已暂停,请先在输入框上方的队列里查看,再点“继续派发”",
|
||||
queue_paused_hold: "待发送已暂停,这条消息已排入队列,点“继续派发”后才会发送",
|
||||
input_refused: "会话、工作区或运行时正在切换,这条消息没能立刻发出;它已排入队列,切换结束后会自动发送,不需要重发",
|
||||
unapplied_steer: "引导没有生效:这一轮在处理它之前就结束了。如果仍然需要,请再发送一次:",
|
||||
display_currency: "费用显示币种已设为 {mode}",
|
||||
job_finished: "后台任务已结束:{name}",
|
||||
@@ -36,5 +37,6 @@ export const NOTICE_TEXT: Record<string, string> = {
|
||||
compact_held: "自动压缩暂缓,上次尝试没有完成:{why}",
|
||||
permission_rules_dormant: "有 {n} 条权限规则没有对应的工具,匹配不到任何调用,因此起不到限制作用(如「{list}」里的 {rule});到「设置 → 权限」里删除或改写,命令要写成 Bash(命令:*)",
|
||||
extension_skipped: "扩展 {ext} 的配套后台程序没有运行,该扩展本次(在 {point})已被跳过;到「工具与集成」里查看并启动它,或停用该扩展",
|
||||
ask_timeout_invalid: "配置文件里的提问等待时长无效(须为 0 或 10 到 86400 之间的整数),提问现在会一直等你回答;到设置里重新设一个",
|
||||
perseveration_loop: "模型卡在重复输出同一段文字,这一轮已停止;可以重试、补充引导,或换一个供应商/模型",
|
||||
};
|
||||
@@ -5,12 +5,21 @@ import { reason } from "./i18n/kernel";
|
||||
import { track as trackWidth } from "./ui/viewport";
|
||||
import "./styles/tokens.css";
|
||||
import "./styles/app.css";
|
||||
import "./styles/dock.css";
|
||||
import "./styles/studio.css";
|
||||
import "./styles/tips.css";
|
||||
import "./styles/waitnote.css";
|
||||
import "./styles/narrow.css";
|
||||
import "./styles/workfold.css";
|
||||
import "./styles/workbench-launcher.css";
|
||||
import "./styles/feedback.css";
|
||||
import "./styles/community.css";
|
||||
import "./styles/sponsor.css";
|
||||
import "./styles/chart.css";
|
||||
import "./styles/versions.css";
|
||||
import "./styles/changedpill.css";
|
||||
import "./styles/sessionnotes.css";
|
||||
import "./styles/providerpresets.css";
|
||||
import { App } from "./ui/App";
|
||||
import { SseHub } from "./port/hub";
|
||||
import { HttpError } from "./port/http_error";
|
||||
|
||||
@@ -15,7 +15,7 @@ const SOURCES = import.meta.glob(["./**/*.{ts,tsx}", "../perf/**/*.{ts,tsx}", "!
|
||||
|
||||
// Entry points are reached by the bundler, not by an import. Declared, never
|
||||
// inferred — nothing in a filename says whether something mounts it.
|
||||
const ENTRIES = new Set(["src/main.tsx", "perf/harness.tsx"]);
|
||||
const ENTRIES = new Set(["src/main.tsx", "perf/harness.tsx", "src/testsetup.ts"]);
|
||||
|
||||
/** One spelling for every file, rooted at the project rather than at whichever
|
||||
* directory the glob happened to resolve it from. */
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
// `state` comes from the kernel because "interrupted" appears in no record: it
|
||||
// is what an open barrier means once nothing is waiting on it, and only the
|
||||
// host knows whether anything still is.
|
||||
export type AdjudicationState = "interrupted" | "resolved" | "cancelled" | "superseded";
|
||||
export type AdjudicationState = "interrupted" | "resolved" | "cancelled" | "timed_out" | "superseded";
|
||||
|
||||
export interface AdjudicationEntry {
|
||||
barrier_id: string;
|
||||
|
||||
@@ -11,6 +11,12 @@ export interface PermissionLists {
|
||||
deny: string[];
|
||||
}
|
||||
|
||||
export interface DormantPermissionRule {
|
||||
list: string;
|
||||
rule: string;
|
||||
tool: string;
|
||||
}
|
||||
|
||||
// Editable user lists and separate project rules that are in force. shadowedBy
|
||||
// names a project config that outranks the edited file; effective is the
|
||||
// merged boundary the controller actually loaded.
|
||||
@@ -25,7 +31,7 @@ export interface PermissionRules extends PermissionLists {
|
||||
shadowedBy?: string;
|
||||
effective?: PermissionLists;
|
||||
// Saved rules that name no tool, so they match nothing.
|
||||
dormant?: { list: string; rule: string; tool: string }[];
|
||||
dormant?: DormantPermissionRule[];
|
||||
}
|
||||
|
||||
// Where an approved write may land, and whether bash runs jailed. The
|
||||
@@ -55,6 +61,15 @@ export interface SandboxSettings {
|
||||
shadowedBy?: string;
|
||||
}
|
||||
|
||||
// The project's primary folder and the extra folders the user granted it write
|
||||
// access to. pending counts other panes on the project that were busy and take
|
||||
// the change at their next rebuild.
|
||||
export interface WorkspaceRoots {
|
||||
primary: string;
|
||||
extra: string[];
|
||||
pending?: number;
|
||||
}
|
||||
|
||||
// What a settings surface shows in place of a save it cannot perform. The
|
||||
// kernel refuses every write while the file will not parse, so this arrives
|
||||
// before anything is tried rather than as each panel's own error.
|
||||
@@ -99,8 +114,41 @@ export interface DisplayCurrencySettings {
|
||||
path: string;
|
||||
}
|
||||
|
||||
// Whether the changed-files pill is drawn above the composer; the user file
|
||||
// holds it.
|
||||
// Whether Studio folds a turn's intermediate work under one line; the user file
|
||||
// holds it.
|
||||
export interface FoldWorkSettings {
|
||||
fold: boolean;
|
||||
path: string;
|
||||
}
|
||||
|
||||
export interface ChangedFilesSettings {
|
||||
show: boolean;
|
||||
path: string;
|
||||
}
|
||||
|
||||
// When a run reads as no longer moving. Only the user file holds it: a
|
||||
// project file cannot pause the user's runs.
|
||||
// How long an ask question waits before the host skips it; 0 waits. The user
|
||||
// file holds it, so the terminal reads the same answer.
|
||||
export interface AskTimeoutSettings {
|
||||
seconds: number;
|
||||
// The kernel's code for a value the file holds that cannot be applied.
|
||||
invalid?: string;
|
||||
defaultSeconds: number;
|
||||
minSeconds: number;
|
||||
maxSeconds: number;
|
||||
path: string;
|
||||
}
|
||||
|
||||
// Whether the end-of-turn delivery receipt is drawn as a card; the user file
|
||||
// holds it, so the terminal reads the same answer.
|
||||
export interface ReceiptSettings {
|
||||
show: boolean;
|
||||
path: string;
|
||||
}
|
||||
|
||||
export interface ProgressWatchSettings {
|
||||
pause: boolean;
|
||||
rounds: number;
|
||||
|
||||
Loaded 100 of 819 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user