mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 10:45:59 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e81658de81 | ||
|
|
15a922a53c | ||
|
|
d05ef8a4f2 | ||
|
|
5154991f34 | ||
|
|
27113eb47d | ||
|
|
65e1617575 | ||
|
|
fae12d3ad5 | ||
|
|
fbe60bb0a3 | ||
|
|
0809983446 | ||
|
|
e5ea0f9e2f | ||
|
|
53e1242122 | ||
|
|
493c6811af | ||
|
|
4eeca4a58c | ||
|
|
3f1008d4ab | ||
|
|
19abe6a878 | ||
|
|
1ff581ef86 | ||
|
|
88c3a736ba | ||
|
|
ddd0f11cd0 | ||
|
|
53b94a785e | ||
|
|
ab89a3d94e | ||
|
|
cbd8487a0e | ||
|
|
7ee39c1d69 | ||
|
|
8cc804e687 | ||
|
|
98622dad1e | ||
|
|
5159ce9c74 | ||
|
|
a2e2b38ba6 | ||
|
|
48c5b29acb | ||
|
|
7aa034224b | ||
|
|
3a96b59bc1 | ||
|
|
ab5e5074a5 | ||
|
|
136dad0094 | ||
|
|
91ea60ade6 | ||
|
|
3e03f73c6f | ||
|
|
7461ea823f | ||
|
|
132bcf98ab | ||
|
|
e5354ea072 | ||
|
|
f5282fe481 | ||
|
|
d558ac1f4e | ||
|
|
0af618a66b | ||
|
|
47d6f06ae1 | ||
|
|
207a921f0d | ||
|
|
b61bc97dc0 | ||
|
|
e894514bb2 | ||
|
|
f0897cf797 | ||
|
|
07d6c66929 | ||
|
|
990375ba71 | ||
|
|
6f95b0166b | ||
|
|
2c81a74225 | ||
|
|
9fafd7ce3a | ||
|
|
1019b2811d | ||
|
|
9ee6ad0a81 | ||
|
|
83a9b00566 | ||
|
|
a9fc7d8a27 | ||
|
|
f2d59e6fa0 | ||
|
|
4e9ae7baba | ||
|
|
732176487f | ||
|
|
be1658c697 | ||
|
|
195fc08132 | ||
|
|
baa8ff0f56 | ||
|
|
92855769ae | ||
|
|
aeadc04396 | ||
|
|
0a019e294e | ||
|
|
8e58a1fd3d | ||
|
|
df6aae6f07 | ||
|
|
34289879ad | ||
|
|
fff1350970 | ||
|
|
5207eaeff6 | ||
|
|
d6c0cda861 | ||
|
|
cadcfd3c44 | ||
|
|
a0aab3373e | ||
|
|
8b7eaac0f0 | ||
|
|
301fdea4a8 | ||
|
|
39c6204bdf | ||
|
|
81e6b67426 | ||
|
|
302535d501 | ||
|
|
bbb42d3c1c | ||
|
|
2f34afada8 | ||
|
|
12bdc4fb8f | ||
|
|
76d4d51145 | ||
|
|
156c1f9c1a | ||
|
|
8d2df779c0 | ||
|
|
b0efcb3b93 | ||
|
|
9fe9c00907 | ||
|
|
d178d0a76e | ||
|
|
b69a587842 | ||
|
|
1b8c281bc7 | ||
|
|
6bd9fa74c9 | ||
|
|
af35e5408f | ||
|
|
0a2d2a3776 | ||
|
|
e4be215d14 | ||
|
|
21e3ceb01e | ||
|
|
991e7dbebd | ||
|
|
fe060ae88f | ||
|
|
e045489e46 | ||
|
|
18d2427316 | ||
|
|
0e04f484df | ||
|
|
8c5efcdada | ||
|
|
99c1626d01 | ||
|
|
3f85cdad07 | ||
|
|
3768de2569 | ||
|
|
8ef15184db | ||
|
|
1b9afa89c7 | ||
|
|
afbc1c0a07 | ||
|
|
8965290c35 | ||
|
|
bddb48202a | ||
|
|
97576fd387 | ||
|
|
1ba983bdda | ||
|
|
d6d3326140 | ||
|
|
cdbbff8a41 | ||
|
|
505a3d7759 | ||
|
|
1ff464b855 | ||
|
|
c766bf7174 | ||
|
|
2ff95b934e | ||
|
|
b3e93df5e9 | ||
|
|
ee4214da3e | ||
|
|
7b46a724b5 | ||
|
|
b4ecf529e3 | ||
|
|
d483ab15a2 | ||
|
|
56a46b6c99 | ||
|
|
8d8579bb95 | ||
|
|
cac2d3b6fd | ||
|
|
e4c8a27b73 | ||
|
|
468efe6d29 | ||
|
|
18a8a95d1f | ||
|
|
eca6834dad | ||
|
|
694b096ffc | ||
|
|
4e7831ba12 | ||
|
|
4024bc66dd | ||
|
|
e5b701091d | ||
|
|
92104fdf8a | ||
|
|
c204fe1a83 | ||
|
|
60204c3030 | ||
|
|
b83ad671b4 | ||
|
|
3b4319cf9a | ||
|
|
da81a9e600 | ||
|
|
a7c634dc63 | ||
|
|
8166f35a08 | ||
|
|
c55502f890 | ||
|
|
428877db31 | ||
|
|
756050ddf9 | ||
|
|
3dae084f94 | ||
|
|
da58ed7454 | ||
|
|
39c861412a | ||
|
|
406b761ff0 | ||
|
|
dbaaf72c01 | ||
|
|
f3cc04465f | ||
|
|
6dbabcbf71 | ||
|
|
8d61174ca0 | ||
|
|
6c32116500 | ||
|
|
da0ed97148 | ||
|
|
5421b86ed3 | ||
|
|
3c8695b470 | ||
|
|
38782678d4 | ||
|
|
dd2485645a | ||
|
|
1eb620fb71 | ||
|
|
81b255a538 | ||
|
|
f8a5df2669 | ||
|
|
b777bde623 | ||
|
|
a879773823 | ||
|
|
fccddf170d | ||
|
|
aec7849199 | ||
|
|
cbba10ae8c | ||
|
|
a649c72188 | ||
|
|
30f5ff1e2b | ||
|
|
eaafef675b | ||
|
|
a308987f1b | ||
|
|
3db246f5fc | ||
|
|
bbd4e65c69 | ||
|
|
cede083695 | ||
|
|
9168b3439d | ||
|
|
164cfef2dd | ||
|
|
2ce60e12f0 | ||
|
|
136bd5fcd1 | ||
|
|
78395619f1 | ||
|
|
430010cc40 | ||
|
|
7ca9dc39b0 | ||
|
|
2c04fece87 | ||
|
|
0b3cad44cc | ||
|
|
38028e0f86 | ||
|
|
6259f77bd0 | ||
|
|
aaaf521163 | ||
|
|
02c1732e68 | ||
|
|
219aadd409 | ||
|
|
9fb4dd8fb0 | ||
|
|
0004d81e52 | ||
|
|
8b6b63c92c | ||
|
|
4115cc702e | ||
|
|
28b3300cc5 | ||
|
|
369f55cbf5 | ||
|
|
b0b7cfa34c | ||
|
|
77eab9e8e2 | ||
|
|
f3452e3a82 | ||
|
|
a311dd464e | ||
|
|
9e32035f32 | ||
|
|
b124e2a5fa | ||
|
|
7dcfb7fa46 | ||
|
|
9a165ea6dc | ||
|
|
5a7fd42d6a | ||
|
|
7da1197efc | ||
|
|
9aad6d8b4c | ||
|
|
3da4a00263 | ||
|
|
a9cd939c1f | ||
|
|
ddf6ca6820 | ||
|
|
c2d5560aac | ||
|
|
51a3ed710c | ||
|
|
dd7625ca58 | ||
|
|
20134210be | ||
|
|
f41512ed00 | ||
|
|
df6b737fba | ||
|
|
d0572a4f5a | ||
|
|
ee72c66af6 | ||
|
|
a0df39a7a1 | ||
|
|
43dc1778d2 | ||
|
|
923370645c | ||
|
|
aad51d1970 | ||
|
|
3575809b3e | ||
|
|
b63d4151a2 | ||
|
|
9b6437fe06 | ||
|
|
9eab98f5e7 | ||
|
|
425c59b2c5 | ||
|
|
00b14555bd | ||
|
|
c92312d04d | ||
|
|
c9e9da581c | ||
|
|
2b6128ff27 | ||
|
|
2370aa5aad | ||
|
|
1a1ccbef33 | ||
|
|
7cefa9c194 | ||
|
|
522001b7be | ||
|
|
213b8fa2db | ||
|
|
8580ea084b | ||
|
|
14ed15ba5e | ||
|
|
a42067d245 | ||
|
|
de2c07c46f | ||
|
|
1ad0716a37 | ||
|
|
e6b520c39b | ||
|
|
f733345587 | ||
|
|
4f1637e05f | ||
|
|
8f7e4a0748 | ||
|
|
3770cb3856 | ||
|
|
2835c52d2b | ||
|
|
64638bccd1 | ||
|
|
ae31f7388e | ||
|
|
ade8df6c1f | ||
|
|
420718b717 | ||
|
|
4e8dc79184 | ||
|
|
97f5a265de | ||
|
|
b76822bc19 | ||
|
|
c2974487dd | ||
|
|
1d75becebc | ||
|
|
d39c0ff888 | ||
|
|
24dc82f313 | ||
|
|
b73f1174b2 | ||
|
|
d62e151fcb | ||
|
|
d5c4e196a6 | ||
|
|
e8e7af5996 | ||
|
|
7e45e66110 | ||
|
|
bf979b7831 | ||
|
|
1405c2a043 | ||
|
|
ca4fbe80aa | ||
|
|
9c3579256d | ||
|
|
5d88b179b2 | ||
|
|
1132215f4a | ||
|
|
d9ba90ed04 | ||
|
|
4a09d5546b | ||
|
|
4a5f86a0d1 | ||
|
|
555b96966c | ||
|
|
790c6be08e | ||
|
|
52f465e4f5 | ||
|
|
3d129f0d6f | ||
|
|
e66c50c76b | ||
|
|
b474d12fdd | ||
|
|
b66fdb30f6 | ||
|
|
092ab722cc | ||
|
|
390f996103 | ||
|
|
46dd766262 | ||
|
|
4cf81e89fe | ||
|
|
63582bc7c8 | ||
|
|
54c73168c3 | ||
|
|
36c1faba14 | ||
|
|
1808ff6d63 | ||
|
|
9c3123c7a3 | ||
|
|
aecf4e6960 | ||
|
|
d4e11410cb | ||
|
|
2f7809c10d | ||
|
|
d2cb0aa894 | ||
|
|
3cb73b3bf4 | ||
|
|
ec866730e0 | ||
|
|
3065db7a49 | ||
|
|
ae2e9053f5 | ||
|
|
28e37a1c0a | ||
|
|
3822d577eb | ||
|
|
88751263bf | ||
|
|
c2d10fb7e0 | ||
|
|
053136c488 | ||
|
|
49939444ff | ||
|
|
3a946c048d | ||
|
|
e30096d678 | ||
|
|
54f4898b2c | ||
|
|
03254360ee | ||
|
|
35fc8b746a | ||
|
|
befb5e258c | ||
|
|
8e9f7cfed0 | ||
|
|
96297a2146 | ||
|
|
48b340a877 | ||
|
|
d67f2835ac | ||
|
|
6512f0db40 | ||
|
|
a0ac937c65 | ||
|
|
15a1f262fb | ||
|
|
7574f8b4c9 | ||
|
|
d945284cec | ||
|
|
c55c6bee78 | ||
|
|
2a3855f16a | ||
|
|
6d0c756228 | ||
|
|
dd9b441f99 | ||
|
|
acf3bfdcbc | ||
|
|
96b296cd90 | ||
|
|
b18f84ee22 | ||
|
|
29c8349c3a | ||
|
|
51fe2e9255 | ||
|
|
8cf5b64418 | ||
|
|
463840813f | ||
|
|
c04f7dd247 | ||
|
|
a44319982f | ||
|
|
a8bea84e68 | ||
|
|
f457bbbd61 | ||
|
|
3007c94084 | ||
|
|
c09dbf56b9 | ||
|
|
26984605be | ||
|
|
85759d074c | ||
|
|
e47c32200c | ||
|
|
861a7b8d12 | ||
|
|
32107d7bb6 | ||
|
|
d5c9f2d739 | ||
|
|
1a74c7450f | ||
|
|
947ce21896 | ||
|
|
872ad88677 | ||
|
|
4512a84cbb | ||
|
|
9424d402fb | ||
|
|
2dfa1ca44c | ||
|
|
e5795e5e0f | ||
|
|
86db9b4df3 | ||
|
|
8829515d5e | ||
|
|
8b2504fb6b | ||
|
|
42503a4d36 | ||
|
|
598ecbf1b2 | ||
|
|
d730d8c8f8 |
No files matched your search
@@ -10,9 +10,7 @@ on:
|
||||
# conservative: when in doubt, run.
|
||||
paths-ignore:
|
||||
- 'docs/**'
|
||||
- 'site/**'
|
||||
- 'release-notes/**'
|
||||
- 'workers/**'
|
||||
- 'benchmarks/**'
|
||||
- 'npm/**'
|
||||
- '*.md'
|
||||
|
||||
@@ -1299,7 +1299,7 @@ jobs:
|
||||
if: ${{ !cancelled() && needs.desktop-prepare.result == 'success' && ((github.event_name != 'pull_request' && needs.changes.outputs.notes_only != 'true') || needs.changes.outputs.native != 'false') }}
|
||||
runs-on: windows-latest
|
||||
# Independent runners prevent valid groups from consuming each other's
|
||||
# step deadline. Go retains its default per-package ten-minute timeout.
|
||||
# step deadline. Each group runs with go -timeout=25m (driver testArgs).
|
||||
timeout-minutes: 45
|
||||
strategy:
|
||||
fail-fast: false
|
||||
@@ -1636,16 +1636,13 @@ jobs:
|
||||
-ignore 'label "windows-11-arm" is unknown' \
|
||||
-ignore 'label "macos-15-intel" is unknown' \
|
||||
.github/workflows/release-stable.yml \
|
||||
.github/workflows/prepare-release-notes.yml \
|
||||
.github/workflows/release-candidate.yml \
|
||||
.github/workflows/release-candidate-verify.yml \
|
||||
.github/workflows/release-promote.yml \
|
||||
.github/workflows/release-verify.yml \
|
||||
.github/workflows/release-site-recovery.yml \
|
||||
.github/workflows/release.yml \
|
||||
.github/workflows/release-npm.yml \
|
||||
.github/workflows/release-desktop.yml \
|
||||
.github/workflows/pages.yml \
|
||||
.github/workflows/apple-notary-log.yml \
|
||||
.github/workflows/macos-signing-check.yml \
|
||||
.github/workflows/release-verify-issues.yml \
|
||||
@@ -1655,6 +1652,7 @@ jobs:
|
||||
scripts/verify-release-artifact-archive.test.mjs \
|
||||
scripts/build-release-cli-candidate.test.mjs scripts/publish-homebrew-cask.test.mjs \
|
||||
scripts/desktop-release-artifacts.test.mjs scripts/release-publication-ledger.test.mjs \
|
||||
scripts/release-cli-freeze.test.mjs \
|
||||
scripts/ci-paths.test.mjs scripts/ci-workflow.test.mjs scripts/macos-go-tests.test.mjs \
|
||||
scripts/desktop-windows-go-tests.test.mjs \
|
||||
npm/publish.test.mjs
|
||||
@@ -1669,14 +1667,14 @@ jobs:
|
||||
node scripts/check-single-release-public-contract.mjs
|
||||
|
||||
# `lint` is a protected check name. Keep it as the fail-closed aggregate so
|
||||
# Required check for the root-module and site jobs the per-OS `test` legs do
|
||||
# required checks for root-module jobs the per-OS `test` legs do
|
||||
# not cover. windows-control and sdk gate internally through RUN_STEPS and so
|
||||
# always report a result: `success` here means "did not fail", not "validated
|
||||
# this diff" — the same trade-off the required `test` legs already make. Do
|
||||
# not add govulncheck: it sets continue-on-error, so needs.*.result is
|
||||
# `success` even when its steps fail and the assertion would be a tautology.
|
||||
root:
|
||||
needs: [changes, windows-control, windows-isolated, sdk, site, coverage]
|
||||
needs: [changes, windows-control, windows-isolated, sdk, coverage]
|
||||
if: ${{ !cancelled() }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
@@ -1684,12 +1682,10 @@ jobs:
|
||||
env:
|
||||
CHANGES_RESULT: ${{ needs.changes.result }}
|
||||
CODE_REQUIRED: ${{ (github.event_name != 'pull_request' && needs.changes.outputs.notes_only != 'true') || needs.changes.outputs.code != 'false' }}
|
||||
SITE_REQUIRED: ${{ (github.event_name != 'pull_request' && needs.changes.outputs.notes_only != 'true') || needs.changes.outputs.site != 'false' }}
|
||||
COVERAGE_REQUIRED: ${{ github.event_name != 'pull_request' && needs.changes.outputs.notes_only != 'true' }}
|
||||
CONTROL_RESULT: ${{ needs.windows-control.result }}
|
||||
ISOLATED_RESULT: ${{ needs.windows-isolated.result }}
|
||||
SDK_RESULT: ${{ needs.sdk.result }}
|
||||
SITE_RESULT: ${{ needs.site.result }}
|
||||
COVERAGE_RESULT: ${{ needs.coverage.result }}
|
||||
run: |
|
||||
test "$CHANGES_RESULT" = success
|
||||
@@ -1697,7 +1693,6 @@ jobs:
|
||||
test "$CONTROL_RESULT" = success
|
||||
test "$SDK_RESULT" = success
|
||||
test "$ISOLATED_RESULT" = "$(expected "$CODE_REQUIRED")"
|
||||
test "$SITE_RESULT" = "$(expected "$SITE_REQUIRED")"
|
||||
test "$COVERAGE_RESULT" = "$(expected "$COVERAGE_REQUIRED")"
|
||||
|
||||
# frontend unit coverage can run once in desktop-frontend without weakening
|
||||
@@ -1731,31 +1726,6 @@ jobs:
|
||||
test "$FRONTEND_RESULT" = skipped
|
||||
fi
|
||||
|
||||
# The site/ auth client has security-sensitive redirect-validation logic
|
||||
# (safeNext) covered by node:test unit tests. Those tests use only Node
|
||||
# builtins, so no `npm install` is needed — run them directly on every PR so
|
||||
# a regression in redirect validation fails the build instead of shipping.
|
||||
# The notes_only term matches every other gated job here. Without it a
|
||||
# release-notes-only push runs this job while the root aggregate expects the
|
||||
# house-style skip, which would leave main-v2 permanently red. A pure
|
||||
# release-notes/** diff cannot touch site/, so skipping it loses no coverage.
|
||||
site:
|
||||
needs: changes
|
||||
if: ${{ !cancelled() && ((github.event_name != 'pull_request' && needs.changes.outputs.notes_only != 'true') || needs.changes.outputs.site != 'false') }}
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: site
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
with:
|
||||
node-version-file: .node-version
|
||||
|
||||
- name: test
|
||||
run: npm test
|
||||
|
||||
govulncheck:
|
||||
needs: changes
|
||||
if: (github.event_name != 'pull_request' && needs.changes.outputs.notes_only != 'true')
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
name: Deploy accounts worker
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: deploy-accounts-worker
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
- uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
|
||||
with:
|
||||
version: 10
|
||||
run_install: false
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
with:
|
||||
node-version: "24"
|
||||
cache: pnpm
|
||||
cache-dependency-path: workers/accounts/pnpm-lock.yaml
|
||||
- name: Deploy
|
||||
working-directory: workers/accounts
|
||||
env:
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
run: |
|
||||
pnpm install --frozen-lockfile
|
||||
pnpm test
|
||||
pnpm typecheck
|
||||
npx wrangler deploy
|
||||
- name: Sync RESEND_API_KEY to the worker
|
||||
working-directory: workers/accounts
|
||||
env:
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
RESEND_API_KEY: ${{ secrets.RESEND_API_KEY }}
|
||||
run: |
|
||||
if [ -n "$RESEND_API_KEY" ]; then
|
||||
printf '%s' "$RESEND_API_KEY" | npx wrangler secret put RESEND_API_KEY
|
||||
else
|
||||
echo "RESEND_API_KEY not set in repo secrets; skipping (worker stays in stub email mode)."
|
||||
fi
|
||||
@@ -1,168 +0,0 @@
|
||||
name: Deploy crash worker
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
firebase_data_action:
|
||||
description: Firebase crash history operation (migration actions do not deploy the Worker)
|
||||
required: true
|
||||
default: none
|
||||
type: choice
|
||||
options:
|
||||
- none
|
||||
- dry-run
|
||||
- apply
|
||||
- verify-only
|
||||
confirmation:
|
||||
description: Enter APPLY_FIREBASE_CRASH_DATA when selecting apply
|
||||
required: false
|
||||
default: ''
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: deploy-crash-worker
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
if: github.event_name == 'push' || inputs.firebase_data_action == 'none'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: npm
|
||||
cache-dependency-path: workers/crash-report/package-lock.json
|
||||
- name: Validate
|
||||
working-directory: workers/crash-report
|
||||
run: |
|
||||
npm ci
|
||||
npm run typecheck
|
||||
npm test
|
||||
- name: Apply and verify diagnostics v2 D1 migration
|
||||
working-directory: workers/crash-report
|
||||
env:
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
run: npm run migrate:diagnostics-v2
|
||||
- name: Apply and verify Firebase crash D1 migration
|
||||
working-directory: workers/crash-report
|
||||
env:
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
run: npm run migrate:firebase-crash
|
||||
- name: Apply dashboard performance indexes
|
||||
working-directory: workers/crash-report
|
||||
env:
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
run: npm run migrate:dashboard-indexes
|
||||
- name: Apply registry performance indexes and install rollup
|
||||
working-directory: workers/crash-report
|
||||
env:
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
run: npm run migrate:registry-indexes
|
||||
- name: Sync Firebase crash secrets
|
||||
working-directory: workers/crash-report
|
||||
env:
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
FIREBASE_DATABASE_URL: ${{ secrets.FIREBASE_DATABASE_URL }}
|
||||
FIREBASE_CLIENT_EMAIL: ${{ secrets.FIREBASE_CLIENT_EMAIL }}
|
||||
FIREBASE_PRIVATE_KEY: ${{ secrets.FIREBASE_PRIVATE_KEY }}
|
||||
run: |
|
||||
configured=0
|
||||
[ -n "$FIREBASE_DATABASE_URL" ] && configured=$((configured + 1))
|
||||
[ -n "$FIREBASE_CLIENT_EMAIL" ] && configured=$((configured + 1))
|
||||
[ -n "$FIREBASE_PRIVATE_KEY" ] && configured=$((configured + 1))
|
||||
if [ "$configured" -ne 0 ] && [ "$configured" -ne 3 ]; then
|
||||
echo "Firebase crash secrets must be either all configured or all absent."
|
||||
exit 1
|
||||
fi
|
||||
node -e 'process.stdout.write(JSON.stringify({FIREBASE_DATABASE_URL:process.env.FIREBASE_DATABASE_URL||null,FIREBASE_CLIENT_EMAIL:process.env.FIREBASE_CLIENT_EMAIL||null,FIREBASE_PRIVATE_KEY:process.env.FIREBASE_PRIVATE_KEY||null}))' | npx wrangler secret bulk
|
||||
- name: Deploy
|
||||
working-directory: workers/crash-report
|
||||
env:
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
run: |
|
||||
npx wrangler deploy
|
||||
# Mirrors the ALERT_WEBHOOK repo secret into the worker so the ingest
|
||||
# sentinel can push alerts (see runIngestSentinel). Managed here so
|
||||
# nobody needs local Cloudflare credentials; when the repo secret is
|
||||
# unset the sentinel stays log-only.
|
||||
- name: Sync alert webhook secret
|
||||
working-directory: workers/crash-report
|
||||
env:
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
ALERT_WEBHOOK: ${{ secrets.ALERT_WEBHOOK }}
|
||||
run: |
|
||||
if [ -z "$ALERT_WEBHOOK" ]; then
|
||||
echo "ALERT_WEBHOOK repo secret not set; removing any existing worker secret."
|
||||
printf '{"ALERT_WEBHOOK":null}' | npx wrangler secret bulk
|
||||
exit 0
|
||||
fi
|
||||
printf '%s' "$ALERT_WEBHOOK" | npx wrangler secret put ALERT_WEBHOOK
|
||||
|
||||
migrate-firebase-data:
|
||||
if: github.event_name == 'workflow_dispatch' && inputs.firebase_data_action != 'none'
|
||||
runs-on: ubuntu-latest
|
||||
environment: canary
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
- name: Require the protected production branch
|
||||
run: |
|
||||
if [ "$GITHUB_REF" != "refs/heads/main-v2" ]; then
|
||||
echo "Firebase crash data migration must run from main-v2."
|
||||
exit 1
|
||||
fi
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: npm
|
||||
cache-dependency-path: workers/crash-report/package-lock.json
|
||||
- name: Validate
|
||||
working-directory: workers/crash-report
|
||||
run: |
|
||||
npm ci
|
||||
npm run typecheck
|
||||
npm test
|
||||
- name: Run Firebase crash history operation
|
||||
working-directory: workers/crash-report
|
||||
env:
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
FIREBASE_DATABASE_URL: ${{ secrets.FIREBASE_DATABASE_URL }}
|
||||
FIREBASE_CLIENT_EMAIL: ${{ secrets.FIREBASE_CLIENT_EMAIL }}
|
||||
FIREBASE_PRIVATE_KEY: ${{ secrets.FIREBASE_PRIVATE_KEY }}
|
||||
FIREBASE_DATA_ACTION: ${{ inputs.firebase_data_action }}
|
||||
FIREBASE_DATA_CONFIRMATION: ${{ inputs.confirmation }}
|
||||
run: |
|
||||
configured=0
|
||||
[ -n "$CLOUDFLARE_API_TOKEN" ] && configured=$((configured + 1))
|
||||
[ -n "$FIREBASE_DATABASE_URL" ] && configured=$((configured + 1))
|
||||
[ -n "$FIREBASE_CLIENT_EMAIL" ] && configured=$((configured + 1))
|
||||
[ -n "$FIREBASE_PRIVATE_KEY" ] && configured=$((configured + 1))
|
||||
if [ "$configured" -ne 4 ]; then
|
||||
echo "Cloudflare and Firebase migration secrets must all be configured."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
case "$FIREBASE_DATA_ACTION" in
|
||||
dry-run)
|
||||
npm run migrate:firebase-data
|
||||
;;
|
||||
apply)
|
||||
if [ "$FIREBASE_DATA_CONFIRMATION" != "APPLY_FIREBASE_CRASH_DATA" ]; then
|
||||
echo "Apply requires the exact confirmation APPLY_FIREBASE_CRASH_DATA."
|
||||
exit 1
|
||||
fi
|
||||
npm run migrate:firebase-data -- --apply
|
||||
npm run migrate:firebase-data -- --verify-only
|
||||
;;
|
||||
verify-only)
|
||||
npm run migrate:firebase-data -- --verify-only
|
||||
;;
|
||||
*)
|
||||
echo "Unsupported Firebase crash data action."
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
@@ -1,36 +0,0 @@
|
||||
name: Deploy forum worker
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: deploy-forum-worker
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
- uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
|
||||
with:
|
||||
version: 10
|
||||
run_install: false
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
with:
|
||||
node-version: "24"
|
||||
cache: pnpm
|
||||
cache-dependency-path: workers/forum/pnpm-lock.yaml
|
||||
- name: Deploy
|
||||
working-directory: workers/forum
|
||||
env:
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
run: |
|
||||
pnpm install --frozen-lockfile
|
||||
pnpm test
|
||||
pnpm typecheck
|
||||
npx wrangler d1 execute reasonix-forum --remote --file=migrate-performance-indexes.sql
|
||||
npx wrangler deploy
|
||||
@@ -1,76 +0,0 @@
|
||||
name: Deploy site
|
||||
run-name: Deploy site ${{ inputs.release_version && format('v{0}', inputs.release_version) || github.sha }}${{ inputs.release_request && format(' [{0}]', inputs.release_request) || '' }}
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
release_version:
|
||||
description: "Optional release version that owns this deployment"
|
||||
required: false
|
||||
default: ""
|
||||
type: string
|
||||
release_request:
|
||||
description: "Release workflow correlation ID (optional)"
|
||||
required: false
|
||||
default: ""
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pages: write
|
||||
id-token: write
|
||||
|
||||
concurrency:
|
||||
group: pages
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
build:
|
||||
if: github.ref == 'refs/heads/main-v2' && github.ref_protected
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: npm
|
||||
cache-dependency-path: site/package-lock.json
|
||||
- name: Build
|
||||
working-directory: site
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
npm ci
|
||||
npm run build
|
||||
- uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5
|
||||
with:
|
||||
path: site/dist
|
||||
|
||||
deploy:
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
environment:
|
||||
name: github-pages
|
||||
url: ${{ steps.deploy.outputs.page_url }}
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
if: inputs.release_version != ''
|
||||
with:
|
||||
ref: ${{ github.sha }}
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
if: inputs.release_version != ''
|
||||
with:
|
||||
node-version: "22"
|
||||
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7
|
||||
if: inputs.release_version != ''
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
cache: false
|
||||
- name: Recheck release ownership immediately before deployment
|
||||
if: inputs.release_version != ''
|
||||
env:
|
||||
VERSION: ${{ inputs.release_version }}
|
||||
run: test "$(bash scripts/observe-release-site.sh "$VERSION" recover)" = true
|
||||
- id: deploy
|
||||
uses: actions/deploy-pages@368f82528645a54fb793d4d04e342629a3f51346 # v5
|
||||
@@ -1,106 +0,0 @@
|
||||
name: Prepare release
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: "Official version without a tag prefix (for example 1.20.0)"
|
||||
required: true
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
draft:
|
||||
name: generate reviewable bilingual draft
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
with:
|
||||
node-version: "22"
|
||||
|
||||
- name: Select the review branch
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
VERSION: ${{ inputs.version }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
git fetch origin main-v2
|
||||
|
||||
if [[ ! "$VERSION" =~ ^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]]; then
|
||||
echo "::error::version must be MAJOR.MINOR.PATCH"
|
||||
exit 1
|
||||
fi
|
||||
branch="release-notes/v${VERSION}"
|
||||
if git ls-remote --exit-code --heads origin "$branch" >/dev/null 2>&1; then
|
||||
git fetch origin "refs/heads/$branch:refs/remotes/origin/$branch"
|
||||
git checkout -b "$branch" --track "origin/$branch"
|
||||
source_sha="$(git merge-base origin/main-v2 HEAD)"
|
||||
else
|
||||
git checkout -b "$branch"
|
||||
source_sha="$(git rev-parse HEAD)"
|
||||
fi
|
||||
git merge-base --is-ancestor "$source_sha" origin/main-v2
|
||||
echo "RELEASE_NOTES_BRANCH=$branch" >> "$GITHUB_ENV"
|
||||
echo "RELEASE_NOTES_SOURCE_SHA=$source_sha" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Generate and validate release notes
|
||||
env:
|
||||
DEEPSEEK_API_KEY: ${{ secrets.DEEPSEEK_API_KEY }}
|
||||
DEEPSEEK_MODEL: deepseek-v4-pro
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
VERSION: ${{ inputs.version }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# The first preparation fixes the product range. Reruns update the
|
||||
# draft without silently absorbing unrelated main-v2 merges.
|
||||
node scripts/generate-release-notes.mjs --version "$VERSION" --to "$RELEASE_NOTES_SOURCE_SHA"
|
||||
node scripts/release-notes.mjs validate
|
||||
node --test scripts/release-notes.test.mjs
|
||||
node scripts/release-notes.mjs render --version "$VERSION" --output /tmp/release-notes.md
|
||||
{
|
||||
echo "## Review draft for v${VERSION#v}"
|
||||
echo
|
||||
cat /tmp/release-notes.md
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Open or update the review PR
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
VERSION: ${{ inputs.version }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git add release-notes/releases.json
|
||||
if ! git diff --cached --quiet; then
|
||||
git commit -m "docs(release): prepare v${VERSION#v} notes" \
|
||||
-m "Summary:
|
||||
Generate a bilingual, product-focused draft from merged pull request metadata. Reuse the selected release-bound PR when one is available.
|
||||
|
||||
Verification:
|
||||
Validate the catalog, citations, bilingual fields, and rendered GitHub release notes before committing."
|
||||
fi
|
||||
git push -u origin "$RELEASE_NOTES_BRANCH"
|
||||
if ! gh pr list --head "$RELEASE_NOTES_BRANCH" --state open --json number --jq 'length > 0' | grep -q true; then
|
||||
if ! gh pr create \
|
||||
--base main-v2 \
|
||||
--head "$RELEASE_NOTES_BRANCH" \
|
||||
--title "docs(release): 准备 v${VERSION#v} 中英更新日志" \
|
||||
--body-file /tmp/release-notes.md; then
|
||||
echo "::warning::GitHub Actions could not open the PR; the reviewed branch is preserved."
|
||||
{
|
||||
echo "## Manual PR handoff"
|
||||
echo
|
||||
echo '```sh'
|
||||
echo "gh pr create --repo ${{ github.repository }} --base main-v2 --head $RELEASE_NOTES_BRANCH --fill"
|
||||
echo '```'
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
fi
|
||||
fi
|
||||
@@ -74,14 +74,32 @@ jobs:
|
||||
node scripts/verify-release-artifact-archive.mjs "$RUNNER_TEMP/payload-artifact.json" "$RUNNER_TEMP/payload.zip"
|
||||
mkdir payload
|
||||
unzip -q "$RUNNER_TEMP/payload.zip" -d payload
|
||||
# One provenance check per payload file; each is a network round trip,
|
||||
# so they run eight at a time and any single failure fails the step.
|
||||
signer_digest="$CONTROL_SHA"
|
||||
find payload -type f -print0 | xargs -0 -P 8 -I{} \
|
||||
gh attestation verify {} --repo "$GITHUB_REPOSITORY" \
|
||||
--signer-workflow "$GITHUB_REPOSITORY/.github/workflows/release-candidate.yml" \
|
||||
--signer-digest "$signer_digest" --source-ref refs/heads/main-v2 \
|
||||
--deny-self-hosted-runners >/dev/null
|
||||
# Each check retries on its own, because the public-good verifier can fail
|
||||
# to initialise on a shared runner; the first runs alone so the others
|
||||
# find the trust cache built, then the rest run eight at a time and any
|
||||
# single failure fails the step.
|
||||
verify_provenance() {
|
||||
local attempt=1
|
||||
until gh attestation verify "$1" --repo "$GITHUB_REPOSITORY" \
|
||||
--signer-workflow "$GITHUB_REPOSITORY/.github/workflows/release-candidate.yml" \
|
||||
--signer-digest "$signer_digest" --source-ref refs/heads/main-v2 \
|
||||
--deny-self-hosted-runners >/dev/null; do
|
||||
test "$attempt" -lt 5 || return 1
|
||||
attempt=$((attempt + 1))
|
||||
sleep 6
|
||||
done
|
||||
}
|
||||
export -f verify_provenance
|
||||
export signer_digest
|
||||
files=()
|
||||
while IFS= read -r -d '' file; do files+=("$file"); done < <(find payload -type f -print0)
|
||||
test "${#files[@]}" -gt 0
|
||||
verify_provenance "${files[0]}"
|
||||
if [ "${#files[@]}" -gt 1 ]; then
|
||||
# shellcheck disable=SC2016 # the child shell must expand "$1" itself
|
||||
printf '%s\0' "${files[@]:1}" | xargs -0 -P 8 -I{} bash -c 'verify_provenance "$1"' _ {}
|
||||
fi
|
||||
node scripts/release-candidate.mjs verify-rehearsal payload record/record.json >/dev/null
|
||||
git fetch origin main-v2
|
||||
git merge-base --is-ancestor "${{ steps.inspected.outputs.source_sha }}" origin/main-v2
|
||||
|
||||
@@ -194,6 +194,8 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
issues: read # release-notes credits read PR and issue authors
|
||||
pull-requests: read
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
@@ -210,6 +212,7 @@ jobs:
|
||||
env:
|
||||
DOCS_BUILD_VERSION: v${{ needs.resolve.outputs.version }}
|
||||
DOCS_SOURCE_REVISION: ${{ github.sha }}
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
node scripts/release-notes.mjs validate
|
||||
node scripts/release-notes.mjs render --version "$DOCS_BUILD_VERSION" --output /tmp/release-notes.md
|
||||
@@ -259,6 +262,8 @@ jobs:
|
||||
permissions:
|
||||
actions: write
|
||||
contents: write
|
||||
issues: read # release-notes credits read PR and issue authors
|
||||
pull-requests: read
|
||||
uses: ./.github/workflows/release-desktop.yml
|
||||
with:
|
||||
channel: stable
|
||||
@@ -285,6 +290,8 @@ jobs:
|
||||
attestations: write
|
||||
contents: read
|
||||
id-token: write
|
||||
issues: read # release-notes credits read PR and issue authors
|
||||
pull-requests: read
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
@@ -338,6 +345,16 @@ jobs:
|
||||
test -s payload/evidence/windows-amd64.json
|
||||
test -s payload/evidence/windows-arm64.json
|
||||
test -s payload/evidence/macos-universal-intel.json
|
||||
# Rendered once and sealed: promotion publishes these bytes and never
|
||||
# reads GitHub again, so a later rename or outage cannot change them.
|
||||
- name: Render reviewed notes into the sealed payload
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
worktree="$(mktemp -d "$RUNNER_TEMP/reasonix-notes.XXXXXX")"
|
||||
git worktree add --detach "$worktree" "${{ github.sha }}"
|
||||
node "$worktree/scripts/release-notes.mjs" render --version "v${{ needs.resolve.outputs.version }}" --output payload/evidence/release-notes.md
|
||||
git worktree remove "$worktree"
|
||||
- uses: actions/attest-build-provenance@977bb373ede98d70efdf65b84cb5f73e068dcc2a # v3
|
||||
with:
|
||||
subject-path: payload/**/*
|
||||
@@ -359,13 +376,6 @@ jobs:
|
||||
if-no-files-found: error
|
||||
overwrite: true
|
||||
retention-days: 90
|
||||
- name: Render reviewed notes from the immutable product
|
||||
run: |
|
||||
git show "${{ github.sha }}:release-notes/releases.json" > /tmp/releases.json
|
||||
worktree="$(mktemp -d "$RUNNER_TEMP/reasonix-notes.XXXXXX")"
|
||||
git worktree add --detach "$worktree" "${{ github.sha }}"
|
||||
node "$worktree/scripts/release-notes.mjs" render --version "v${{ needs.resolve.outputs.version }}" --output /tmp/release-notes.md
|
||||
git worktree remove "$worktree"
|
||||
- name: Seal candidate record
|
||||
env:
|
||||
RELEASE_VERSION: ${{ needs.resolve.outputs.version }}
|
||||
@@ -385,7 +395,7 @@ jobs:
|
||||
RELEASE_ACCEPTANCE_JSON: >-
|
||||
[{"kind":"windows-amd64","status":"passed","evidencePath":"evidence/windows-amd64.json"},{"kind":"windows-arm64","status":"passed","evidencePath":"evidence/windows-arm64.json"},{"kind":"macos-universal-intel","status":"passed","evidencePath":"evidence/macos-universal-intel.json"}]
|
||||
run: |
|
||||
RELEASE_RENDERED_NOTES_SHA256="$(sha256sum /tmp/release-notes.md | awk '{print $1}')"
|
||||
RELEASE_RENDERED_NOTES_SHA256="$(sha256sum payload/evidence/release-notes.md | awk '{print $1}')"
|
||||
RELEASE_CREATED_AT="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||
RELEASE_EXPIRES_AT="$(date -u -d '+30 days' +%Y-%m-%dT%H:%M:%SZ)"
|
||||
export RELEASE_RENDERED_NOTES_SHA256 RELEASE_CREATED_AT RELEASE_EXPIRES_AT
|
||||
|
||||
@@ -8,8 +8,8 @@ name: Release desktop
|
||||
# build/ fans out to one native runner per platform. Artifacts are
|
||||
# minisign-signed (MINISIGN_* secrets), a latest.json manifest is generated, and
|
||||
# everything is published to a GitHub release and mirrored to R2 (the updater
|
||||
# reads R2 first, then the crash worker release gateway; stable desktop releases
|
||||
# own GitHub's repository-wide "latest").
|
||||
# reads R2 first, then the crash worker release gateway; no desktop release
|
||||
# claims GitHub's repository-wide "latest").
|
||||
#
|
||||
# Historical workflow-call inputs still understand Preview artifacts so old
|
||||
# runs remain diagnosable. They are not exposed by manual dispatch and cannot
|
||||
@@ -1084,6 +1084,8 @@ jobs:
|
||||
permissions:
|
||||
contents: write
|
||||
actions: read
|
||||
issues: read # release-notes credits read PR and issue authors
|
||||
pull-requests: read
|
||||
# Approved orchestrators have already passed the matching GitHub environment.
|
||||
# Direct prereleases and manual Stable recovery pass release-gate above. The
|
||||
# Certum signing completes before either platform bundle reaches publication.
|
||||
@@ -1200,6 +1202,8 @@ jobs:
|
||||
# GitHub release as usual.
|
||||
- name: Render reviewed release notes
|
||||
if: ${{ !inputs.orchestrated && needs.resolve.outputs.channel != 'preview' }}
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: node scripts/release-notes.mjs render --version "${{ needs.resolve.outputs.notes_version }}" --output /tmp/release-notes.md
|
||||
|
||||
- name: Revalidate approved release ref
|
||||
@@ -1233,8 +1237,8 @@ jobs:
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
DESKTOP_MANUAL_ONLY: ${{ inputs.desktop_manual_only || false }}
|
||||
# Keep the repository homepage focused on the installable desktop app;
|
||||
# the CLI release line is configured not to claim repository-wide latest.
|
||||
# Desktop releases never claim repository-wide latest; the updater
|
||||
# reads R2 and the release gateway, not GitHub's latest shortcut.
|
||||
run: >-
|
||||
bash release-control/scripts/publish-desktop-github-release.sh
|
||||
"${{ needs.resolve.outputs.tag }}"
|
||||
@@ -1292,7 +1296,6 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write # gh release download + compatibility manifest upload
|
||||
actions: write # dispatch pages.yml to re-bake the site version
|
||||
# Stable keeps GitHub as a fallback when R2 is unavailable. Preview has no
|
||||
# GitHub release, so it must fail closed before attempting publication.
|
||||
if: ${{ always() && !cancelled() && needs.resolve.result == 'success' && needs.publish.result == 'success' && github.repository_owner == 'esengine' }}
|
||||
@@ -1496,6 +1499,12 @@ jobs:
|
||||
if [ -n "$existing_keys" ] && [ "$existing_keys" != "None" ]; then
|
||||
aws s3 cp "s3://${R2_BUCKET}/${TAG}/" "$existing_directory/" \
|
||||
--recursive --endpoint-url "$ENDPOINT"
|
||||
# A signature whose payload never finished uploading is an unfinished
|
||||
# write, not published content: the candidate's pair fills it below.
|
||||
# A signature that has its payload is never touched.
|
||||
while IFS= read -r -d '' signature; do
|
||||
[ -f "${signature%.minisig}" ] || rm -f -- "$signature"
|
||||
done < <(find "$existing_directory" -type f -name '*.minisig' -print0)
|
||||
verify_signature_directory "$existing_directory"
|
||||
if [ -f "$existing_directory/latest.json" ]; then
|
||||
existing_manifest=true
|
||||
@@ -1818,11 +1827,3 @@ jobs:
|
||||
else
|
||||
echo "CLI release $VERSION does not exist yet; release.yml will attach the compatibility latest.json when it publishes."
|
||||
fi
|
||||
|
||||
# Stable release moved R2 latest/ — rebuild the site so its build-time baked
|
||||
# version + JSON-LD follow (site.js's runtime .rxv refresh can't touch first paint / SEO).
|
||||
- name: Refresh site to the new version
|
||||
if: env.HAS_R2 == 'true' && needs.resolve.outputs.channel != 'preview' && needs.resolve.outputs.prerelease != 'true' && steps.mirror_r2.outputs.pointer_moved == 'true'
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: gh workflow run pages.yml --ref main-v2
|
||||
@@ -4,16 +4,9 @@ on:
|
||||
pull_request:
|
||||
paths:
|
||||
- "release-notes/**"
|
||||
- "scripts/generate-release-notes.mjs"
|
||||
- "scripts/release-notes*.mjs"
|
||||
- "scripts/release-credits*.mjs"
|
||||
- "scripts/release-event.mjs"
|
||||
- "site/src/pages/changelog/**"
|
||||
- "site/src/components/ChangelogPage.astro"
|
||||
- "site/src/data/releases.ts"
|
||||
- "site/src/styles/changelog.css"
|
||||
- "desktop/frontend/src/App.tsx"
|
||||
- "desktop/frontend/src/components/SettingsPanel.tsx"
|
||||
- "desktop/frontend/src/components/UpdateBanner.tsx"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -27,4 +20,4 @@ jobs:
|
||||
with:
|
||||
node-version: "22"
|
||||
- run: node scripts/release-notes.mjs validate
|
||||
- run: node --test scripts/release-notes.test.mjs
|
||||
- run: node --test scripts/release-notes.test.mjs scripts/release-credits.test.mjs
|
||||
@@ -221,6 +221,7 @@ jobs:
|
||||
if: ${{ inputs.candidate_artifact_name == '' }}
|
||||
env:
|
||||
VERSION_ARG: ${{ steps.ver.outputs.arg }}
|
||||
NPM_STABLE_DIST_TAG: ${{ vars.CLI_PUBLISH_FROZEN == 'true' && 'legacy-v1' || 'latest' }}
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
run: node npm/build.mjs "$VERSION_ARG" --publish
|
||||
- name: Publish or recover prepared npm packages
|
||||
@@ -228,10 +229,11 @@ jobs:
|
||||
env:
|
||||
VERSION: ${{ inputs.base_version }}
|
||||
APPROVED_SHA: ${{ inputs.approved_sha }}
|
||||
NPM_STABLE_DIST_TAG: ${{ vars.CLI_PUBLISH_FROZEN == 'true' && 'legacy-v1' || 'latest' }}
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
run: node npm/publish-candidate.mjs "$RUNNER_TEMP/release-candidate/npm" "$VERSION" "$APPROVED_SHA"
|
||||
- name: Align legacy aliases with the official release
|
||||
if: ${{ inputs.channel == 'stable' }}
|
||||
if: ${{ inputs.channel == 'stable' && vars.CLI_PUBLISH_FROZEN != 'true' }}
|
||||
env:
|
||||
VERSION: ${{ inputs.base_version }}
|
||||
APPROVED_SHA: ${{ inputs.approved_sha }}
|
||||
|
||||
@@ -23,6 +23,8 @@ permissions:
|
||||
actions: write
|
||||
attestations: read
|
||||
contents: write
|
||||
issues: read # granted to the nested release workflows, which can render notes
|
||||
pull-requests: read
|
||||
|
||||
jobs:
|
||||
preflight:
|
||||
@@ -120,14 +122,32 @@ jobs:
|
||||
SOURCE_SHA: ${{ steps.record.outputs.source_sha }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# One provenance check per payload file; each is a network round trip,
|
||||
# so they run eight at a time and any single failure fails the step.
|
||||
signer_digest="${{ steps.record.outputs.candidate_control_sha }}"
|
||||
find payload -type f -print0 | xargs -0 -P 8 -I{} \
|
||||
gh attestation verify {} --repo "$GITHUB_REPOSITORY" \
|
||||
--signer-workflow "$GITHUB_REPOSITORY/.github/workflows/release-candidate.yml" \
|
||||
--signer-digest "$signer_digest" --source-ref refs/heads/main-v2 \
|
||||
--deny-self-hosted-runners >/dev/null
|
||||
# Each check retries on its own, because the public-good verifier can fail
|
||||
# to initialise on a shared runner; the first runs alone so the others
|
||||
# find the trust cache built, then the rest run eight at a time and any
|
||||
# single failure fails the step.
|
||||
verify_provenance() {
|
||||
local attempt=1
|
||||
until gh attestation verify "$1" --repo "$GITHUB_REPOSITORY" \
|
||||
--signer-workflow "$GITHUB_REPOSITORY/.github/workflows/release-candidate.yml" \
|
||||
--signer-digest "$signer_digest" --source-ref refs/heads/main-v2 \
|
||||
--deny-self-hosted-runners >/dev/null; do
|
||||
test "$attempt" -lt 5 || return 1
|
||||
attempt=$((attempt + 1))
|
||||
sleep 6
|
||||
done
|
||||
}
|
||||
export -f verify_provenance
|
||||
export signer_digest
|
||||
files=()
|
||||
while IFS= read -r -d '' file; do files+=("$file"); done < <(find payload -type f -print0)
|
||||
test "${#files[@]}" -gt 0
|
||||
verify_provenance "${files[0]}"
|
||||
if [ "${#files[@]}" -gt 1 ]; then
|
||||
# shellcheck disable=SC2016 # the child shell must expand "$1" itself
|
||||
printf '%s\0' "${files[@]:1}" | xargs -0 -P 8 -I{} bash -c 'verify_provenance "$1"' _ {}
|
||||
fi
|
||||
node scripts/release-candidate.mjs verify payload record/record.json >/dev/null
|
||||
git fetch origin main-v2 --tags
|
||||
git merge-base --is-ancestor "$SOURCE_SHA" origin/main-v2
|
||||
@@ -146,12 +166,19 @@ jobs:
|
||||
echo "- Payload files: \`$(jq '.files | length' record/record.json)\`"
|
||||
echo "- Native acceptance: \`$(jq -r '[.acceptance[] | select(.status == "passed") | .kind] | join(", ")' record/record.json)\`"
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
- name: Render and bind reviewed release notes
|
||||
# A candidate sealed with its notes publishes those bytes without touching
|
||||
# the network. One sealed before that renders offline from its own source.
|
||||
- name: Bind reviewed release notes
|
||||
run: |
|
||||
worktree="$(mktemp -d "$RUNNER_TEMP/reasonix-notes.XXXXXX")"
|
||||
git worktree add --detach "$worktree" "${{ steps.record.outputs.source_sha }}"
|
||||
node "$worktree/scripts/release-notes.mjs" render --version "v${{ steps.record.outputs.version }}" --output release-notes.md
|
||||
git worktree remove "$worktree"
|
||||
sealed="$(jq -r '.notes.renderedPath // empty' record/record.json)"
|
||||
if [ -n "$sealed" ]; then
|
||||
cp "payload/$sealed" release-notes.md
|
||||
else
|
||||
worktree="$(mktemp -d "$RUNNER_TEMP/reasonix-notes.XXXXXX")"
|
||||
git worktree add --detach "$worktree" "${{ steps.record.outputs.source_sha }}"
|
||||
node "$worktree/scripts/release-notes.mjs" render --version "v${{ steps.record.outputs.version }}" --output release-notes.md
|
||||
git worktree remove "$worktree"
|
||||
fi
|
||||
test "$(sha256sum release-notes.md | awk '{print $1}')" = "$(jq -r .notes.renderedSha256 record/record.json)"
|
||||
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
with:
|
||||
@@ -306,7 +333,6 @@ jobs:
|
||||
if: ${{ always() && !cancelled() }}
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
actions: write
|
||||
contents: write
|
||||
steps:
|
||||
- name: Require every publisher
|
||||
@@ -326,7 +352,7 @@ jobs:
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
cache: false
|
||||
- name: Reconcile public artifacts, owned Pages deployment, and durable ledger
|
||||
- name: Reconcile public artifacts, release event, and durable ledger
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
RELEASE_REPOSITORY: ${{ github.repository }}
|
||||
@@ -334,7 +360,8 @@ jobs:
|
||||
RELEASE_OPERATION: ${{ inputs.operation }}
|
||||
RELEASE_EXPECTED_SHA: ${{ needs.preflight.outputs.source_sha }}
|
||||
RELEASE_LEDGER_OUTPUT: /tmp/publication-ledger.json
|
||||
run: bash scripts/sync-release-site.sh
|
||||
CLI_PUBLISH_FROZEN: ${{ vars.CLI_PUBLISH_FROZEN == 'true' }}
|
||||
run: bash scripts/reconcile-release-publication.sh
|
||||
- name: Upload publication ledger
|
||||
if: always()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
|
||||
@@ -1,111 +0,0 @@
|
||||
name: Recover release site
|
||||
run-name: Recover site v${{ inputs.version }}
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: "Already-published stable version; no builds or package publication"
|
||||
required: true
|
||||
type: string
|
||||
|
||||
concurrency:
|
||||
group: stable-release-publication
|
||||
cancel-in-progress: false
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
preflight:
|
||||
if: github.repository == 'esengine/DeepSeek-Reasonix' && github.ref == 'refs/heads/main-v2' && github.ref_protected
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
outputs:
|
||||
source_sha: ${{ steps.identity.outputs.source_sha }}
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
ref: ${{ github.sha }}
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
with:
|
||||
node-version: "22"
|
||||
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
cache: false
|
||||
- name: Validate executable contracts and public access before approval
|
||||
env:
|
||||
VERSION: ${{ inputs.version }}
|
||||
run: |
|
||||
bash scripts/validate-release-control-plane.sh
|
||||
bash scripts/check-release-public-access.sh "$VERSION"
|
||||
- name: Bind existing public tags to protected history
|
||||
id: identity
|
||||
env:
|
||||
VERSION: ${{ inputs.version }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
[[ "$VERSION" =~ ^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]]
|
||||
source_sha="$(git rev-parse "refs/tags/v$VERSION^{commit}")"
|
||||
git merge-base --is-ancestor "$source_sha" origin/main-v2
|
||||
bash scripts/release-candidate-tags.sh check recover "$VERSION" "$source_sha"
|
||||
echo "source_sha=$source_sha" >> "$GITHUB_OUTPUT"
|
||||
- name: Re-read public package identities before approval
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
RELEASE_REPOSITORY: ${{ github.repository }}
|
||||
RELEASE_VERSION: ${{ inputs.version }}
|
||||
RELEASE_OPERATION: recover
|
||||
RELEASE_EXPECTED_SHA: ${{ steps.identity.outputs.source_sha }}
|
||||
CLI_TAG: v${{ inputs.version }}
|
||||
DESKTOP_TAG: desktop-v${{ inputs.version }}
|
||||
RELEASE_LEDGER_OUTPUT: ${{ runner.temp }}/site-recovery-preflight.json
|
||||
run: bash scripts/verify-stable-release-artifacts.sh
|
||||
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
with:
|
||||
name: site-recovery-preflight-${{ inputs.version }}-${{ github.run_attempt }}
|
||||
path: ${{ runner.temp }}/site-recovery-preflight.json
|
||||
retention-days: 90
|
||||
if-no-files-found: error
|
||||
|
||||
sync:
|
||||
needs: preflight
|
||||
environment: release
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 45
|
||||
permissions:
|
||||
actions: write
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
ref: ${{ github.sha }}
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
with:
|
||||
node-version: "22"
|
||||
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
cache: false
|
||||
- name: Recover only the website and verify public state again
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
RELEASE_REPOSITORY: ${{ github.repository }}
|
||||
RELEASE_VERSION: ${{ inputs.version }}
|
||||
RELEASE_OPERATION: recover
|
||||
RELEASE_EXPECTED_SHA: ${{ needs.preflight.outputs.source_sha }}
|
||||
RELEASE_SITE_RECOVERY_ONLY: "true"
|
||||
RELEASE_LEDGER_OUTPUT: ${{ runner.temp }}/publication-ledger.json
|
||||
run: bash scripts/sync-release-site.sh
|
||||
- name: Preserve successful or incomplete recovery evidence
|
||||
if: always()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
with:
|
||||
name: release-site-recovery-ledger-${{ inputs.version }}-${{ github.run_attempt }}
|
||||
path: ${{ runner.temp }}/publication-ledger.json
|
||||
retention-days: 90
|
||||
if-no-files-found: error
|
||||
@@ -55,6 +55,8 @@ concurrency:
|
||||
permissions:
|
||||
actions: write
|
||||
contents: write
|
||||
issues: read # release-notes credits read PR and issue authors
|
||||
pull-requests: read
|
||||
|
||||
jobs:
|
||||
preflight:
|
||||
@@ -63,6 +65,8 @@ jobs:
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
issues: read # release-notes credits read PR and issue authors
|
||||
pull-requests: read
|
||||
outputs:
|
||||
version: ${{ steps.release.outputs.version }}
|
||||
cli_tag: ${{ steps.release.outputs.cli_tag }}
|
||||
@@ -111,6 +115,8 @@ jobs:
|
||||
bash scripts/validate-stable-candidate.sh "$RELEASE_VERSION" "$RELEASE_SHA"
|
||||
bash scripts/verify-release-push-ci.sh "$RELEASE_SHA"
|
||||
- name: Validate reviewed release notes
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: node scripts/release-notes.mjs render --version "${{ steps.release.outputs.cli_tag }}" --output /tmp/release-notes.md
|
||||
# Recovery builds deliberately check out the immutable tagged candidate,
|
||||
# which can predate its reviewed release-note entry. Carry the exact file
|
||||
@@ -226,7 +232,6 @@ jobs:
|
||||
if: ${{ always() && !cancelled() }}
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
actions: write
|
||||
contents: write
|
||||
steps:
|
||||
- name: Require every publisher to succeed
|
||||
@@ -273,6 +278,7 @@ jobs:
|
||||
CLI_TAG: ${{ needs.authorize.outputs.cli_tag }}
|
||||
DESKTOP_TAG: ${{ needs.authorize.outputs.desktop_tag }}
|
||||
DESKTOP_MANUAL_ONLY: ${{ inputs.desktop_manual_only || false }}
|
||||
CLI_PUBLISH_FROZEN: ${{ vars.CLI_PUBLISH_FROZEN == 'true' }}
|
||||
run: bash scripts/verify-stable-release-artifacts.sh
|
||||
- name: Publish exact Stable release record
|
||||
env:
|
||||
@@ -303,7 +309,3 @@ jobs:
|
||||
else
|
||||
gh release upload "$CLI_TAG" /tmp/release-event.json
|
||||
fi
|
||||
- name: Refresh public changelog
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: gh workflow run pages.yml --ref main-v2
|
||||
@@ -35,5 +35,6 @@ jobs:
|
||||
RELEASE_OPERATION: recover
|
||||
CLI_TAG: v${{ inputs.version }}
|
||||
DESKTOP_TAG: desktop-v${{ inputs.version }}
|
||||
VERIFY_HOMEPAGE: "true"
|
||||
VERIFY_PUBLIC_POINTERS: "true"
|
||||
CLI_PUBLISH_FROZEN: ${{ vars.CLI_PUBLISH_FROZEN == 'true' }}
|
||||
run: bash scripts/verify-stable-release-artifacts.sh
|
||||
@@ -212,6 +212,10 @@ jobs:
|
||||
needs: [resolve, cache-guard]
|
||||
if: ${{ always() && !cancelled() && needs.cache-guard.result == 'success' }}
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
issues: read # release-notes credits read PR and issue authors
|
||||
pull-requests: read
|
||||
# The Stable caller has already passed the single GitHub release approval.
|
||||
# Official standalone recovery passes release-gate above. This job therefore
|
||||
# must not add a second GitHub environment approval.
|
||||
@@ -274,6 +278,7 @@ jobs:
|
||||
if: ${{ !inputs.orchestrated }}
|
||||
env:
|
||||
RELEASE_TAG: ${{ needs.resolve.outputs.notes_version }}
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: node scripts/release-notes.mjs render --version "$RELEASE_TAG" --output /tmp/release-notes.md
|
||||
- name: Revalidate approved release ref
|
||||
env:
|
||||
@@ -328,10 +333,10 @@ jobs:
|
||||
if: ${{ steps.publication.outputs.decision == 'publish' && inputs.candidate_artifact_name == '' }}
|
||||
with:
|
||||
version: '~> v2'
|
||||
args: release --clean
|
||||
args: release --clean${{ vars.CLI_PUBLISH_FROZEN == 'true' && ' --skip=homebrew' || '' }}
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
HOMEBREW_TAP_TOKEN: ${{ needs.resolve.outputs.channel == 'stable' && secrets.HOMEBREW_TAP_TOKEN || '' }}
|
||||
HOMEBREW_TAP_TOKEN: ${{ needs.resolve.outputs.channel == 'stable' && vars.CLI_PUBLISH_FROZEN != 'true' && secrets.HOMEBREW_TAP_TOKEN || '' }}
|
||||
# workflow_dispatch recovery runs have a branch-shaped GITHUB_REF even
|
||||
# though checkout is on the release tag. Pin GoReleaser explicitly, and
|
||||
# avoid ambiguity from the three release tags sharing one commit.
|
||||
@@ -354,7 +359,7 @@ jobs:
|
||||
--title "Reasonix CLI $TAG" --notes-file /tmp/release-notes.md --latest=false
|
||||
|
||||
- name: Publish prepared Homebrew cask
|
||||
if: ${{ inputs.candidate_artifact_name != '' && needs.resolve.outputs.channel == 'stable' }}
|
||||
if: ${{ inputs.candidate_artifact_name != '' && needs.resolve.outputs.channel == 'stable' && vars.CLI_PUBLISH_FROZEN != 'true' }}
|
||||
env:
|
||||
HOMEBREW_TAP_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }}
|
||||
run: node release-control/scripts/publish-homebrew-cask.mjs "$RUNNER_TEMP/release-candidate/cli/reasonix.rb"
|
||||
@@ -370,6 +375,7 @@ jobs:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
TAG: ${{ needs.resolve.outputs.tag }}
|
||||
NOTES_TAG: ${{ needs.resolve.outputs.notes_version }}
|
||||
CLI_PUBLISH_FROZEN: ${{ vars.CLI_PUBLISH_FROZEN == 'true' }}
|
||||
HAS_R2: ${{ secrets.R2_ACCESS_KEY_ID != '' && secrets.R2_SECRET_ACCESS_KEY != '' && secrets.R2_ACCOUNT_ID != '' && secrets.R2_BUCKET != '' }}
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
|
||||
@@ -473,6 +479,11 @@ jobs:
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ "${CLI_PUBLISH_FROZEN:-}" = "true" ]; then
|
||||
echo "CLI publication is frozen; the $channel pointer stays where it is and $TAG keeps only its immutable record"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
current_tag=""
|
||||
pointer_error="$(mktemp)"
|
||||
if aws s3 cp "s3://${R2_BUCKET}/cli/${channel}/latest.json" /tmp/cli-release.pointer.json \
|
||||
@@ -548,17 +559,14 @@ jobs:
|
||||
' latest.json >/dev/null
|
||||
gh release upload "$TAG" latest.json --clobber
|
||||
|
||||
# The compatibility asset exists for pre-v1.16 desktop updaters that
|
||||
# still poll GitHub's repository-wide latest URL. Desktop releases now
|
||||
# own that Latest badge, but this check still exercises the public fallback
|
||||
# path exactly the way those clients fetch it: anonymously, over the public
|
||||
# edge, with a Go client UA. Unlike dl.reasonix.io (whose bot protection
|
||||
# 403s Actions egress — see the R2 note above), GitHub serves its own
|
||||
# runners, so this can hard-fail. #5826/#5858 shipped a broken update check
|
||||
# for weeks precisely because nothing exercised the public path. Retries
|
||||
# cover the release CDN propagating the freshly uploaded asset.
|
||||
- name: Smoke public compatibility manifest
|
||||
# Verifies what the CLI release actually serves after the compatibility
|
||||
# upload: exactly the approved asset set, each uploaded, with the size and
|
||||
# sha256 digest GitHub recorded, SHA256SUMS downloaded from the release,
|
||||
# and the compatibility latest.json byte-identical to the one uploaded
|
||||
# above. Any mismatch or API failure fails the job.
|
||||
- name: Verify published CLI release assets
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
TAG: ${{ needs.resolve.outputs.tag }}
|
||||
HAS_R2: ${{ secrets.R2_ACCESS_KEY_ID != '' && secrets.R2_SECRET_ACCESS_KEY != '' && secrets.R2_ACCOUNT_ID != '' && secrets.R2_BUCKET != '' }}
|
||||
run: |
|
||||
@@ -573,18 +581,19 @@ jobs:
|
||||
echo "R2 secrets not configured; no compatibility asset uploaded; skipping"
|
||||
exit 0
|
||||
fi
|
||||
url="https://github.com/${{ github.repository }}/releases/latest/download/latest.json"
|
||||
for attempt in 1 2 3 4 5 6; do
|
||||
if curl -fsSL -A "Go-http-client/2.0" -o /tmp/compat-latest.json "$url"; then
|
||||
jq -e '(.version | type == "string") and (.platforms | type == "object")' /tmp/compat-latest.json >/dev/null
|
||||
echo "public compatibility manifest OK (desktop version $(jq -r .version /tmp/compat-latest.json))"
|
||||
exit 0
|
||||
fi
|
||||
echo "attempt $attempt failed; retrying in 10s"
|
||||
sleep 10
|
||||
done
|
||||
echo "::error::public compatibility manifest unreachable at $url"
|
||||
exit 1
|
||||
release_json=/tmp/published-cli-release.json
|
||||
checksums=/tmp/published-cli-SHA256SUMS
|
||||
gh api "repos/${{ github.repository }}/releases/tags/$TAG" >"$release_json"
|
||||
gh release download "$TAG" -R "${{ github.repository }}" \
|
||||
--pattern SHA256SUMS --output "$checksums" --clobber
|
||||
decision="$(bash release-control/scripts/decide-cli-release-publication.sh \
|
||||
stable "$TAG" "${{ github.repository }}" "$release_json" "$checksums")"
|
||||
test "$decision" = "reuse"
|
||||
jq -e '[.assets[].name] | index("latest.json") != null' "$release_json" >/dev/null
|
||||
want="sha256:$(sha256sum latest.json | cut -d' ' -f1)"
|
||||
got="$(jq -r '.assets[] | select(.name == "latest.json") | .digest' "$release_json")"
|
||||
test "$want" = "$got"
|
||||
echo "published CLI release $TAG assets verified against SHA256SUMS and the uploaded manifest"
|
||||
|
||||
# A stable CLI release must never leave the npm line behind: v1.17.5
|
||||
# shipped as binaries/Homebrew while npm `latest` still pointed at 0.53.2
|
||||
@@ -607,6 +616,7 @@ jobs:
|
||||
- name: Check npm latest dist-tag freshness
|
||||
env:
|
||||
TAG: ${{ needs.resolve.outputs.tag }}
|
||||
CLI_PUBLISH_FROZEN: ${{ vars.CLI_PUBLISH_FROZEN == 'true' }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
case "$TAG" in
|
||||
@@ -620,6 +630,10 @@ jobs:
|
||||
echo "::error::the npm-v$version tag was never pushed — the npm channel is being left behind and 'npm update -g' users will be downgraded to the old 'latest'. Push it: git tag npm-v$version ${TAG} && git push origin npm-v$version (or 'npm dist-tag add reasonix@$version latest' for an already-published version)."
|
||||
exit 1
|
||||
fi
|
||||
if [ "${CLI_PUBLISH_FROZEN:-}" = "true" ]; then
|
||||
echo "CLI publication is frozen; npm latest belongs to another line, only the npm-v$version tag is required"
|
||||
exit 0
|
||||
fi
|
||||
for attempt in 1 2 3 4 5 6; do
|
||||
got="$(npm view reasonix dist-tags.latest 2>/dev/null || true)"
|
||||
if [ -n "$got" ]; then
|
||||
|
||||
@@ -64,6 +64,12 @@ linters:
|
||||
linters:
|
||||
- staticcheck
|
||||
text: SA5011
|
||||
# x/net's Transport still returns GoAwayError although the type is marked
|
||||
# deprecated, so the classifier has to keep recognising it.
|
||||
- path: internal/provider/transport_diagnostic
|
||||
linters:
|
||||
- staticcheck
|
||||
text: SA1019
|
||||
# SA4023 is trivially true on GOOS=windows: dialAgent's Windows stub
|
||||
# (agent_windows.go) always returns a non-nil error until named-pipe
|
||||
# transport lands. Suppress only this call site; the comparison stays
|
||||
|
||||
@@ -24,6 +24,12 @@ branch.
|
||||
|
||||
### Added
|
||||
|
||||
- **Discover Reasonix Studio (Desktop 1.x):** a one-time introduction on an idle
|
||||
stable desktop launch, plus a permanent entry in **Settings → About**. Opens
|
||||
the official Studio download page and explains coexistence and data limits;
|
||||
it does not install Studio or change the 1.x updater. See
|
||||
[the introduction guide](docs/STUDIO_DISCOVERY.md).
|
||||
|
||||
- **Editable message queue (Desktop):** full-text editing in the original queue row,
|
||||
pointer and keyboard reordering, move-to-top/bottom actions, and a pause
|
||||
control. Saves preserve message identity, order and attachments; conflicting
|
||||
|
||||
@@ -52,14 +52,16 @@ lint-cross:
|
||||
test:
|
||||
go test ./...
|
||||
|
||||
# The desktop suite needs ~25m locally, past go's default 10m test alarm
|
||||
# (the TaggedHistory migrations alone are ~389s), so these targets pin a wider one.
|
||||
desktop-test:
|
||||
cd desktop && go test .
|
||||
cd desktop && go test -timeout=25m .
|
||||
|
||||
desktop-test-short:
|
||||
cd desktop && go test -short .
|
||||
cd desktop && go test -short -timeout=25m .
|
||||
|
||||
desktop-test-times:
|
||||
cd desktop && go test -count=1 -json . | python3 ../scripts/desktop-test-times.py
|
||||
cd desktop && go test -count=1 -timeout=25m -json . | python3 ../scripts/desktop-test-times.py
|
||||
|
||||
sdk-test:
|
||||
cd sdk/go && go test ./...
|
||||
|
||||
@@ -100,8 +100,7 @@ for the latest desktop build.
|
||||
| Windows | Installer `.exe` or portable `.zip` | x64 / ARM64 |
|
||||
| Linux | `.deb` or `.tar.gz` | x64 |
|
||||
|
||||
Windows installers are code-signed through [SignPath.io](https://signpath.io/)
|
||||
with a free certificate provided by the [SignPath Foundation](https://signpath.org/).
|
||||
Windows installers are code-signed with a Certum code-signing certificate.
|
||||
|
||||
### Path C: VS Code extension
|
||||
|
||||
|
||||
+1
-2
@@ -92,8 +92,7 @@ brew install esengine/reasonix/reasonix # macOS
|
||||
| Windows | 安装器 `.exe` 或便携 `.zip` | x64 / ARM64 |
|
||||
| Linux | `.deb` 或 `.tar.gz` | x64 |
|
||||
|
||||
Windows 安装器通过 [SignPath.io](https://signpath.io/) 完成代码签名,证书由
|
||||
[SignPath 基金会](https://signpath.org/) 免费提供。
|
||||
Windows 安装器使用 Certum 代码签名证书签名。
|
||||
|
||||
### 路径 C:VS Code 扩展
|
||||
|
||||
|
||||
+53
-6
@@ -56,9 +56,9 @@ Supported boundaries include:
|
||||
- Sandbox behavior for built-in shell execution where the platform supports it.
|
||||
- Secret handling for provider keys, bot credentials, OAuth tokens, plugin
|
||||
headers, and credential-store fallback files.
|
||||
- HTTP `serve` protections for the unauthenticated local server, including
|
||||
localhost binding assumptions, JSON-only state-changing requests, and CORS
|
||||
restrictions.
|
||||
- HTTP `serve` protections, including the launch token every state-changing
|
||||
request requires when authentication is off, localhost binding assumptions,
|
||||
JSON-only state-changing requests, and CORS restrictions.
|
||||
- Desktop and bot session isolation, including per-workspace session metadata
|
||||
and configured bot allowlists.
|
||||
- Updater, install, and release verification paths.
|
||||
@@ -67,10 +67,10 @@ The following are normally treated as trusted local/operator-controlled inputs
|
||||
unless another bug lets an untrusted actor supply them:
|
||||
|
||||
- CLI arguments and text typed directly by the local user.
|
||||
- Project configuration files intentionally loaded from the current workspace.
|
||||
- The user's own configuration under the Reasonix home directory.
|
||||
- Explicit `@path` references supplied by the local user to attach local files.
|
||||
- MCP servers, language servers, hooks, and slash commands installed or enabled
|
||||
by the local user.
|
||||
- MCP servers, language servers, hooks, and slash commands installed, enabled,
|
||||
or approved by the local user.
|
||||
- Provider base URLs and model names configured by the local user.
|
||||
|
||||
The following can be security issues when reachable by an untrusted actor or
|
||||
@@ -89,6 +89,53 @@ when they bypass the intended boundary:
|
||||
- Trusting unverified update artifacts, plugin definitions, or downloaded
|
||||
binaries.
|
||||
|
||||
## Project Configuration
|
||||
|
||||
Files that arrive with a workspace are untrusted input: `reasonix.toml`,
|
||||
`.reasonix/settings.json`, `.mcp.json` and the project `.env`. Cloning a
|
||||
repository is not a decision to trust its author, so these files may narrow
|
||||
what the user configured and never widen it.
|
||||
|
||||
| Setting | What a workspace file may do |
|
||||
| --- | --- |
|
||||
| `sandbox.bash` | Move `off` to `enforce`, never back |
|
||||
| `sandbox.network` | Move `true` to `false`, never back |
|
||||
| `sandbox.forbid_read` | Add to the user's list |
|
||||
| `sandbox.allow_write`, `sandbox.workspace_root` | Apply only when they resolve inside the workspace, symlinks followed; `${VAR}` in any sandbox path expands from the process environment only |
|
||||
| `permissions.deny`, `permissions.ask` | Add to the user's rules |
|
||||
| `permissions.mode`, `permissions.allow`, `permissions.allow_dynamic_bash` | Nothing |
|
||||
| `desktop.default_tool_approval_mode` | Nothing |
|
||||
| `[network]` proxy settings, `[bot]` | Nothing |
|
||||
| `[[providers]]` | Declare them; they stay out of use until approved, and model settings naming them fall back to the user's |
|
||||
|
||||
An "always allow" answer, or extra write access, granted in a workspace is
|
||||
stored under the Reasonix home, not in the workspace.
|
||||
|
||||
Programs a workspace names for the host to run stay off until the user
|
||||
approves them for that workspace:
|
||||
|
||||
- Hooks, language server commands, `tools.search.rg_path`,
|
||||
`tools.shell.path` and `[browser]` launch settings are approved with
|
||||
`reasonix trust`, or for hooks from the desktop hooks settings.
|
||||
- An approval is stored under the Reasonix home with a digest of the
|
||||
declaration and of the workspace files it names; any change needs approval
|
||||
again. Approvals and grants belong to one workspace folder.
|
||||
- Hooks and language servers are checked against their approval again before
|
||||
each start, and refused if a file they name changed, appeared or now
|
||||
resolves elsewhere.
|
||||
- A single program path (`tools.shell.path`, `tools.search.rg_path`,
|
||||
`browser.chrome_path`) may not point into the workspace, an `allow_write`
|
||||
root, a temporary directory or a toolchain cache the bash jail leaves
|
||||
writable, and may not climb with `..`; its approval covers the program
|
||||
file's content.
|
||||
- Without an interactive frontend they stay off.
|
||||
- MCP servers a workspace declares stay off until the user enables that
|
||||
declaration for that workspace.
|
||||
|
||||
A project value that is not applied is reported as a load warning with the
|
||||
reason. A way for workspace files to widen any of the above without the
|
||||
user's approval is a security issue.
|
||||
|
||||
## `@` File References
|
||||
|
||||
Reasonix supports `@path` references so users can include local files and images
|
||||
|
||||
@@ -20,6 +20,7 @@ func writeTaskVerify(t *testing.T, taskDir, script string) {
|
||||
}
|
||||
|
||||
func TestGradeCheckpointsFindsEarliestCorrectState(t *testing.T) {
|
||||
requireRealBash(t)
|
||||
taskDir := filepath.Join(t.TempDir(), "task")
|
||||
writeTaskVerify(t, taskDir, "#!/usr/bin/env bash\ngrep -q done answer.txt\n")
|
||||
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
const corpusDir = "../../benchmarks/e2e"
|
||||
@@ -56,6 +58,24 @@ func gradeSeed(t *testing.T, work string) error {
|
||||
return cmd.Run()
|
||||
}
|
||||
|
||||
// requireRealBash skips hosts whose bash cannot actually run commands. On
|
||||
// Windows exec.LookPath("bash") happily resolves to the System32 WSL launcher,
|
||||
// so a LookPath guard passes while every grade fails inside a distro that may
|
||||
// not even carry /bin/bash. The graders are POSIX fixtures; probing mirrors
|
||||
// what sandbox's own shell resolution does before trusting a bash. Timeout
|
||||
// bounded in case the stub blocks on an install prompt.
|
||||
func requireRealBash(t *testing.T) {
|
||||
t.Helper()
|
||||
if _, err := exec.LookPath("bash"); err != nil {
|
||||
t.Skip("bash unavailable; the graders are POSIX shell fixtures")
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
defer cancel()
|
||||
if err := exec.CommandContext(ctx, "bash", "-c", "true").Run(); err != nil {
|
||||
t.Skipf("bash cannot run commands (%v); the graders need a working POSIX shell", err)
|
||||
}
|
||||
}
|
||||
|
||||
// forbiddenArtifact names, per task, a file whose mere existence is the
|
||||
// documented way to fake that task's missing piece. Probing it keeps the
|
||||
// absence checks honest; inferring intent from a missing manifest does not,
|
||||
@@ -157,10 +177,9 @@ func TestNoSolutionCorpusGradesTheInverseContract(t *testing.T) {
|
||||
// The graders are POSIX shell and python3 fixtures, and the suite they
|
||||
// belong to only ever runs on POSIX CI. Where either is missing this
|
||||
// checks the host, not the corpus.
|
||||
for _, bin := range []string{"bash", "python3"} {
|
||||
if _, err := exec.LookPath(bin); err != nil {
|
||||
t.Skipf("%s unavailable; the no-solution graders need a POSIX shell and python3", bin)
|
||||
}
|
||||
requireRealBash(t)
|
||||
if _, err := exec.LookPath("python3"); err != nil {
|
||||
t.Skip("python3 unavailable; the no-solution graders need a POSIX shell and python3")
|
||||
}
|
||||
tasks, err := loadTasks(corpusDir)
|
||||
if err != nil {
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
// produced. For an exploration task the grader's "want X, got Y" line is the
|
||||
// entire finding — a bare false would leave every failure indistinguishable.
|
||||
func TestGradeVerboseKeepsWhatTheGraderSaid(t *testing.T) {
|
||||
requireRealBash(t)
|
||||
taskDir := t.TempDir()
|
||||
verify := "#!/usr/bin/env bash\necho \"answer.txt normalized to 'askrigg', want 'gorsefen'\" >&2\nexit 1\n"
|
||||
if err := os.WriteFile(filepath.Join(taskDir, "verify.sh"), []byte(verify), 0o755); err != nil {
|
||||
@@ -27,6 +28,7 @@ func TestGradeVerboseKeepsWhatTheGraderSaid(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestGradeVerboseStaysQuietWhenTheTaskPasses(t *testing.T) {
|
||||
requireRealBash(t)
|
||||
taskDir := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(taskDir, "verify.sh"), []byte("#!/usr/bin/env bash\nexit 0\n"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
// provider-network-probe performs one bounded, synthetic Chat Completions call.
|
||||
// It never executes tools and outputs only the provider's safe diagnostic model.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/provider"
|
||||
"reasonix/internal/secrets"
|
||||
)
|
||||
|
||||
type singleTransport struct {
|
||||
base http.RoundTripper
|
||||
used atomic.Bool
|
||||
}
|
||||
|
||||
func (t *singleTransport) RoundTrip(r *http.Request) (*http.Response, error) {
|
||||
if t.used.Swap(true) {
|
||||
return nil, errors.New("probe request limit reached")
|
||||
}
|
||||
return t.base.RoundTrip(r)
|
||||
}
|
||||
|
||||
func main() {
|
||||
endpoint := flag.String("endpoint", "https://api.deepseek.com/chat/completions", "complete endpoint URL; no credentials or query")
|
||||
model := flag.String("model", "deepseek-v4-flash", "fixed model for the comparison")
|
||||
protocol := flag.String("protocol", "h2", "h1 or h2; observed protocol is included in output")
|
||||
bodyBytes := flag.Int("body-bytes", 1024, "target synthetic JSON bytes (1024..4194304)")
|
||||
expect := flag.Bool("expect-continue", false, "independent Expect: 100-continue experiment")
|
||||
budget := flag.Duration("timeout", 360*time.Second, "whole probe budget (1s..10m)")
|
||||
flag.Parse()
|
||||
if err := run(*endpoint, *model, *protocol, *bodyBytes, *expect, *budget); err != nil {
|
||||
// Validation errors are fixed local prose, never transport error strings.
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func run(endpoint, model, protocol string, bodyBytes int, expect bool, budget time.Duration) error {
|
||||
u, err := url.Parse(endpoint)
|
||||
if err != nil || u.Hostname() == "" || u.User != nil || u.RawQuery != "" || u.Fragment != "" || (u.Scheme != "https" && u.Scheme != "http") {
|
||||
return errors.New("endpoint must be an HTTP(S) URL without credentials, query or fragment")
|
||||
}
|
||||
if protocol != "h1" && protocol != "h2" {
|
||||
return errors.New("protocol must be h1 or h2")
|
||||
}
|
||||
if bodyBytes < 1024 || bodyBytes > 4<<20 || budget < time.Second || budget > 10*time.Minute {
|
||||
return errors.New("body size or timeout outside probe limits")
|
||||
}
|
||||
key := os.Getenv("REASONIX_PROBE_API_KEY")
|
||||
if key == "" {
|
||||
return errors.New("set REASONIX_PROBE_API_KEY in the environment")
|
||||
}
|
||||
if u.Scheme != "https" && u.Hostname() != "127.0.0.1" && u.Hostname() != "localhost" && u.Hostname() != "::1" {
|
||||
return errors.New("remote probes require HTTPS")
|
||||
}
|
||||
body := syntheticBody(model, bodyBytes)
|
||||
tr := http.DefaultTransport.(*http.Transport).Clone()
|
||||
tr.DialContext = (&net.Dialer{Timeout: 30 * time.Second}).DialContext
|
||||
tr.TLSHandshakeTimeout = 30 * time.Second
|
||||
tr.ResponseHeaderTimeout = 300 * time.Second
|
||||
tr.ExpectContinueTimeout = time.Second
|
||||
tr.Protocols = new(http.Protocols)
|
||||
tr.Protocols.SetHTTP1(protocol == "h1")
|
||||
tr.Protocols.SetHTTP2(protocol == "h2")
|
||||
defer tr.CloseIdleConnections()
|
||||
client := &http.Client{Transport: &singleTransport{base: tr}, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), budget)
|
||||
defer cancel()
|
||||
var mu sync.Mutex
|
||||
var observation provider.RequestObservation
|
||||
ctx = provider.WithRequestObserver(ctx, func(v provider.RequestObservation) { mu.Lock(); observation = v; mu.Unlock() })
|
||||
resp, requestErr := provider.SendOnce(ctx, client, provider.SendOptions{Provider: "probe", Protocol: "openai"}, func(ctx context.Context) (*http.Request, error) {
|
||||
r, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, strings.NewReader(string(body)))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Prevent request-body replay inside Transport. A new transport is used
|
||||
// for this invocation; the wrapper also rejects any application resend.
|
||||
r.GetBody = nil
|
||||
r.Header.Set("Authorization", "Bearer "+key)
|
||||
r.Header.Set("Content-Type", "application/json")
|
||||
if expect {
|
||||
r.Header.Set("Expect", "100-continue")
|
||||
}
|
||||
return r, nil
|
||||
})
|
||||
traceID := ""
|
||||
if resp != nil {
|
||||
traceID = provider.DiagnoseFailure(&provider.APIError{TraceID: resp.Header.Get("x-trace-id")}).TraceID
|
||||
if traceID == "" {
|
||||
traceID = provider.DiagnoseFailure(&provider.APIError{TraceID: resp.Header.Get("trace-id")}).TraceID
|
||||
}
|
||||
if traceID == "" {
|
||||
traceID = provider.DiagnoseFailure(&provider.APIError{TraceID: resp.Header.Get("trace_id")}).TraceID
|
||||
}
|
||||
_, readErr := io.Copy(io.Discard, io.LimitReader(resp.Body, 1<<20))
|
||||
resp.Body.Close()
|
||||
if requestErr == nil {
|
||||
requestErr = readErr
|
||||
}
|
||||
}
|
||||
mu.Lock()
|
||||
result := struct {
|
||||
SchemaVersion int `json:"schemaVersion"`
|
||||
RequestedProtocol string `json:"requestedProtocol"`
|
||||
ExpectContinue bool `json:"expectContinue"`
|
||||
TraceID string `json:"traceId,omitempty"`
|
||||
Request provider.RequestObservation `json:"request"`
|
||||
Failure *provider.FailureDiagnostic `json:"failure,omitempty"`
|
||||
}{1, protocol, expect, traceID, observation, nil}
|
||||
mu.Unlock()
|
||||
if requestErr != nil {
|
||||
d := provider.DiagnoseFailure(requestErr)
|
||||
result.Failure = d
|
||||
}
|
||||
encoded, err := json.Marshal(result)
|
||||
if err != nil {
|
||||
return errors.New("cannot encode safe probe result")
|
||||
}
|
||||
encoded, err = secrets.RedactJSON(encoded)
|
||||
if err != nil || !json.Valid(encoded) {
|
||||
return errors.New("cannot redact probe result")
|
||||
}
|
||||
fmt.Println(string(encoded))
|
||||
return nil
|
||||
}
|
||||
|
||||
func syntheticBody(model string, size int) []byte {
|
||||
request := map[string]any{"model": model, "stream": true, "max_tokens": 1, "messages": []map[string]string{{"role": "user", "content": "Reply OK. Synthetic padding: "}}}
|
||||
base, _ := json.Marshal(request)
|
||||
if padding := size - len(base); padding > 0 {
|
||||
request["messages"].([]map[string]string)[0]["content"] += strings.Repeat("x", padding)
|
||||
}
|
||||
body, _ := json.Marshal(request)
|
||||
return body
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestSyntheticProbeIsBoundedAndDoesNotFollowRedirects(t *testing.T) {
|
||||
for _, size := range []int{1024, 3_248_572} {
|
||||
body := syntheticBody("test-model", size)
|
||||
var payload map[string]any
|
||||
if len(body) != size || json.Unmarshal(body, &payload) != nil || payload["max_tokens"] != float64(1) || payload["tools"] != nil {
|
||||
t.Fatalf("invalid synthetic request length=%d", len(body))
|
||||
}
|
||||
}
|
||||
var calls atomic.Int32
|
||||
s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
calls.Add(1)
|
||||
io.Copy(io.Discard, r.Body)
|
||||
http.Redirect(w, r, "/redirect", http.StatusTemporaryRedirect)
|
||||
}))
|
||||
defer s.Close()
|
||||
t.Setenv("REASONIX_PROBE_API_KEY", "local-only-key")
|
||||
if err := run(s.URL, "test-model", "h1", 1024, false, time.Second); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if calls.Load() != 1 {
|
||||
t.Fatalf("calls=%d", calls.Load())
|
||||
}
|
||||
tr := &singleTransport{base: http.DefaultTransport}
|
||||
tr.used.Store(true)
|
||||
req, _ := http.NewRequestWithContext(context.Background(), http.MethodPost, s.URL, nil)
|
||||
if _, err := tr.RoundTrip(req); err == nil || calls.Load() != 1 {
|
||||
t.Fatal("probe allowed a retry")
|
||||
}
|
||||
}
|
||||
@@ -190,8 +190,8 @@ func migrateWithRelaunch(installRoot, activeVersion string, relaunch bool) error
|
||||
}
|
||||
}
|
||||
shellMembers, err := installlayout.ShellMembers(installRoot, runtime.GOOS)
|
||||
if err != nil {
|
||||
return fmt.Errorf("migrate shell: %w", err)
|
||||
if err != nil || len(shellMembers) == 0 {
|
||||
return refuseShellMissing(installRoot, activeVersion, relaunch, err)
|
||||
}
|
||||
for _, member := range shellMembers {
|
||||
members = append(members, member)
|
||||
@@ -431,25 +431,35 @@ func ensureLauncherEntry(installRoot string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func finalizeLegacyPendingUpdate(installRoot, activeVersion string) error {
|
||||
// readLegacyPendingUpdate returns the transaction an old file updater left for
|
||||
// this install and version, or nil when there is none.
|
||||
func readLegacyPendingUpdate(installRoot, activeVersion string) (*repair.UpdateTransaction, error) {
|
||||
tx, err := repair.ReadPendingUpdate()
|
||||
if os.IsNotExist(err) {
|
||||
return nil
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("migrate: read legacy pending update: %w", err)
|
||||
return nil, fmt.Errorf("migrate: read legacy pending update: %w", err)
|
||||
}
|
||||
if tx.TargetKind != "file" {
|
||||
return fmt.Errorf("migrate: legacy pending update target kind %q is not supported", tx.TargetKind)
|
||||
return nil, fmt.Errorf("migrate: legacy pending update target kind %q is not supported", tx.TargetKind)
|
||||
}
|
||||
if strings.TrimSpace(tx.ToVersion) != strings.TrimSpace(activeVersion) {
|
||||
return fmt.Errorf("migrate: pending update targets %s, not %s", tx.ToVersion, activeVersion)
|
||||
return nil, fmt.Errorf("migrate: pending update targets %s, not %s", tx.ToVersion, activeVersion)
|
||||
}
|
||||
for _, target := range append([]repair.UpdateTransactionFile{{TargetPath: tx.TargetPath}}, tx.Files...) {
|
||||
if !pathWithinInstallRoot(installRoot, target.TargetPath) {
|
||||
return fmt.Errorf("migrate: pending update target escapes install root")
|
||||
return nil, fmt.Errorf("migrate: pending update target escapes install root")
|
||||
}
|
||||
}
|
||||
return tx, nil
|
||||
}
|
||||
|
||||
func finalizeLegacyPendingUpdate(installRoot, activeVersion string) error {
|
||||
tx, err := readLegacyPendingUpdate(installRoot, activeVersion)
|
||||
if err != nil || tx == nil {
|
||||
return err
|
||||
}
|
||||
id := repair.UpdateTransactionID(tx)
|
||||
if err := repair.MarkUpdateHealthyExact(activeVersion, tx.CreatedAt, id); err != nil {
|
||||
return fmt.Errorf("migrate: commit legacy pending update: %w", err)
|
||||
|
||||
@@ -11,6 +11,10 @@ import (
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
// A legacy-updater child keeps the home its parent test chose.
|
||||
if mode := os.Getenv(legacyChildModeEnv); mode != "" {
|
||||
os.Exit(runLegacyChild(mode))
|
||||
}
|
||||
testenv.RunWithIsolatedUserState(m)
|
||||
}
|
||||
|
||||
@@ -69,6 +73,7 @@ func writeInstallerStaging(t *testing.T, root, label string, includeLauncher boo
|
||||
func TestMigrateFlatInstallToVersioned(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
writeFlatUnit(t, root, "flat")
|
||||
writeShellTree(t, root, "flat")
|
||||
// Thin launcher entry must already exist (packaging places it). Use a
|
||||
// non-executable marker so startLauncher fails closed without hanging.
|
||||
_ = os.WriteFile(filepath.Join(root, "reasonix-launcher"), []byte("launcher"), 0o644)
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/config"
|
||||
"reasonix/internal/installlayout"
|
||||
"reasonix/internal/repair"
|
||||
)
|
||||
|
||||
// errShellMissing identifies a flat release unit whose desktop cannot start:
|
||||
// the desktop needs the Electron app/ tree beside it, and the 1.18–1.19.2 file
|
||||
// updaters publish only the three binaries they know by name.
|
||||
var errShellMissing = errors.New("migrate: flat release unit has no Electron desktop shell")
|
||||
|
||||
const migrateLogName = "legacy-migrate.log"
|
||||
|
||||
// refuseShellMissing commits nothing for an absent or incomplete shell: no
|
||||
// version directory, no current.json, no health mark and no flat cleanup.
|
||||
// Where an old updater's transaction is still pending it restores the release
|
||||
// unit that transaction replaced.
|
||||
func refuseShellMissing(installRoot, activeVersion string, relaunch bool, cause error) error {
|
||||
refusal := fmt.Errorf("%w: %s has no %s/ tree", errShellMissing, installRoot, installlayout.AppShellDirName)
|
||||
if cause != nil {
|
||||
refusal = fmt.Errorf("%w: %s/ tree is unusable: %w", errShellMissing, installlayout.AppShellDirName, cause)
|
||||
}
|
||||
result, rollbackErr := rollbackLegacyPendingUpdate(installRoot, activeVersion)
|
||||
switch {
|
||||
case rollbackErr != nil:
|
||||
refusal = fmt.Errorf("%w; restore the previous release: %w", refusal, rollbackErr)
|
||||
case result.RolledBack:
|
||||
refusal = fmt.Errorf("%w; restored %s", refusal, result.FromVersion)
|
||||
if relaunch {
|
||||
if err := startRestoredRelease(installRoot); err != nil {
|
||||
refusal = fmt.Errorf("%w; start the restored release: %w", refusal, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
logMigrateRefusal(installRoot, result.RolledBack, refusal)
|
||||
return refusal
|
||||
}
|
||||
|
||||
func rollbackLegacyPendingUpdate(installRoot, activeVersion string) (repair.UpdateRollbackResult, error) {
|
||||
// Windows cannot replace the running migrator image, and its installer
|
||||
// carries the shell into update mode, so this state is Unix-only.
|
||||
if runtime.GOOS == "windows" {
|
||||
return repair.UpdateRollbackResult{}, nil
|
||||
}
|
||||
tx, err := readLegacyPendingUpdate(installRoot, activeVersion)
|
||||
if err != nil || tx == nil {
|
||||
return repair.UpdateRollbackResult{}, err
|
||||
}
|
||||
result, err := repair.RollbackPendingUpdateExact(tx)
|
||||
if err == nil && result.MixedInstall {
|
||||
err = fmt.Errorf("rollback left a mix of two releases")
|
||||
}
|
||||
return result, err
|
||||
}
|
||||
|
||||
// startRestoredRelease relaunches the way the legacy updater itself does:
|
||||
// through its Guard when the restored unit has one, else the desktop directly.
|
||||
func startRestoredRelease(installRoot string) error {
|
||||
if guard := optionalRegular(filepath.Join(installRoot, "reasonix-guard")); guard != "" {
|
||||
return startDetached(installRoot, guard, "launch", "--detach")
|
||||
}
|
||||
if desktop := optionalRegular(filepath.Join(installRoot, installlayout.DesktopBinaryName())); desktop != "" {
|
||||
return startDetached(installRoot, desktop)
|
||||
}
|
||||
return fmt.Errorf("restored release has no entry point")
|
||||
}
|
||||
|
||||
func startDetached(dir, path string, args ...string) error {
|
||||
cmd := exec.Command(path, args...)
|
||||
cmd.Dir = dir
|
||||
return cmd.Start()
|
||||
}
|
||||
|
||||
// logMigrateRefusal persists the refusal: a migrator relaunched by an old
|
||||
// updater has no terminal, so stderr alone reaches nobody.
|
||||
func logMigrateRefusal(installRoot string, rolledBack bool, refusal error) {
|
||||
home := config.ReasonixHomeDir()
|
||||
if home == "" {
|
||||
return
|
||||
}
|
||||
path := filepath.Join(home, "desktop-shell", "logs", migrateLogName)
|
||||
if os.MkdirAll(filepath.Dir(path), 0o700) != nil {
|
||||
return
|
||||
}
|
||||
f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o600)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer f.Close()
|
||||
_, _ = fmt.Fprintf(f, "%s event=legacy_migrate_refused code=shell_missing version=%s rolled_back=%t root=%q detail=%q\n",
|
||||
time.Now().UTC().Format(time.RFC3339), version, rolledBack, installRoot, refusal.Error())
|
||||
}
|
||||
@@ -0,0 +1,290 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/config"
|
||||
"reasonix/internal/installlayout"
|
||||
"reasonix/internal/repair"
|
||||
)
|
||||
|
||||
const (
|
||||
legacyChildModeEnv = "REASONIX_LEGACY_MIGRATOR_TEST_CHILD"
|
||||
legacyChildRootEnv = "REASONIX_LEGACY_MIGRATOR_TEST_ROOT"
|
||||
legacyFromVersion = "v1.19.2"
|
||||
legacyToVersion = "v1.39.4"
|
||||
childShellMissing = 3
|
||||
)
|
||||
|
||||
func writeShellTree(t *testing.T, root, label string) {
|
||||
t.Helper()
|
||||
for _, name := range installlayout.ShellRequiredNames(runtime.GOOS) {
|
||||
p := filepath.Join(root, filepath.FromSlash(name))
|
||||
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(p, []byte(label+"-"+name), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func snapshotFlat(t *testing.T, root string) map[string]string {
|
||||
t.Helper()
|
||||
entries, err := os.ReadDir(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out := map[string]string{}
|
||||
for _, entry := range entries {
|
||||
if !entry.Type().IsRegular() {
|
||||
out[entry.Name()] = "<dir>"
|
||||
continue
|
||||
}
|
||||
data, err := os.ReadFile(filepath.Join(root, entry.Name()))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out[entry.Name()] = string(data)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func assertNothingCommitted(t *testing.T, root string) {
|
||||
t.Helper()
|
||||
if _, err := os.Lstat(filepath.Join(root, installlayout.CurrentFileName)); !os.IsNotExist(err) {
|
||||
t.Fatalf("current.json written for a release unit without a shell: %v", err)
|
||||
}
|
||||
if _, err := os.Lstat(filepath.Join(root, "versions")); !os.IsNotExist(err) {
|
||||
t.Fatalf("versions/ created for a release unit without a shell: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func assertRefusalLogged(t *testing.T, want ...string) {
|
||||
t.Helper()
|
||||
data, err := os.ReadFile(filepath.Join(config.ReasonixHomeDir(), "desktop-shell", "logs", migrateLogName))
|
||||
if err != nil {
|
||||
t.Fatalf("refusal was not logged: %v", err)
|
||||
}
|
||||
for _, w := range append([]string{"code=shell_missing"}, want...) {
|
||||
if !strings.Contains(string(data), w) {
|
||||
t.Fatalf("refusal log %q lacks %q", data, w)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigrateRefusesFlatUnitWithoutShell(t *testing.T) {
|
||||
cases := map[string]func(t *testing.T, root string){
|
||||
"absent": func(*testing.T, string) {},
|
||||
"incomplete": func(t *testing.T, root string) {
|
||||
p := filepath.Join(root, installlayout.AppShellDirName, "resources", "app.asar")
|
||||
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(p, []byte("asar"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
},
|
||||
}
|
||||
for name, shell := range cases {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
t.Setenv("REASONIX_HOME", t.TempDir())
|
||||
root := t.TempDir()
|
||||
writeFlatUnit(t, root, "new")
|
||||
if err := os.WriteFile(filepath.Join(root, "reasonix-guard"), []byte("migrator"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
shell(t, root)
|
||||
before := snapshotFlat(t, root)
|
||||
|
||||
err := migrateWithRelaunch(root, legacyToVersion, false)
|
||||
if !errors.Is(err, errShellMissing) {
|
||||
t.Fatalf("migrate = %v, want errShellMissing", err)
|
||||
}
|
||||
assertNothingCommitted(t, root)
|
||||
if after := snapshotFlat(t, root); fmt.Sprint(after) != fmt.Sprint(before) {
|
||||
t.Fatalf("install root changed on refusal:\nbefore %v\nafter %v", before, after)
|
||||
}
|
||||
assertRefusalLogged(t, "rolled_back=false")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// legacyUpdatedInstall reproduces what a 1.18–1.19.2 updater leaves behind: it
|
||||
// runs the updater's own transaction steps from inside the install, publishing
|
||||
// only desktop, CLI and the migrator under the Guard name.
|
||||
func legacyUpdatedInstall(t *testing.T, withShell bool) (root string, oldDesktop []byte) {
|
||||
t.Helper()
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("the file-by-file legacy updater is the Unix portable path")
|
||||
}
|
||||
t.Setenv("REASONIX_HOME", t.TempDir())
|
||||
self, err := os.Executable()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
oldDesktop, err = os.ReadFile(self)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
root = t.TempDir()
|
||||
for name, body := range map[string][]byte{
|
||||
"reasonix-desktop": oldDesktop,
|
||||
"reasonix-guard": []byte("old-guard"),
|
||||
"reasonix": []byte("old-cli"),
|
||||
} {
|
||||
if err := os.WriteFile(filepath.Join(root, name), body, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if withShell {
|
||||
writeShellTree(t, root, "new")
|
||||
}
|
||||
if code, out := runLegacyChildProcess(t, filepath.Join(root, "reasonix-desktop"), "legacy-update", root); code != 0 {
|
||||
t.Fatalf("legacy updater child exited %d: %s", code, out)
|
||||
}
|
||||
if got, _ := os.ReadFile(filepath.Join(root, "reasonix-desktop")); string(got) != "new-desktop" {
|
||||
t.Fatalf("legacy updater did not publish the new desktop: %q", got)
|
||||
}
|
||||
return root, oldDesktop
|
||||
}
|
||||
|
||||
func runLegacyChildProcess(t *testing.T, exe, mode, root string) (int, string) {
|
||||
t.Helper()
|
||||
cmd := exec.Command(exe, "-test.run=^$")
|
||||
cmd.Env = append(os.Environ(), legacyChildModeEnv+"="+mode, legacyChildRootEnv+"="+root)
|
||||
var out bytes.Buffer
|
||||
cmd.Stdout, cmd.Stderr = &out, &out
|
||||
err := cmd.Run()
|
||||
var exit *exec.ExitError
|
||||
if errors.As(err, &exit) {
|
||||
return exit.ExitCode(), out.String()
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("run %s child: %v", mode, err)
|
||||
}
|
||||
return 0, out.String()
|
||||
}
|
||||
|
||||
func TestLegacyUpdaterWithoutShellRestoresPreviousRelease(t *testing.T) {
|
||||
root, oldDesktop := legacyUpdatedInstall(t, false)
|
||||
code, out := runLegacyChildProcess(t, filepath.Join(root, "reasonix-guard"), "migrate", root)
|
||||
if code != childShellMissing {
|
||||
t.Fatalf("migrator child exited %d, want errShellMissing: %s", code, out)
|
||||
}
|
||||
assertNothingCommitted(t, root)
|
||||
for name, want := range map[string][]byte{
|
||||
"reasonix-desktop": oldDesktop,
|
||||
"reasonix-guard": []byte("old-guard"),
|
||||
"reasonix": []byte("old-cli"),
|
||||
} {
|
||||
if got, err := os.ReadFile(filepath.Join(root, name)); err != nil || !bytes.Equal(got, want) {
|
||||
t.Fatalf("%s was not restored to the previous release (err %v)", name, err)
|
||||
}
|
||||
}
|
||||
if _, err := os.Lstat(filepath.Join(root, installlayout.LauncherBinaryName())); !os.IsNotExist(err) {
|
||||
t.Fatalf("thin launcher installed beside a restored legacy release: %v", err)
|
||||
}
|
||||
if repair.PendingUpdateExists() {
|
||||
t.Fatal("legacy transaction still pending after rollback")
|
||||
}
|
||||
assertRefusalLogged(t, "rolled_back=true")
|
||||
}
|
||||
|
||||
func TestLegacyUpdaterWithShellStillCommitsTransaction(t *testing.T) {
|
||||
root, _ := legacyUpdatedInstall(t, true)
|
||||
if code, out := runLegacyChildProcess(t, filepath.Join(root, "reasonix-guard"), "migrate", root); code != 0 {
|
||||
t.Fatalf("migrator child exited %d: %s", code, out)
|
||||
}
|
||||
ptr, err := installlayout.ReadCurrent(root)
|
||||
if err != nil || ptr.ActiveVersion != legacyToVersion {
|
||||
t.Fatalf("pointer=%+v err=%v", ptr, err)
|
||||
}
|
||||
desktop, err := installlayout.ActiveDesktopPath(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got, _ := os.ReadFile(desktop); string(got) != "new-desktop" {
|
||||
t.Fatalf("active desktop = %q", got)
|
||||
}
|
||||
if !installlayout.HasActiveShell(root) {
|
||||
t.Fatal("active version lost its shell")
|
||||
}
|
||||
if repair.PendingUpdateExists() {
|
||||
t.Fatal("legacy transaction was not committed")
|
||||
}
|
||||
}
|
||||
|
||||
func runLegacyChild(mode string) int {
|
||||
root := os.Getenv(legacyChildRootEnv)
|
||||
var err error
|
||||
switch mode {
|
||||
case "legacy-update":
|
||||
err = applyLegacyUpdate(root)
|
||||
case "migrate":
|
||||
err = migrateWithRelaunch(root, legacyToVersion, false)
|
||||
if errors.Is(err, errShellMissing) {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
return childShellMissing
|
||||
}
|
||||
default:
|
||||
err = fmt.Errorf("unknown child mode %q", mode)
|
||||
}
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// applyLegacyUpdate follows applyLinux from v1.19.2 desktop/updater.go step
|
||||
// for step, with the running desktop as the updater.
|
||||
func applyLegacyUpdate(root string) error {
|
||||
exe, err := os.Executable()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
migrator, err := os.ReadFile(exe)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
desktop := filepath.Join(root, "reasonix-desktop")
|
||||
guard := filepath.Join(root, "reasonix-guard")
|
||||
cli := filepath.Join(root, "reasonix")
|
||||
tx, err := repair.PrepareFileUpdate(legacyFromVersion, legacyToVersion, desktop, guard, cli)
|
||||
if err != nil {
|
||||
return fmt.Errorf("prepare: %w", err)
|
||||
}
|
||||
claimed, release, err := repair.ClaimPendingFileUpdateExact(tx.ToVersion, tx.CreatedAt, repair.UpdateTransactionID(tx), desktop, []string{desktop, guard, cli}, 2*time.Minute)
|
||||
if err != nil {
|
||||
return fmt.Errorf("claim: %w", err)
|
||||
}
|
||||
defer release()
|
||||
if err := repair.MarkUpdateApplyFailedExact(claimed, "Linux update publish did not complete"); err != nil {
|
||||
return err
|
||||
}
|
||||
var receipts []repair.FileUpdateInstallReceipt
|
||||
for _, member := range []struct {
|
||||
path string
|
||||
body []byte
|
||||
}{{cli, []byte("new-cli")}, {guard, migrator}, {desktop, []byte("new-desktop")}} {
|
||||
receipt, err := repair.PublishClaimedFileUpdateMemberExact(claimed, member.path, member.body, 0o700)
|
||||
if err != nil {
|
||||
return fmt.Errorf("publish %s: %w", filepath.Base(member.path), err)
|
||||
}
|
||||
receipts = append(receipts, receipt)
|
||||
}
|
||||
if _, err := repair.RecordClaimedFileUpdateInstalled(claimed, receipts...); err != nil {
|
||||
return fmt.Errorf("record installed: %w", err)
|
||||
}
|
||||
return repair.ClearUpdateApplyFailureExact(claimed)
|
||||
}
|
||||
+5
-6
@@ -156,10 +156,9 @@ Desktop releases ride their own tag namespace, `desktop-v<semver>` (plain `v*`
|
||||
tags are the CLI release). Pushing one triggers `.github/workflows/release-desktop.yml`,
|
||||
which builds on a native runner per platform (Electron's Chromium can't
|
||||
cross-compile), packages each artifact, signs it with minisign, generates a
|
||||
`latest.json` manifest, publishes a GitHub release, marks the desktop release as
|
||||
GitHub's repository-wide `Latest`, mirrors everything to R2, and attaches the
|
||||
current desktop manifest to the matching CLI release for old clients that still
|
||||
ask GitHub's repository-wide `latest` release for it.
|
||||
`latest.json` manifest, publishes a GitHub release that does not claim the repository-wide `Latest`
|
||||
badge, mirrors everything to R2, and attaches the current desktop manifest to
|
||||
the matching CLI release for old clients.
|
||||
The Linux artifact bundles Electron's Chromium and ships a root-owned
|
||||
`chrome-sandbox` helper in the `.deb`; no system webview is required.
|
||||
|
||||
@@ -193,8 +192,8 @@ not depend on homepage badge semantics. Self-update behavior by platform:
|
||||
|
||||
### Code signing — first launch
|
||||
|
||||
- **Windows** — stable builds carry an Authenticode signature (SignPath, approved
|
||||
per release; `release-desktop.yml` verifies every payload binary through
|
||||
- **Windows** — stable builds carry an Authenticode signature (Certum certificate;
|
||||
`release-desktop.yml` verifies every payload binary through
|
||||
`scripts/verify-windows-authenticode.ps1` and fails the release otherwise). A
|
||||
brand-new version can still show SmartScreen until the signature accumulates
|
||||
reputation: *More info → Run anyway*.
|
||||
|
||||
+16
-11
@@ -4820,7 +4820,7 @@ func migrateLegacyWorkspacesIntoProjects() {
|
||||
if len(legacy) == 0 {
|
||||
return
|
||||
}
|
||||
_ = updateProjectsFile(func(f *desktopProjectFile) (bool, error) {
|
||||
_ = updateProjectsFilePreservingLegacyState(func(f *desktopProjectFile) (bool, error) {
|
||||
seen := make(map[string]bool, len(f.Projects)+len(legacy))
|
||||
for _, p := range f.Projects {
|
||||
seen[p.Root] = true
|
||||
@@ -6148,6 +6148,13 @@ func firstNonEmpty(values ...string) string {
|
||||
}
|
||||
|
||||
func (a *App) ContextUsageForTab(tabID string) ContextInfo {
|
||||
if a.isRemoteTab(tabID) {
|
||||
used, window, ok := a.remoteContextSnapshot(tabID)
|
||||
if ok {
|
||||
return ContextInfo{Used: used, Window: window}
|
||||
}
|
||||
return ContextInfo{}
|
||||
}
|
||||
read := a.captureContextRead(tabID)
|
||||
ctrl := read.ctrl
|
||||
var info ContextInfo
|
||||
@@ -7449,11 +7456,7 @@ func (a *App) mcpServersView() []ServerView {
|
||||
}
|
||||
|
||||
func mcpEntryEnabled(p config.PluginEntry, workspace string) bool {
|
||||
enabled, err := config.DefaultMCPActivationStore().IsEnabled(p, workspace)
|
||||
if err != nil {
|
||||
return p.ShouldAutoStart()
|
||||
}
|
||||
return enabled
|
||||
return config.MCPServerEnabled(p, workspace)
|
||||
}
|
||||
|
||||
func mcpRuntimeState(status string) string {
|
||||
@@ -8517,6 +8520,9 @@ func (a *App) ReconnectMCPServer(name string) error {
|
||||
if !found {
|
||||
return fmt.Errorf("no configured MCP server named %q", name)
|
||||
}
|
||||
if err := config.RecordExplicitStart(entry, root); err != nil {
|
||||
return err
|
||||
}
|
||||
controllers := a.mcpControllersSharingHost(host, name, ctrl)
|
||||
for i := range controllers {
|
||||
if controllers[i].ctrl == ctrl {
|
||||
@@ -8673,6 +8679,9 @@ func (a *App) SetMCPServerTier(name, tier string) error {
|
||||
}
|
||||
a.bumpExtensionGeneration()
|
||||
if tab != nil && ctrl != nil && !mcpConnected(ctrl, name) {
|
||||
if err := config.RecordExplicitStart(updated, root); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := ctrl.ConnectMCPServer(updated); err != nil {
|
||||
recordMCPFailure(ctrl, updated, err)
|
||||
return nil
|
||||
@@ -8707,7 +8716,7 @@ func (a *App) saveDesktopMCPServer(root string, entry config.PluginEntry) error
|
||||
if err := ensureMCPServerDirectlyWritable(root, entry.Name); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := config.UpsertPluginInSourceForRoot(root, entry)
|
||||
_, err := config.UpsertPluginKeepingDecision(root, entry)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -9871,10 +9880,6 @@ func previewMediaKind(path string) (kind string, mime string) {
|
||||
return "", ""
|
||||
}
|
||||
|
||||
func (a *App) activeWorkspaceBase() (string, error) {
|
||||
return workspaceBaseFromRoot(a.activeWorkspaceRoot())
|
||||
}
|
||||
|
||||
func (a *App) workspaceTargetForTab(tabID string) (string, control.SessionAPI, bool) {
|
||||
tabID = strings.TrimSpace(tabID)
|
||||
a.mu.RLock()
|
||||
|
||||
+42
-71
@@ -1103,6 +1103,7 @@ status_bar_items = ["cost", "balance"]
|
||||
`), 0o644); err != nil {
|
||||
t.Fatalf("write project config: %v", err)
|
||||
}
|
||||
approveWorkspace(t, project)
|
||||
|
||||
userCfg := config.LoadForEdit(config.UserConfigPath())
|
||||
if err := userCfg.SetDesktopLanguage("en"); err != nil {
|
||||
@@ -1345,54 +1346,26 @@ func TestSettingsShowsGlobalCredentialWithoutMutatingWorkspaceEnv(t *testing.T)
|
||||
t.Fatalf("settings provider missing from settings: %+v", got.Providers)
|
||||
}
|
||||
|
||||
func TestSettingsSeedsMissingUserConfigFromLegacyProjectConfig(t *testing.T) {
|
||||
// With no user config yet, Settings start from the defaults, not from the
|
||||
// checkout's reasonix.toml, and the first edit writes only the user's choice.
|
||||
func TestSettingsNeverSeedTheUserConfigFromTheProjectFile(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
|
||||
project := robustTempDir(t)
|
||||
if err := os.WriteFile(filepath.Join(project, "reasonix.toml"), []byte(`
|
||||
default_model = "legacy-provider/legacy-model"
|
||||
|
||||
[desktop]
|
||||
language = "zh"
|
||||
layout_style = "workbench"
|
||||
theme = "light"
|
||||
theme_style = "glacier"
|
||||
close_behavior = "quit"
|
||||
status_bar_style = "text"
|
||||
status_bar_items = ["model", "cache", "balance"]
|
||||
`), 0o644); err != nil {
|
||||
if err := os.WriteFile(filepath.Join(project, "reasonix.toml"), []byte("[desktop]\ntheme = \"light\"\n\n[permissions]\nmode = \"allow\"\n"), 0o644); err != nil {
|
||||
t.Fatalf("write project config: %v", err)
|
||||
}
|
||||
|
||||
orig, _ := os.Getwd()
|
||||
defer func() { _ = os.Chdir(orig) }()
|
||||
if err := os.Chdir(project); err != nil {
|
||||
t.Fatalf("chdir project: %v", err)
|
||||
}
|
||||
|
||||
approveWorkspace(t, project)
|
||||
t.Chdir(project)
|
||||
app := NewApp()
|
||||
got := app.Settings()
|
||||
if got.ConfigPath != config.UserConfigPath() {
|
||||
t.Fatalf("Settings configPath = %q, want user config %q", got.ConfigPath, config.UserConfigPath())
|
||||
}
|
||||
if got.DefaultModel != "legacy-provider/legacy-model" || got.DesktopLanguage != "zh" || got.DesktopLayoutStyle != "workbench" || got.DesktopTheme != "light" || got.DesktopThemeStyle != "glacier" || got.CloseBehavior != "quit" || got.StatusBarStyle != "icon" {
|
||||
t.Fatalf("Settings did not seed from legacy project config: %+v", got)
|
||||
}
|
||||
if want := []string{"model", "cache", "balance"}; !reflect.DeepEqual(got.StatusBarItems, want) {
|
||||
t.Fatalf("Settings did not seed status bar items from legacy project config: got %v want %v", got.StatusBarItems, want)
|
||||
}
|
||||
if _, err := os.Stat(config.UserConfigPath()); !os.IsNotExist(err) {
|
||||
t.Fatalf("Settings() should not write user config before an edit, stat err = %v", err)
|
||||
if got := app.Settings(); got.ConfigPath != config.UserConfigPath() || got.DesktopTheme == "light" {
|
||||
t.Fatalf("Settings = path %q theme %q, want the user config's defaults", got.ConfigPath, got.DesktopTheme)
|
||||
}
|
||||
if err := app.SetDesktopLanguage("en"); err != nil {
|
||||
t.Fatalf("SetDesktopLanguage: %v", err)
|
||||
}
|
||||
userCfg := config.LoadForEdit(config.UserConfigPath())
|
||||
if userCfg.DesktopLanguage() != "en" || userCfg.DesktopLayoutStyle() != "workbench" || userCfg.DesktopTheme() != "light" || userCfg.DesktopThemeStyle() != "glacier" || userCfg.DesktopCloseBehavior() != "quit" || userCfg.DesktopStatusBarStyle() != "icon" {
|
||||
t.Fatalf("saved user config did not preserve seeded desktop prefs: lang:%q layout:%q theme:%q style:%q close:%q status:%q", userCfg.DesktopLanguage(), userCfg.DesktopLayoutStyle(), userCfg.DesktopTheme(), userCfg.DesktopThemeStyle(), userCfg.DesktopCloseBehavior(), userCfg.DesktopStatusBarStyle())
|
||||
}
|
||||
if want := []string{"model", "cache", "balance"}; !reflect.DeepEqual(userCfg.DesktopStatusBarItems(), want) {
|
||||
t.Fatalf("saved user config did not preserve seeded status bar items: got %v want %v", userCfg.DesktopStatusBarItems(), want)
|
||||
if userCfg.DesktopLanguage() != "en" || userCfg.DesktopTheme() == "light" || userCfg.Permissions.Mode == "allow" {
|
||||
t.Fatalf("user config took the checkout's values: theme %q mode %q", userCfg.DesktopTheme(), userCfg.Permissions.Mode)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3227,6 +3200,7 @@ api_key_env = "DEEPSEEK_API_KEY"
|
||||
if err := os.WriteFile(filepath.Join(projectRoot, "reasonix.toml"), []byte(projectConfig), 0o644); err != nil {
|
||||
t.Fatalf("write project config: %v", err)
|
||||
}
|
||||
approveWorkspace(t, projectRoot)
|
||||
|
||||
app := NewApp()
|
||||
tab := &WorkspaceTab{ID: "project", WorkspaceRoot: projectRoot, Ready: true}
|
||||
@@ -3954,6 +3928,7 @@ default_effort = "max"
|
||||
if err := os.WriteFile(filepath.Join(projectA, "reasonix.toml"), []byte(ownerConfig), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
approveWorkspace(t, projectA)
|
||||
staleConfig := `default_model = "stale/stale-model"
|
||||
[[providers]]
|
||||
name = "stale"
|
||||
@@ -3966,6 +3941,7 @@ reasoning_protocol = "none"
|
||||
if err := os.WriteFile(filepath.Join(projectB, "reasonix.toml"), []byte(staleConfig), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
approveWorkspace(t, projectB)
|
||||
|
||||
topicID := "topic_effort_owner"
|
||||
topicTitle := "Effort owner"
|
||||
@@ -6909,39 +6885,6 @@ func (r *appendingDesktopRunner) Run(_ context.Context, input string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestCapabilitiesShowsDefaultMCPAsAutomaticIdleNotDisabled(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
dir := robustTempDir(t)
|
||||
t.Chdir(dir)
|
||||
if err := os.WriteFile(filepath.Join(dir, "reasonix.toml"), []byte(`
|
||||
[[plugins]]
|
||||
name = "playwright"
|
||||
command = "npx"
|
||||
args = ["-y", "@playwright/mcp"]
|
||||
`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
app := NewApp()
|
||||
app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), "")
|
||||
defer func() {
|
||||
if c := app.activeCtrl(); c != nil {
|
||||
c.Close()
|
||||
}
|
||||
}()
|
||||
|
||||
view := app.Capabilities()
|
||||
for _, s := range view.Servers {
|
||||
if s.Name == "playwright" {
|
||||
if s.Status != "deferred" || s.StartIntent != "automatic" || s.RuntimeState != "idle" {
|
||||
t.Fatalf("default MCP view = %+v, want deferred automatic idle", s)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Fatalf("playwright MCP missing from Capabilities: %+v", view.Servers)
|
||||
}
|
||||
|
||||
func TestCapabilitiesIncludesInstalledPlugins(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
reasonixHome := config.ReasonixHomeDir()
|
||||
@@ -7006,6 +6949,8 @@ url = %q
|
||||
`, srv.URL), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
approveWorkspace(t, dir)
|
||||
enableProjectMCPForTest(t, dir)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
@@ -7093,6 +7038,7 @@ args = ["-y", "@playwright/mcp"]
|
||||
`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
approveWorkspace(t, dir)
|
||||
|
||||
app := NewApp()
|
||||
app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), "")
|
||||
@@ -7116,6 +7062,7 @@ tier = "lazy"
|
||||
`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
approveWorkspace(t, dir)
|
||||
|
||||
app := NewApp()
|
||||
app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), "")
|
||||
@@ -7164,6 +7111,7 @@ url = "http://127.0.0.1:1/mcp"
|
||||
`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
approveWorkspace(t, dir)
|
||||
|
||||
host := plugin.NewHost()
|
||||
defer host.Close()
|
||||
@@ -7202,6 +7150,7 @@ url = %q
|
||||
`, srv.URL), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
approveWorkspace(t, dir)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
@@ -7352,6 +7301,7 @@ url = %q
|
||||
`, projectServer.URL), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
approveWorkspace(t, dir)
|
||||
|
||||
host := plugin.NewHost()
|
||||
t.Cleanup(host.Close)
|
||||
@@ -7505,6 +7455,7 @@ network = true
|
||||
`, exe, singleInstanceAddr, gateConfig), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
approveWorkspace(t, dir)
|
||||
|
||||
entry := config.PluginEntry{
|
||||
Name: "h", Command: exe, Args: helperArgs,
|
||||
@@ -8323,6 +8274,7 @@ args = ["serve"]
|
||||
`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
approveWorkspace(t, dir)
|
||||
|
||||
app := NewApp()
|
||||
app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), "")
|
||||
@@ -8434,6 +8386,8 @@ command = "reasonix-missing-mcp-binary"
|
||||
`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
approveWorkspace(t, dir)
|
||||
enableProjectMCPForWorkspace(t, dir, "")
|
||||
|
||||
app := NewApp()
|
||||
app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), "")
|
||||
@@ -8961,6 +8915,8 @@ tier = "lazy"
|
||||
`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
approveWorkspace(t, dir)
|
||||
enableProjectMCPForWorkspace(t, dir, "")
|
||||
|
||||
app := NewApp()
|
||||
app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), "")
|
||||
@@ -8992,6 +8948,7 @@ tier = "lazy"
|
||||
`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
approveWorkspace(t, dir)
|
||||
|
||||
app := NewApp()
|
||||
app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), "")
|
||||
@@ -9022,6 +8979,8 @@ tier = "lazy"
|
||||
`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
approveWorkspace(t, dir)
|
||||
enableProjectMCPForWorkspace(t, dir, "")
|
||||
|
||||
host := plugin.NewHost()
|
||||
host.RecordFailure(plugin.Spec{Name: "figma", Type: "http", URL: "https://mcp.figma.com/mcp"}, errors.New("connect: 401 unauthorized"))
|
||||
@@ -9056,6 +9015,8 @@ tier = "lazy"
|
||||
`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
approveWorkspace(t, dir)
|
||||
enableProjectMCPForWorkspace(t, dir, "")
|
||||
|
||||
host := plugin.NewHost()
|
||||
host.RecordFailure(plugin.Spec{Name: "figma", Type: "http", URL: "https://mcp.figma.com/mcp"}, errors.New("connect: 401 unauthorized"))
|
||||
@@ -9115,6 +9076,7 @@ tier = "lazy"
|
||||
`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
approveWorkspace(t, dir)
|
||||
|
||||
app := NewApp()
|
||||
app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), "")
|
||||
@@ -9192,6 +9154,7 @@ args = ["-y", "@playwright/mcp"]
|
||||
`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
approveWorkspace(t, dir)
|
||||
|
||||
app := NewApp()
|
||||
app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), "")
|
||||
@@ -9230,6 +9193,8 @@ tier = "background"
|
||||
`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
approveWorkspace(t, dir)
|
||||
enableProjectMCPForWorkspace(t, dir, "")
|
||||
|
||||
app := NewApp()
|
||||
app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), "")
|
||||
@@ -9280,6 +9245,8 @@ name = "codegraph"
|
||||
`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
approveWorkspace(t, dir)
|
||||
enableProjectMCPForWorkspace(t, dir, "")
|
||||
|
||||
reg := tool.NewRegistry()
|
||||
reg.Add(desktopFakeTool{name: "mcp__codegraph__connect"})
|
||||
@@ -9338,6 +9305,8 @@ tier = "lazy"
|
||||
`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
approveWorkspace(t, dir)
|
||||
enableProjectMCPForWorkspace(t, dir, "")
|
||||
|
||||
app := NewApp()
|
||||
app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), "")
|
||||
@@ -9431,6 +9400,8 @@ tier = "eager"
|
||||
`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
approveWorkspace(t, dir)
|
||||
enableProjectMCPForWorkspace(t, dir, "")
|
||||
|
||||
app := NewApp()
|
||||
app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), "")
|
||||
|
||||
@@ -426,7 +426,7 @@ func TestAttachDroppedInWorkspaceReferencesInPlace(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAttachDroppedOutsideWorkspaceCopiesToAttachments(t *testing.T) {
|
||||
func TestAttachDroppedOutsideWorkspaceReferencesFile(t *testing.T) {
|
||||
orig, _ := os.Getwd()
|
||||
defer os.Chdir(orig)
|
||||
|
||||
@@ -439,8 +439,10 @@ func TestAttachDroppedOutsideWorkspaceCopiesToAttachments(t *testing.T) {
|
||||
if err := os.Chdir(root); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c := control.New(control.Options{WorkspaceRoot: root})
|
||||
t.Cleanup(c.Close)
|
||||
app := &App{
|
||||
tabs: map[string]*WorkspaceTab{"project": {ID: "project", WorkspaceRoot: root}},
|
||||
tabs: map[string]*WorkspaceTab{"project": {ID: "project", WorkspaceRoot: root, Ctrl: c}},
|
||||
activeTabID: "project",
|
||||
}
|
||||
|
||||
@@ -448,8 +450,8 @@ func TestAttachDroppedOutsideWorkspaceCopiesToAttachments(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("AttachDropped: %v", err)
|
||||
}
|
||||
if got.Kind != "attachment" || !strings.HasPrefix(got.Path, ".reasonix/attachments/") || !strings.HasSuffix(got.Path, ".pdf") {
|
||||
t.Fatalf("got %+v, want copied pdf attachment", got)
|
||||
if got.Kind != "workspace" || !filepath.IsAbs(got.Path) || got.DisplayPath == "" {
|
||||
t.Fatalf("got %+v, want exact file reference", got)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestStaleAttachmentCleanupDoesNotFollowSwappedDirectory(t *testing.T) {
|
||||
a, root, _ := uploadFixture(t)
|
||||
outside := t.TempDir()
|
||||
attachments := filepath.Join(root, ".reasonix", "attachments")
|
||||
want := []byte("external victim must survive stale attachment cleanup")
|
||||
var victim string
|
||||
a.attachmentIOHook = func() {
|
||||
entries, err := os.ReadDir(attachments)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(entries) != 1 || entries[0].IsDir() {
|
||||
t.Fatalf("saved attachment entries = %v", entries)
|
||||
}
|
||||
victim = filepath.Join(outside, entries[0].Name())
|
||||
if err := os.WriteFile(victim, want, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Rename(attachments, filepath.Join(root, ".reasonix", "original-attachments")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if runtime.GOOS == "windows" {
|
||||
if out, err := exec.Command("cmd", "/c", "mklink", "/J", attachments, outside).CombinedOutput(); err != nil {
|
||||
t.Fatalf("create attachment junction: %v: %s", err, out)
|
||||
}
|
||||
} else if err := os.Symlink(outside, attachments); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
a.mu.Lock()
|
||||
a.tabs["a"].SessionGeneration++
|
||||
a.mu.Unlock()
|
||||
}
|
||||
|
||||
data := "data:application/octet-stream;base64," + base64.StdEncoding.EncodeToString([]byte("saved APK fixture"))
|
||||
if ref, err := a.SavePastedFileForTab("a", "app.apk", data); err == nil || ref != "" {
|
||||
t.Fatalf("stale write returned ref %q, err %v", ref, err)
|
||||
}
|
||||
got, err := os.ReadFile(victim)
|
||||
if err != nil {
|
||||
t.Fatalf("stale-owner cleanup deleted the external victim through the swapped attachment directory: %v", err)
|
||||
}
|
||||
if string(got) != string(want) {
|
||||
t.Fatalf("external victim changed: %q", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"reasonix/internal/config"
|
||||
"reasonix/internal/control"
|
||||
)
|
||||
|
||||
const attachmentUploadPath = "/__reasonix_attachment_upload"
|
||||
|
||||
func (a *App) attachmentUploadMiddleware() func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != attachmentUploadPath {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
if r.Method != http.MethodPost {
|
||||
w.Header().Set("Allow", http.MethodPost)
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
if r.Header.Get("Content-Type") != "application/octet-stream" {
|
||||
http.Error(w, "unsupported upload type", http.StatusUnsupportedMediaType)
|
||||
return
|
||||
}
|
||||
target, err := a.attachmentTargetToken(r.URL.Query().Get("target"))
|
||||
if err != nil {
|
||||
http.Error(w, "attachment target is unavailable", http.StatusConflict)
|
||||
return
|
||||
}
|
||||
limit, err := config.LoadAttachmentStreamLimit()
|
||||
if err != nil {
|
||||
http.Error(w, "attachment upload configuration could not be read", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithCancel(r.Context())
|
||||
defer cancel()
|
||||
stop := context.AfterFunc(a.attachmentOperationContext(target), func() { cancel(); _ = r.Body.Close() })
|
||||
defer stop()
|
||||
rel, err := control.SaveAttachmentStreamInRoot(ctx, target.root, r.URL.Query().Get("name"), r.Body, limit)
|
||||
rel, err = a.finishAttachmentWrite(target, rel, err)
|
||||
if err != nil {
|
||||
if !a.attachmentTargetCurrent(target) {
|
||||
http.Error(w, "attachment upload could not be saved", http.StatusConflict)
|
||||
return
|
||||
}
|
||||
var limitErr *control.AttachmentStreamLimitError
|
||||
if errors.As(err, &limitErr) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusRequestEntityTooLarge)
|
||||
_ = json.NewEncoder(w).Encode(struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
LimitBytes int64 `json:"limit_bytes"`
|
||||
LimitSource string `json:"limit_source"`
|
||||
}{"attachment.too_large", limitErr.Error(), limitErr.LimitBytes, limitErr.LimitSource})
|
||||
return
|
||||
}
|
||||
http.Error(w, "attachment upload could not be saved", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(struct {
|
||||
Path string `json:"path"`
|
||||
}{Path: rel})
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/config"
|
||||
)
|
||||
|
||||
func TestAttachmentUploadEnforcesUserStreamLimitAndCleansPartialFile(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name, user, project, body string
|
||||
length int64
|
||||
status int
|
||||
}{
|
||||
{"exact", "4", "", "1234", 0, http.StatusOK},
|
||||
{"plus one", "4", "", "12345", 0, http.StatusRequestEntityTooLarge},
|
||||
{"unknown length", "4", "", "12345", -1, http.StatusRequestEntityTooLarge},
|
||||
{"misleading length", "4", "", "12345", 1, http.StatusRequestEntityTooLarge},
|
||||
{"project cannot disable", "4", "0", "12345", -1, http.StatusRequestEntityTooLarge},
|
||||
{"user unlimited", "0", "1", "123456", -1, http.StatusOK},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
app, root, token := uploadFixture(t)
|
||||
if err := os.WriteFile(config.UserConfigPath(), []byte("[attachments]\nmax_stream_bytes = "+tc.user+"\n"), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if tc.project != "" {
|
||||
if err := os.WriteFile(filepath.Join(root, "reasonix.toml"), []byte("[attachments]\nmax_stream_bytes = "+tc.project+"\n"), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
req := uploadRequest(token, strings.NewReader(tc.body))
|
||||
if tc.length != 0 {
|
||||
req.ContentLength = tc.length
|
||||
}
|
||||
recorder := httptest.NewRecorder()
|
||||
app.attachmentUploadMiddleware()(http.NotFoundHandler()).ServeHTTP(recorder, req)
|
||||
if recorder.Code != tc.status {
|
||||
t.Fatalf("status=%d want=%d body=%s", recorder.Code, tc.status, recorder.Body)
|
||||
}
|
||||
if tc.status != http.StatusRequestEntityTooLarge {
|
||||
return
|
||||
}
|
||||
var result struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
LimitBytes int64 `json:"limit_bytes"`
|
||||
LimitSource string `json:"limit_source"`
|
||||
}
|
||||
if err := json.Unmarshal(recorder.Body.Bytes(), &result); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.Code != "attachment.too_large" || result.Message == "" || result.LimitBytes != 4 || result.LimitSource != "user" {
|
||||
t.Fatalf("untyped limit error: %+v", result)
|
||||
}
|
||||
entries, err := os.ReadDir(filepath.Join(root, ".reasonix", "attachments"))
|
||||
if err != nil || len(entries) != 0 {
|
||||
t.Fatalf("partial files=%v err=%v", entries, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAttachmentUploadRejectsUnreadableUserLimitBeforeCreatingFile(t *testing.T) {
|
||||
app, root, token := uploadFixture(t)
|
||||
if err := os.WriteFile(config.UserConfigPath(), []byte("[attachments]\nmax_stream_bytes = [broken"), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
recorder := httptest.NewRecorder()
|
||||
app.attachmentUploadMiddleware()(http.NotFoundHandler()).ServeHTTP(recorder, uploadRequest(token, strings.NewReader("body")))
|
||||
if recorder.Code != http.StatusInternalServerError {
|
||||
t.Fatalf("configuration failure accepted upload: %d %s", recorder.Code, recorder.Body)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(root, ".reasonix", "attachments")); !os.IsNotExist(err) {
|
||||
t.Fatalf("configuration failure created attachment files: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
type uploadLimitMutationReader struct {
|
||||
body *strings.Reader
|
||||
mutate func()
|
||||
}
|
||||
|
||||
func (reader *uploadLimitMutationReader) Read(p []byte) (int, error) {
|
||||
if reader.mutate != nil {
|
||||
reader.mutate()
|
||||
reader.mutate = nil
|
||||
}
|
||||
return reader.body.Read(p)
|
||||
}
|
||||
|
||||
func TestAttachmentUploadRetiredOwnerWinsOverStreamLimit(t *testing.T) {
|
||||
app, root, token := uploadFixture(t)
|
||||
if err := os.WriteFile(config.UserConfigPath(), []byte("[attachments]\nmax_stream_bytes = 4\n"), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
reader := &uploadLimitMutationReader{body: strings.NewReader("12345"), mutate: func() { app.tabs["a"].SessionGeneration++ }}
|
||||
recorder := httptest.NewRecorder()
|
||||
app.attachmentUploadMiddleware()(http.NotFoundHandler()).ServeHTTP(recorder, uploadRequest(token, reader))
|
||||
if recorder.Code != http.StatusConflict {
|
||||
t.Fatalf("retired owner error=%d body=%s", recorder.Code, recorder.Body)
|
||||
}
|
||||
entries, err := os.ReadDir(filepath.Join(root, ".reasonix", "attachments"))
|
||||
if err != nil || len(entries) != 0 {
|
||||
t.Fatalf("partial files=%v err=%v", entries, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/control"
|
||||
)
|
||||
|
||||
func uploadFixture(t *testing.T) (*App, string, string) {
|
||||
t.Helper()
|
||||
home := t.TempDir()
|
||||
t.Setenv("REASONIX_HOME", home)
|
||||
if err := os.WriteFile(filepath.Join(home, "config.toml"), nil, 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
root := t.TempDir()
|
||||
c := control.New(control.Options{WorkspaceRoot: root})
|
||||
t.Cleanup(c.Close)
|
||||
a := &App{tabs: map[string]*WorkspaceTab{"a": {ID: "a", WorkspaceRoot: root, Ctrl: c, SessionGeneration: 1}}, activeTabID: "a"}
|
||||
target, err := a.CaptureAttachmentTarget(ComposerTarget{Kind: "session", TabID: "a"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { a.ReleaseAttachmentTarget(target.Token) })
|
||||
return a, root, target.Token
|
||||
}
|
||||
|
||||
type uploadZeros struct{}
|
||||
|
||||
func (uploadZeros) Read(p []byte) (int, error) {
|
||||
clear(p)
|
||||
return len(p), nil
|
||||
}
|
||||
|
||||
func uploadRequest(token string, body io.Reader) *http.Request {
|
||||
query := url.Values{"target": {token}, "name": {"large app.apk"}}
|
||||
r := httptest.NewRequest(http.MethodPost, attachmentUploadPath+"?"+query.Encode(), body)
|
||||
r.Header.Set("Content-Type", "application/octet-stream")
|
||||
r.Header.Set("Authorization", "Bearer test-secret")
|
||||
return r
|
||||
}
|
||||
|
||||
func TestAttachmentUploadLargeFileKeepsCapturedWorkspace(t *testing.T) {
|
||||
a, root, token := uploadFixture(t)
|
||||
other := t.TempDir()
|
||||
a.tabs["b"] = &WorkspaceTab{ID: "b", WorkspaceRoot: other}
|
||||
a.activeTabID = "b"
|
||||
const size = 80 << 20
|
||||
recorder := httptest.NewRecorder()
|
||||
handler := bearerAuth("test-secret", a.attachmentUploadMiddleware()(http.NotFoundHandler()))
|
||||
handler.ServeHTTP(recorder, uploadRequest(token, io.LimitReader(uploadZeros{}, size)))
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("status=%d body=%s", recorder.Code, recorder.Body)
|
||||
}
|
||||
var result struct {
|
||||
Path string `json:"path"`
|
||||
}
|
||||
if err := json.Unmarshal(recorder.Body.Bytes(), &result); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
info, err := os.Stat(filepath.Join(root, filepath.FromSlash(result.Path)))
|
||||
if err != nil || info.Size() != size {
|
||||
t.Fatalf("file=%v err=%v", info, err)
|
||||
}
|
||||
if !strings.HasSuffix(result.Path, ".apk") {
|
||||
t.Fatalf("path=%s", result.Path)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(other, filepath.FromSlash(result.Path))); !os.IsNotExist(err) {
|
||||
t.Fatalf("wrote into focused tab: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAttachmentUploadRejectsUntrustedAndStaleRequests(t *testing.T) {
|
||||
a, root, token := uploadFixture(t)
|
||||
handler := bearerAuth("test-secret", a.attachmentUploadMiddleware()(http.NotFoundHandler()))
|
||||
for _, scenario := range []string{"no auth", "bad type", "bad method", "unknown target", "released target"} {
|
||||
t.Run(scenario, func(t *testing.T) {
|
||||
r := uploadRequest(token, strings.NewReader("body"))
|
||||
want := http.StatusConflict
|
||||
switch scenario {
|
||||
case "no auth":
|
||||
r.Header.Del("Authorization")
|
||||
want = http.StatusUnauthorized
|
||||
case "bad type":
|
||||
r.Header.Set("Content-Type", "text/plain")
|
||||
want = http.StatusUnsupportedMediaType
|
||||
case "bad method":
|
||||
r.Method = http.MethodGet
|
||||
want = http.StatusMethodNotAllowed
|
||||
case "unknown target":
|
||||
r.URL.RawQuery = "target=not-a-token"
|
||||
case "released target":
|
||||
a.ReleaseAttachmentTarget(token)
|
||||
}
|
||||
recorder := httptest.NewRecorder()
|
||||
handler.ServeHTTP(recorder, r)
|
||||
if recorder.Code != want {
|
||||
t.Fatalf("status=%d want=%d", recorder.Code, want)
|
||||
}
|
||||
})
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(root, ".reasonix", "attachments")); !os.IsNotExist(err) {
|
||||
t.Fatalf("rejected upload created files: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAttachmentUploadRemovesFileWhenTargetChangesBeforeCommit(t *testing.T) {
|
||||
a, root, token := uploadFixture(t)
|
||||
a.attachmentIOHook = func() { a.tabs["a"].SessionGeneration++ }
|
||||
recorder := httptest.NewRecorder()
|
||||
a.attachmentUploadMiddleware()(http.NotFoundHandler()).ServeHTTP(recorder, uploadRequest(token, strings.NewReader("partial")))
|
||||
if recorder.Code != http.StatusConflict {
|
||||
t.Fatalf("status=%d", recorder.Code)
|
||||
}
|
||||
entries, err := os.ReadDir(filepath.Join(root, ".reasonix", "attachments"))
|
||||
if err != nil || len(entries) != 0 {
|
||||
t.Fatalf("left partial files: %v err=%v", entries, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAttachmentUploadCancellationClosesBlockedBodyAndCleansFile(t *testing.T) {
|
||||
a, root, token := uploadFixture(t)
|
||||
reader, writer := io.Pipe()
|
||||
defer writer.Close()
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
defer close(done)
|
||||
a.attachmentUploadMiddleware()(http.NotFoundHandler()).ServeHTTP(httptest.NewRecorder(), uploadRequest(token, reader))
|
||||
}()
|
||||
if _, err := writer.Write(make([]byte, 64<<10)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
a.ReleaseAttachmentTarget(token)
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("cancelled upload kept reading its body")
|
||||
}
|
||||
entries, err := os.ReadDir(filepath.Join(root, ".reasonix", "attachments"))
|
||||
if err != nil || len(entries) != 0 {
|
||||
t.Fatalf("files=%v err=%v", entries, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAttachmentUploadLateCancellationRemovesCompletedFile(t *testing.T) {
|
||||
a, root, token := uploadFixture(t)
|
||||
a.attachmentIOHook = func() { a.ReleaseAttachmentTarget(token) }
|
||||
recorder := httptest.NewRecorder()
|
||||
a.attachmentUploadMiddleware()(http.NotFoundHandler()).ServeHTTP(recorder, uploadRequest(token, strings.NewReader("complete but cancelled")))
|
||||
if recorder.Code != http.StatusConflict {
|
||||
t.Fatalf("status=%d", recorder.Code)
|
||||
}
|
||||
entries, err := os.ReadDir(filepath.Join(root, ".reasonix", "attachments"))
|
||||
if err != nil || len(entries) != 0 {
|
||||
t.Fatalf("files=%v err=%v", entries, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDroppedLargeExternalFileRemainsReference(t *testing.T) {
|
||||
a, root, token := uploadFixture(t)
|
||||
path := filepath.Join(t.TempDir(), "large app.apk")
|
||||
f, err := os.Create(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := f.Truncate(120 << 20); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := f.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
item, err := a.AttachDroppedForTarget(token, path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if item.Kind != "workspace" || !filepath.IsAbs(item.Path) || item.IsDir {
|
||||
t.Fatalf("item=%+v", item)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(root, ".reasonix", "attachments")); !os.IsNotExist(err) {
|
||||
t.Fatalf("native file was copied: %v", err)
|
||||
}
|
||||
item2, err := a.AttachDroppedForTarget(token, item.DisplayPath)
|
||||
if err != nil || item2.Path != item.Path {
|
||||
t.Fatalf("restored=%+v err=%v", item2, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDraftExternalFileReferenceEscapesSpacesAndPreservesOtherPaths(t *testing.T) {
|
||||
a, _, _ := uploadFixture(t)
|
||||
path := filepath.Join(t.TempDir(), "large app.apk")
|
||||
if err := os.WriteFile(path, []byte("PK\x00binary"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
other := "@" + control.EscapeRefPath(path+".backup")
|
||||
request := SessionDraftSubmissionRequest{Input: "inspect @" + control.EscapeRefPath(path) + " " + other, WorkspaceRefs: []DraftWorkspaceRef{{Path: path, DisplayPath: path}}}
|
||||
if err := a.resolveDraftExternalRefs("a", &request); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
canonical, err := filepath.EvalSymlinks(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := "inspect @" + control.EscapeRefPath(filepath.ToSlash(canonical)) + " " + other
|
||||
if request.Input != want {
|
||||
t.Fatalf("input=%q want=%q", request.Input, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDraftNativeFileReferenceMatchesSessionReference(t *testing.T) {
|
||||
a, root, _ := uploadFixture(t)
|
||||
sourceRoot := t.TempDir()
|
||||
canonicalPath := filepath.Join(sourceRoot, "large app.apk")
|
||||
if err := os.WriteFile(canonicalPath, []byte("APK"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
path := canonicalPath
|
||||
alias := filepath.Join(t.TempDir(), "parent alias")
|
||||
if err := os.Symlink(sourceRoot, alias); err == nil {
|
||||
path = filepath.Join(alias, filepath.Base(canonicalPath))
|
||||
}
|
||||
item, err := a.attachDroppedForTarget(attachmentTarget{root: root, draftID: "draft"}, path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
restored, err := a.AttachDroppedForTab("a", item.DisplayPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if item.Path != restored.Path || item.Path != filepath.ToSlash(item.Path) {
|
||||
t.Fatalf("draft=%+v session=%+v", item, restored)
|
||||
}
|
||||
}
|
||||
@@ -111,6 +111,9 @@ func (a *App) attachmentTargetCurrent(target attachmentTarget) bool {
|
||||
}
|
||||
|
||||
func (a *App) attachmentTargetInvalidReason(target attachmentTarget) string {
|
||||
if target.ctx != nil && target.ctx.Err() != nil {
|
||||
return "attachment operation cancelled"
|
||||
}
|
||||
if target.tab == nil {
|
||||
if target.draftID == "" {
|
||||
return "missing owner"
|
||||
@@ -165,7 +168,7 @@ func (a *App) finishAttachmentWrite(target attachmentTarget, rel string, err err
|
||||
return rel, nil
|
||||
}
|
||||
if isAttachmentRefPath(rel) {
|
||||
_ = os.Remove(filepath.Join(target.root, filepath.FromSlash(rel)))
|
||||
_ = control.RemoveStoredAttachment(target.root, rel)
|
||||
}
|
||||
return "", fmt.Errorf("attachment workspace changed while saving; please retry")
|
||||
}
|
||||
@@ -428,6 +431,9 @@ func (a *App) attachDroppedForTarget(target attachmentTarget, path string) (Drop
|
||||
if err != nil {
|
||||
return DroppedItem{}, err
|
||||
}
|
||||
if info.Mode()&os.ModeSymlink != 0 || (!info.IsDir() && !info.Mode().IsRegular()) {
|
||||
return DroppedItem{}, fmt.Errorf("dropped path must be a regular file or directory")
|
||||
}
|
||||
if isImageExt(path) {
|
||||
if rel, saveErr := control.SaveImageFileInRoot(target.root, path); saveErr == nil {
|
||||
rel, saveErr = a.finishAttachmentWrite(target, rel, nil)
|
||||
@@ -436,7 +442,7 @@ func (a *App) attachDroppedForTarget(target attachmentTarget, path string) (Drop
|
||||
}
|
||||
preview, previewErr := a.attachmentDataURLForTarget(target, rel)
|
||||
if previewErr != nil {
|
||||
_ = os.Remove(filepath.Join(target.root, filepath.FromSlash(rel)))
|
||||
_ = control.RemoveStoredAttachment(target.root, rel)
|
||||
return DroppedItem{}, previewErr
|
||||
}
|
||||
return DroppedItem{Kind: "attachment", Path: rel, PreviewURL: preview}, nil
|
||||
@@ -450,6 +456,20 @@ func (a *App) attachDroppedForTarget(target attachmentTarget, path string) (Drop
|
||||
}
|
||||
return DroppedItem{Kind: "workspace", Path: rel, IsDir: info.IsDir()}, nil
|
||||
}
|
||||
if target.draftID != "" {
|
||||
abs, err := filepath.Abs(path)
|
||||
if err != nil {
|
||||
return DroppedItem{}, err
|
||||
}
|
||||
if !info.IsDir() {
|
||||
abs, err = filepath.EvalSymlinks(abs)
|
||||
if err != nil {
|
||||
return DroppedItem{}, err
|
||||
}
|
||||
abs = filepath.ToSlash(abs)
|
||||
}
|
||||
return DroppedItem{Kind: "workspace", Path: abs, IsDir: info.IsDir(), DisplayPath: filepath.ToSlash(abs)}, nil
|
||||
}
|
||||
if info.IsDir() {
|
||||
if target.tab == nil || target.ctrl == nil {
|
||||
return DroppedItem{}, fmt.Errorf("workspace is not ready")
|
||||
@@ -473,15 +493,17 @@ func (a *App) attachDroppedForTarget(target attachmentTarget, path string) (Drop
|
||||
}
|
||||
return DroppedItem{Kind: "workspace", Path: token, IsDir: true, DisplayPath: displayPath}, nil
|
||||
}
|
||||
rel, err := control.SaveAttachmentFileInRoot(target.root, path)
|
||||
if target.ctrl == nil || !a.attachmentTargetCurrent(target) {
|
||||
return DroppedItem{}, fmt.Errorf("workspace is not ready")
|
||||
}
|
||||
token, displayPath, err := target.ctrl.RegisterExternalFileRef(path)
|
||||
if err != nil {
|
||||
return DroppedItem{}, err
|
||||
}
|
||||
rel, err = a.finishAttachmentWrite(target, rel, nil)
|
||||
if err != nil {
|
||||
return DroppedItem{}, err
|
||||
if !a.attachmentTargetCurrent(target) {
|
||||
return DroppedItem{}, fmt.Errorf("attachment workspace changed while reading; please retry")
|
||||
}
|
||||
return DroppedItem{Kind: "attachment", Path: rel}, nil
|
||||
return DroppedItem{Kind: "workspace", Path: token, DisplayPath: displayPath}, nil
|
||||
}
|
||||
|
||||
func isImageExt(path string) bool {
|
||||
|
||||
@@ -90,7 +90,12 @@ func TestRetryAuthenticationPublishesAuthoritativeTabRefresh(t *testing.T) {
|
||||
}
|
||||
for _, item := range app.ListTabs() {
|
||||
if item.ID == tab.ID && item.Authentication != nil && item.Authentication.Ready() {
|
||||
refreshed <- true
|
||||
// 非阻塞发送:该回调跑在共享的 asyncRuntimeEmitter.run 上,
|
||||
// 阻塞发送会把后续所有 runtime 事件永久卡死。
|
||||
select {
|
||||
case refreshed <- true:
|
||||
default:
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -103,4 +108,23 @@ func TestRetryAuthenticationPublishesAuthoritativeTabRefresh(t *testing.T) {
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("retry did not publish a refresh of authoritative authentication state")
|
||||
}
|
||||
|
||||
// 回归:缓冲已满时再次触发同一事件,emitter 泵必须继续排空而不是停在
|
||||
// tabs.go 的 emit 调用点上(曾实证 chan send 卡死 7 分钟)。
|
||||
for range 3 {
|
||||
app.runtimeEvents.Emit(app.ctx, tabMetaRefreshEventChannel)
|
||||
}
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for {
|
||||
app.runtimeEvents.mu.Lock()
|
||||
busy := app.runtimeEvents.running
|
||||
app.runtimeEvents.mu.Unlock()
|
||||
if !busy {
|
||||
return
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatal("asyncRuntimeEmitter pump stayed blocked after repeated refresh events")
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
+17
-2
@@ -64,6 +64,10 @@ type botBridgeHub struct {
|
||||
lastPersistSeq uint64
|
||||
|
||||
queue chan desktopBridgeNotification
|
||||
// closed 终止 run worker;queue 本身不能 close——observe 仍可能在
|
||||
// controller 事件 goroutine 上并发 enqueue,向已关闭 channel 发送会 panic。
|
||||
closed chan struct{}
|
||||
closeOnce sync.Once
|
||||
}
|
||||
|
||||
type desktopPendingPrompt struct {
|
||||
@@ -135,11 +139,17 @@ func newBotBridgeHub(deps botBridgeDeps) *botBridgeHub {
|
||||
takeovers: make(map[string]bot.DesktopWatchRoute),
|
||||
takeoverTabs: make(map[string]string),
|
||||
queue: make(chan desktopBridgeNotification, botBridgeQueueSize),
|
||||
closed: make(chan struct{}),
|
||||
}
|
||||
go h.run()
|
||||
return h
|
||||
}
|
||||
|
||||
// Close 停掉通知 worker goroutine。幂等;App 关停时调用。
|
||||
func (h *botBridgeHub) Close() {
|
||||
h.closeOnce.Do(func() { close(h.closed) })
|
||||
}
|
||||
|
||||
// observe 接收某个桌面会话的一条事件。在 controller 事件 goroutine 上运行,
|
||||
// 只做内存记账和入队,不做任何阻塞调用。
|
||||
func (h *botBridgeHub) observe(tabID string, e event.Event) {
|
||||
@@ -209,8 +219,13 @@ func (h *botBridgeHub) enqueue(n desktopBridgeNotification) {
|
||||
}
|
||||
|
||||
func (h *botBridgeHub) run() {
|
||||
for n := range h.queue {
|
||||
h.deliver(n)
|
||||
for {
|
||||
select {
|
||||
case <-h.closed:
|
||||
return
|
||||
case n := <-h.queue:
|
||||
h.deliver(n)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -29,6 +29,12 @@ func (a *App) newBotBridge() *botBridgeHub {
|
||||
})
|
||||
}
|
||||
|
||||
func (a *App) stopBotBridge() {
|
||||
if a.botBridge != nil {
|
||||
a.botBridge.Close()
|
||||
}
|
||||
}
|
||||
|
||||
// bridgeSessions 枚举所有 live 会话:可见 tab 用完整 TabMeta,后台 detached
|
||||
// 会话补一份轻量快照(controller 仍存活,审批/问答仍可路由)。
|
||||
func (a *App) bridgeSessions() []bot.DesktopSessionInfo {
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -129,6 +130,28 @@ func testGroupRoute() bot.DesktopWatchRoute {
|
||||
return r
|
||||
}
|
||||
|
||||
// newBotBridgeHub 启动一个 run worker;Close 必须让它退出,否则每个 App 实例
|
||||
// 都泄漏一个 goroutine(转储曾观测到上千个)。enqueue 在 Close 后仍不得 panic:
|
||||
// observe 可能在 controller 事件 goroutine 上与关停并发。
|
||||
func TestBridgeCloseStopsRunGoroutine(t *testing.T) {
|
||||
before := runtime.NumGoroutine()
|
||||
env := newBridgeTestEnv(nil)
|
||||
env.hub.SetWatch(testWatchRoute(), true)
|
||||
env.hub.Close()
|
||||
env.hub.Close() // 幂等
|
||||
// 有订阅者时 observe 会真正 enqueue;Close 后不得 panic。
|
||||
env.hub.observe("tab-1", event.Event{Kind: event.TurnDone})
|
||||
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if runtime.NumGoroutine() <= before {
|
||||
return
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
t.Fatalf("hub run goroutine still alive after Close: %d goroutines > baseline %d", runtime.NumGoroutine(), before)
|
||||
}
|
||||
|
||||
func TestBridgeTakeoverRejectsGroupChat(t *testing.T) {
|
||||
env := newBridgeTestEnvSessions([]bot.DesktopSessionInfo{{TabID: "tab-1", Label: "会话一", Ready: true}})
|
||||
if _, err := env.hub.Takeover(testGroupRoute(), "tab-1"); err == nil {
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"reasonix/internal/bot"
|
||||
"reasonix/internal/botruntime"
|
||||
"reasonix/internal/config"
|
||||
"reasonix/internal/session"
|
||||
)
|
||||
|
||||
type BotRuntimeStatusView struct {
|
||||
@@ -123,7 +124,7 @@ func (a *App) refreshBotRuntime() {
|
||||
a.botBridge.seedWatchers(bridgeRoutesFromConfig(cfg.Bot.DesktopWatchers), watcherVersion)
|
||||
bridge = a.botBridge
|
||||
}
|
||||
_ = a.botRuntime.apply(a.bootContext(), cfg, globalTabWorkspaceRoot(), a.persistRemoteBotToolApprovalMode, bridge)
|
||||
_ = a.botRuntime.apply(a.bootContext(), cfg, globalTabWorkspaceRoot(), a.persistRemoteBotToolApprovalMode, bridge, a.historicalSessionService)
|
||||
}
|
||||
|
||||
func (a *App) loadDesktopBotConfig() (*config.Config, error) {
|
||||
@@ -151,7 +152,7 @@ func (a *App) BotRuntimeStatus() BotRuntimeStatusView {
|
||||
return a.botRuntime.snapshot()
|
||||
}
|
||||
|
||||
func (r *desktopBotRuntime) apply(parent context.Context, cfg *config.Config, workspaceRoot string, onToolApprovalModeChange func(bot.InboundMessage, string) error, bridge bot.DesktopBridge) error {
|
||||
func (r *desktopBotRuntime) apply(parent context.Context, cfg *config.Config, workspaceRoot string, onToolApprovalModeChange func(bot.InboundMessage, string) error, bridge bot.DesktopBridge, sessionServiceForRoot func(string) (*session.Service, error)) error {
|
||||
if r == nil {
|
||||
return nil
|
||||
}
|
||||
@@ -234,6 +235,7 @@ func (r *desktopBotRuntime) apply(parent context.Context, cfg *config.Config, wo
|
||||
OnSessionReady: botruntime.NewSessionRemembererWithWorkspace(logger, workspaceRoot),
|
||||
OnToolApprovalModeChange: onToolApprovalModeChange,
|
||||
Desktop: bridge,
|
||||
SessionServiceForRoot: sessionServiceForRoot,
|
||||
}
|
||||
bindings := botruntime.AdapterBindings(cfg, plan.Enabled, nil, logger)
|
||||
if len(bindings) == 0 {
|
||||
|
||||
+30
-155
@@ -265,19 +265,18 @@ func TestDesktopBotRuntimeConfigLoadsAllSavedCredentialsAfterRestart(t *testing.
|
||||
}
|
||||
}
|
||||
|
||||
func TestDesktopBotRuntimeMigratesLegacyProjectBotSettings(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
|
||||
userCfg := config.Default()
|
||||
if err := userCfg.SetDesktopAppearance("dark", "graphite"); err != nil {
|
||||
t.Fatalf("set desktop appearance: %v", err)
|
||||
}
|
||||
if err := userCfg.SaveTo(config.UserConfigPath()); err != nil {
|
||||
t.Fatalf("save user config: %v", err)
|
||||
}
|
||||
|
||||
project := robustTempDir(t)
|
||||
if err := os.WriteFile(filepath.Join(project, "reasonix.toml"), []byte(`
|
||||
// A checkout's [bot] never reaches the bot runtime or the user's config, with
|
||||
// or without a user config of its own.
|
||||
func TestDesktopBotRuntimeIgnoresProjectBotSettings(t *testing.T) {
|
||||
for _, withUser := range []bool{false, true} {
|
||||
isolateDesktopUserDirs(t)
|
||||
if withUser {
|
||||
if err := config.Default().SaveTo(config.UserConfigPath()); err != nil {
|
||||
t.Fatalf("save user config: %v", err)
|
||||
}
|
||||
}
|
||||
project := robustTempDir(t)
|
||||
if err := os.WriteFile(filepath.Join(project, "reasonix.toml"), []byte(`
|
||||
[bot]
|
||||
enabled = true
|
||||
|
||||
@@ -289,154 +288,30 @@ feishu_users = ["ou-legacy"]
|
||||
id = "feishu-lark"
|
||||
provider = "feishu"
|
||||
domain = "lark"
|
||||
label = "Lark"
|
||||
enabled = true
|
||||
status = "connected"
|
||||
`), 0o644); err != nil {
|
||||
t.Fatalf("write project config: %v", err)
|
||||
}
|
||||
|
||||
orig, _ := os.Getwd()
|
||||
defer func() { _ = os.Chdir(orig) }()
|
||||
if err := os.Chdir(project); err != nil {
|
||||
t.Fatalf("chdir project: %v", err)
|
||||
}
|
||||
|
||||
app := NewApp()
|
||||
got, err := app.loadDesktopBotConfig()
|
||||
if err != nil {
|
||||
t.Fatalf("load desktop bot config: %v", err)
|
||||
}
|
||||
if !got.Bot.Enabled || len(got.Bot.Connections) != 1 || got.Bot.Connections[0].ID != "feishu-lark" {
|
||||
t.Fatalf("desktop bot config = %+v, want migrated legacy Lark connection", got.Bot)
|
||||
}
|
||||
|
||||
// The bot-runtime load is a pure read: the merge above stays in memory and
|
||||
// the user config file is not rewritten.
|
||||
preWrite := config.LoadForEdit(config.UserConfigPath())
|
||||
if preWrite.Bot.Enabled || len(preWrite.Bot.Connections) != 0 {
|
||||
t.Fatalf("read path persisted bot config = %+v, want disk untouched until a locked write", preWrite.Bot)
|
||||
}
|
||||
|
||||
// The first locked write path performs the on-disk migration.
|
||||
if err := app.applyConfigOnly(func(*config.Config) error { return nil }); err != nil {
|
||||
t.Fatalf("applyConfigOnly: %v", err)
|
||||
}
|
||||
persisted := config.LoadForEdit(config.UserConfigPath())
|
||||
if !persisted.Bot.Enabled || len(persisted.Bot.Connections) != 1 || persisted.Bot.Connections[0].ID != "feishu-lark" {
|
||||
t.Fatalf("persisted bot config = %+v, want migrated legacy Lark connection", persisted.Bot)
|
||||
}
|
||||
if persisted.DesktopTheme() != "dark" {
|
||||
t.Fatalf("desktop theme = %q, want preserved user preference", persisted.DesktopTheme())
|
||||
t.Fatalf("write project config: %v", err)
|
||||
}
|
||||
t.Chdir(project)
|
||||
app := NewApp()
|
||||
got, err := app.loadDesktopBotConfig()
|
||||
if err != nil {
|
||||
t.Fatalf("load desktop bot config: %v", err)
|
||||
}
|
||||
if got.Bot.Enabled || len(got.Bot.Connections) != 0 {
|
||||
t.Fatalf("desktop bot config = %+v, want the checkout's [bot] ignored", got.Bot)
|
||||
}
|
||||
if err := app.applyConfigOnly(func(*config.Config) error { return nil }); err != nil {
|
||||
t.Fatalf("applyConfigOnly: %v", err)
|
||||
}
|
||||
if persisted := config.LoadForEdit(config.UserConfigPath()); persisted.Bot.Enabled || len(persisted.Bot.Connections) != 0 {
|
||||
t.Fatalf("user config took the checkout's [bot]: %+v", persisted.Bot)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDesktopBotRuntimePersistsLegacyProjectBotWhenUserConfigMissing(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
|
||||
project := robustTempDir(t)
|
||||
if err := os.WriteFile(filepath.Join(project, "reasonix.toml"), []byte(`
|
||||
[desktop]
|
||||
theme = "dark"
|
||||
|
||||
[bot]
|
||||
enabled = true
|
||||
|
||||
[bot.allowlist]
|
||||
enabled = true
|
||||
feishu_users = ["ou-legacy"]
|
||||
|
||||
[[bot.connections]]
|
||||
id = "feishu-lark"
|
||||
provider = "feishu"
|
||||
domain = "lark"
|
||||
label = "Lark"
|
||||
enabled = true
|
||||
status = "connected"
|
||||
`), 0o644); err != nil {
|
||||
t.Fatalf("write project config: %v", err)
|
||||
}
|
||||
|
||||
orig, _ := os.Getwd()
|
||||
defer func() { _ = os.Chdir(orig) }()
|
||||
if err := os.Chdir(project); err != nil {
|
||||
t.Fatalf("chdir project: %v", err)
|
||||
}
|
||||
|
||||
app := NewApp()
|
||||
got, err := app.loadDesktopBotConfig()
|
||||
if err != nil {
|
||||
t.Fatalf("load desktop bot config: %v", err)
|
||||
}
|
||||
if !got.Bot.Enabled || len(got.Bot.Connections) != 1 || got.Bot.Connections[0].ID != "feishu-lark" {
|
||||
t.Fatalf("desktop bot config = %+v, want migrated legacy Lark connection", got.Bot)
|
||||
}
|
||||
|
||||
// The bot-runtime load is a pure read: it serves the legacy config from
|
||||
// memory and must not create the user config file.
|
||||
if _, err := os.Stat(config.UserConfigPath()); !os.IsNotExist(err) {
|
||||
t.Fatalf("read path must not create the user config, stat err = %v", err)
|
||||
}
|
||||
|
||||
// The first locked write path creates the user config with the migrated
|
||||
// bot settings (adopting the legacy config, ConfigVersion-bumped).
|
||||
if err := app.applyConfigOnly(func(*config.Config) error { return nil }); err != nil {
|
||||
t.Fatalf("applyConfigOnly: %v", err)
|
||||
}
|
||||
persisted := config.LoadForEdit(config.UserConfigPath())
|
||||
if !persisted.Bot.Enabled || len(persisted.Bot.Connections) != 1 || persisted.Bot.Connections[0].ID != "feishu-lark" {
|
||||
t.Fatalf("persisted bot config = %+v, want migrated legacy Lark connection", persisted.Bot)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDesktopSettingsBotMigrationPersistsOnlyBotBeforeFirstEdit(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
|
||||
project := robustTempDir(t)
|
||||
if err := os.WriteFile(filepath.Join(project, "reasonix.toml"), []byte(`
|
||||
[desktop]
|
||||
theme = "dark"
|
||||
close_behavior = "quit"
|
||||
|
||||
[bot]
|
||||
enabled = true
|
||||
|
||||
[bot.allowlist]
|
||||
enabled = true
|
||||
feishu_users = ["ou-legacy"]
|
||||
|
||||
[[bot.connections]]
|
||||
id = "feishu-lark"
|
||||
provider = "feishu"
|
||||
domain = "lark"
|
||||
label = "Lark"
|
||||
enabled = true
|
||||
status = "connected"
|
||||
`), 0o644); err != nil {
|
||||
t.Fatalf("write project config: %v", err)
|
||||
}
|
||||
|
||||
orig, _ := os.Getwd()
|
||||
defer func() { _ = os.Chdir(orig) }()
|
||||
if err := os.Chdir(project); err != nil {
|
||||
t.Fatalf("chdir project: %v", err)
|
||||
}
|
||||
|
||||
settings := NewApp().Settings()
|
||||
if !settings.Bot.Enabled || len(settings.Bot.Connections) != 1 || settings.Bot.Connections[0].ID != "feishu-lark" {
|
||||
t.Fatalf("settings bot = %+v, want migrated legacy Lark connection", settings.Bot)
|
||||
}
|
||||
if settings.DesktopTheme != "dark" || settings.CloseBehavior != "quit" {
|
||||
t.Fatalf("settings desktop prefs = theme:%q close:%q, want legacy seed visible before first edit", settings.DesktopTheme, settings.CloseBehavior)
|
||||
}
|
||||
|
||||
persisted := config.LoadForEdit(config.UserConfigPath())
|
||||
if persisted.DesktopTheme() == "dark" || persisted.DesktopCloseBehavior() == "quit" {
|
||||
t.Fatalf("persisted desktop prefs = theme:%q close:%q, want bot-only migration", persisted.DesktopTheme(), persisted.DesktopCloseBehavior())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDesktopBotRuntimeMigrationDoesNotOverwriteUserBotSettings(t *testing.T) {
|
||||
func TestDesktopBotRuntimeKeepsUserBotSettingsBesideAProjectBot(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
|
||||
userCfg := config.Default()
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"reasonix/internal/config"
|
||||
"reasonix/internal/session"
|
||||
)
|
||||
|
||||
const embeddedBotSessionPrefix = "bot-session:local:"
|
||||
|
||||
// Embedded bot conversations keep their existing project-local store. Resolve
|
||||
// the sidebar's stable session reference there before creating a surface; a
|
||||
// forged reference must not open a session belonging to another workspace.
|
||||
func (a *App) embeddedBotSessionPath(scope, workspaceRoot, route string) (string, error) {
|
||||
id := strings.TrimPrefix(route, embeddedBotSessionPrefix)
|
||||
if err := session.ValidateSessionID(id); err != nil {
|
||||
return "", err
|
||||
}
|
||||
root := globalTabWorkspaceRoot()
|
||||
if scope == "project" {
|
||||
root = normalizeProjectRoot(workspaceRoot)
|
||||
if root == "" {
|
||||
return "", fmt.Errorf("workspaceRoot is required")
|
||||
}
|
||||
}
|
||||
storeRoot := session.RootForLegacyDir(config.ProjectSessionDir(root))
|
||||
service, err := a.historicalSessionService(storeRoot)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
info, err := service.Query().Stat(a.bootContext(), session.SessionRef{HostID: localDesktopHostID, SessionID: id})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
same, err := sameDesktopPathStrict(info.CWD, root)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !same {
|
||||
return "", errSessionWorkspaceConflict
|
||||
}
|
||||
path := filepath.Join(storeRoot, id)
|
||||
if !nativeStoredDirectory(path) {
|
||||
return "", session.ErrSessionNotFound
|
||||
}
|
||||
return path, nil
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/agent"
|
||||
"reasonix/internal/control"
|
||||
"reasonix/internal/event"
|
||||
"reasonix/internal/provider"
|
||||
"reasonix/internal/session"
|
||||
"reasonix/internal/tool"
|
||||
)
|
||||
|
||||
type compactionAdmissionSink struct {
|
||||
*tabEventSink
|
||||
states chan event.RuntimeStateSnapshot
|
||||
}
|
||||
|
||||
func (s *compactionAdmissionSink) RuntimeStateChanged(state event.RuntimeStateSnapshot) {
|
||||
s.states <- state
|
||||
}
|
||||
|
||||
func awaitCompactionIdle(t *testing.T, ctrl *control.Controller, states <-chan event.RuntimeStateSnapshot) {
|
||||
t.Helper()
|
||||
timeout := time.After(10 * time.Second)
|
||||
for {
|
||||
select {
|
||||
case state := <-states:
|
||||
if !state.Running && state.Maintenance == nil && !ctrl.RuntimeStatus().Running {
|
||||
return
|
||||
}
|
||||
case <-timeout:
|
||||
t.Fatal("maintenance did not release its runtime owner")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompactionAdmissionReleasesManagementReservation(t *testing.T) {
|
||||
for _, name := range []string{"receipt", "legacy", "display"} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
prov := &compactReceiptProvider{started: make(chan struct{}, 8), release: make(chan struct{}, 8)}
|
||||
sess := agent.NewSession("system")
|
||||
for range 8 {
|
||||
sess.Add(provider.Message{Role: provider.RoleUser, Content: strings.Repeat("question ", 300)})
|
||||
sess.Add(provider.Message{Role: provider.RoleAssistant, Content: strings.Repeat("answer ", 300)})
|
||||
}
|
||||
sink := &compactionAdmissionSink{tabEventSink: &tabEventSink{tabID: "compact"}, states: make(chan event.RuntimeStateSnapshot, 128)}
|
||||
exec := agent.New(prov, tool.NewRegistry(), sess, agent.Options{ContextWindow: 32_000}, sink)
|
||||
dir := t.TempDir()
|
||||
service, err := session.NewService("desktop", session.NewFilesystemPersistence(filepath.Join(dir, "sessions-v5")))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = service.CloseAll(context.Background()) })
|
||||
runtime, err := service.Create(t.Context(), session.CreateOptions{SessionID: "compact"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ctrl := control.New(control.Options{Runner: exec, Executor: exec, Sink: sink, SessionService: service, SessionRuntime: runtime, ExclusiveSession: true})
|
||||
cleanupExactTurnController(t, ctrl)
|
||||
tab := &WorkspaceTab{ID: "compact", Scope: "global", Ready: true, Ctrl: ctrl, sink: sink.tabEventSink}
|
||||
app := &App{tabs: map[string]*WorkspaceTab{tab.ID: tab}, activeTabID: tab.ID}
|
||||
submit := func(input, id string) error {
|
||||
switch name {
|
||||
case "legacy":
|
||||
return app.SubmitToTabWithID(tab.ID, input, id)
|
||||
case "display":
|
||||
return app.SubmitDisplayToTabWithID(tab.ID, input, input, id)
|
||||
}
|
||||
_, err := app.StartTurnForTab(tab.ID, input, id)
|
||||
return err
|
||||
}
|
||||
|
||||
for cycle := range 2 {
|
||||
if err := submit("/compact preserve decisions", "compact"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
select {
|
||||
case <-prov.started:
|
||||
case <-time.After(10 * time.Second):
|
||||
t.Fatal("summary provider did not start")
|
||||
}
|
||||
if _, err := app.StartTurnForTab(tab.ID, "must remain blocked", "blocked"); !errors.Is(err, control.ErrTurnRunning) {
|
||||
t.Fatalf("active maintenance lost admission fence: %v", err)
|
||||
}
|
||||
prov.release <- struct{}{}
|
||||
awaitCompactionIdle(t, ctrl, sink.states)
|
||||
if !sink.tryBeginTurn("probe") {
|
||||
t.Fatal("completed maintenance retained desktop turn reservation")
|
||||
}
|
||||
sink.cancelTurnStart()
|
||||
// A new send must reach the provider and supply history for compaction.
|
||||
if err := submit(strings.Repeat("new task detail ", 500), fmt.Sprintf("next-%d", cycle)); err != nil {
|
||||
t.Fatalf("next send: %v", err)
|
||||
}
|
||||
select {
|
||||
case <-prov.started:
|
||||
case <-time.After(10 * time.Second):
|
||||
t.Fatal("next send did not reach provider")
|
||||
}
|
||||
prov.release <- struct{}{}
|
||||
awaitCompactionIdle(t, ctrl, sink.states)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestManagementAdmissionDoesNotReleaseStartedTurn(t *testing.T) {
|
||||
sink := &tabEventSink{}
|
||||
if !sink.tryBeginTurn("management") {
|
||||
t.Fatal("reserve management")
|
||||
}
|
||||
sink.Emit(event.Event{Kind: event.TurnStarted})
|
||||
sink.cancelTurnStart()
|
||||
if sink.tryBeginTurn("new") {
|
||||
t.Fatal("management completion released an independently started turn")
|
||||
}
|
||||
sink.Emit(event.Event{Kind: event.TurnDone})
|
||||
if !sink.tryBeginTurn("new") {
|
||||
t.Fatal("completed turn retained reservation")
|
||||
}
|
||||
}
|
||||
|
||||
func TestManagementAdmissionPreservesTurnDoneFanout(t *testing.T) {
|
||||
sink := &tabEventSink{}
|
||||
gate := &turnFanoutGate{kind: event.TurnDone, entered: make(chan struct{}), release: make(chan struct{})}
|
||||
sink.SetBotSink(gate)
|
||||
if !sink.tryBeginTurn("management") {
|
||||
t.Fatal("reserve management")
|
||||
}
|
||||
sink.Emit(event.Event{Kind: event.TurnStarted})
|
||||
done := make(chan struct{})
|
||||
go func() { sink.Emit(event.Event{Kind: event.TurnDone}); close(done) }()
|
||||
select {
|
||||
case <-gate.entered:
|
||||
case <-time.After(10 * time.Second):
|
||||
t.Fatal("TurnDone fan-out did not start")
|
||||
}
|
||||
sink.cancelTurnStart()
|
||||
admitted := sink.tryBeginTurn("new")
|
||||
close(gate.release)
|
||||
<-done
|
||||
if admitted {
|
||||
t.Fatal("management completion bypassed TurnDone fan-out fence")
|
||||
}
|
||||
if !sink.tryBeginTurn("new") {
|
||||
t.Fatal("settled fan-out retained reservation")
|
||||
}
|
||||
}
|
||||
@@ -46,7 +46,11 @@ type DeliveryWorktreeOpenResult = IsolatedWorktreeOpenResult
|
||||
// optional Git isolation path. A false result never disables writing itself;
|
||||
// the cross-platform workspace writer lease remains the no-Git fallback.
|
||||
func (a *App) IsolatedWorktreeAvailability(workspaceRoot string) worktree.Availability {
|
||||
return inspectDeliveryWorktree(a.bootContext(), workspaceRoot)
|
||||
repo, err := a.workspaceRepoForRoot(workspaceRoot)
|
||||
if err != nil {
|
||||
return worktree.Availability{Reason: err.Error()}
|
||||
}
|
||||
return inspectDeliveryWorktree(a.bootContext(), repo)
|
||||
}
|
||||
|
||||
// CreateIsolatedWorktree creates a durable branch-backed worktree and opens it
|
||||
@@ -61,7 +65,11 @@ func (a *App) CreateIsolatedWorktree(workspaceRoot string) (IsolatedWorktreeOpen
|
||||
return worktree.Result{}, err
|
||||
}
|
||||
defer releaseAdmission()
|
||||
return createDeliveryWorktree(a.bootContext(), workspaceRoot, config.DeliveryWorktreeDir())
|
||||
repo, err := a.workspaceRepoForRoot(workspaceRoot)
|
||||
if err != nil {
|
||||
return worktree.Result{}, err
|
||||
}
|
||||
return createDeliveryWorktree(a.bootContext(), repo, config.DeliveryWorktreeDir())
|
||||
}()
|
||||
if err != nil {
|
||||
return IsolatedWorktreeOpenResult{}, err
|
||||
|
||||
@@ -7,13 +7,15 @@ import (
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/config"
|
||||
"reasonix/internal/gitcmd"
|
||||
"reasonix/internal/worktree"
|
||||
)
|
||||
|
||||
func TestDeliveryWorktreeAvailabilityDelegatesWithoutRequiringGit(t *testing.T) {
|
||||
original := inspectDeliveryWorktree
|
||||
t.Cleanup(func() { inspectDeliveryWorktree = original })
|
||||
inspectDeliveryWorktree = func(_ context.Context, root string) worktree.Availability {
|
||||
inspectDeliveryWorktree = func(_ context.Context, repo gitcmd.Repo) worktree.Availability {
|
||||
root := filepath.Base(repo.Dir)
|
||||
return worktree.Availability{Available: false, Reason: "Git is not installed", RepoRoot: root}
|
||||
}
|
||||
got := NewApp().DeliveryWorktreeAvailability("project")
|
||||
@@ -35,7 +37,8 @@ func TestCreateDeliveryWorktreeRegistersAndOpensManagedProject(t *testing.T) {
|
||||
|
||||
original := createDeliveryWorktree
|
||||
t.Cleanup(func() { createDeliveryWorktree = original })
|
||||
createDeliveryWorktree = func(_ context.Context, source, gotManaged string) (worktree.Result, error) {
|
||||
createDeliveryWorktree = func(_ context.Context, sourceRepo gitcmd.Repo, gotManaged string) (worktree.Result, error) {
|
||||
source := filepath.Base(sourceRepo.Dir)
|
||||
if source != "source-project" {
|
||||
t.Fatalf("source = %q", source)
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
@@ -23,8 +24,12 @@ func acquireDesktopProjectsFileLock() (func(), error) {
|
||||
|
||||
// updateProjectsFileCrossProcessLocked requires desktopProjectsFileMu and the
|
||||
// desktop-projects cross-process file lock.
|
||||
func updateProjectsFileCrossProcessLocked(mutator func(*desktopProjectFile) (bool, error)) error {
|
||||
func updateProjectsFileCrossProcessLocked(mutator func(*desktopProjectFile) (bool, error), assignCollisions bool) error {
|
||||
f := loadProjectsFile()
|
||||
previous := make([]string, 0, len(f.Projects))
|
||||
for _, project := range f.Projects {
|
||||
previous = append(previous, project.Root)
|
||||
}
|
||||
changed, err := mutator(&f)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -32,5 +37,10 @@ func updateProjectsFileCrossProcessLocked(mutator func(*desktopProjectFile) (boo
|
||||
if !changed {
|
||||
return nil
|
||||
}
|
||||
if assignCollisions {
|
||||
if err := assignAddedProjectStateCollisions(previous, f.Projects); err != nil {
|
||||
return fmt.Errorf("%w: %w", errProjectStateCollisionAssignment, err)
|
||||
}
|
||||
}
|
||||
return saveProjectsFile(f)
|
||||
}
|
||||
@@ -11,6 +11,8 @@
|
||||
"typecheck": "tsc -p tsconfig.json && tsc -p tsconfig.preload.json",
|
||||
"test": "node scripts/build-browser-page.mjs && node --import tsx --test \"src/**/*.test.ts\"",
|
||||
"test:graphics-recovery": "node scripts/graphics-recovery-smoke.mjs",
|
||||
"test:attachments": "node scripts/attachment-smoke.mjs",
|
||||
"test:composer-close": "node scripts/composer-close-smoke.mjs",
|
||||
"start": "node scripts/build.mjs && node scripts/start.mjs",
|
||||
"dev": "node scripts/build.mjs && node scripts/start.mjs --dev",
|
||||
"smoke": "node scripts/build.mjs && node scripts/smoke.mjs"
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdtempSync, mkdirSync, openSync, ftruncateSync, closeSync, statSync, writeFileSync, readFileSync, readdirSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { resolve, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { _electron } from "playwright";
|
||||
|
||||
const root = resolve(fileURLToPath(new URL("..", import.meta.url)));
|
||||
const home = mkdtempSync(join(tmpdir(), "reasonix-attachment-native-"));
|
||||
writeFileSync(join(home, "config.toml"), "");
|
||||
const workspace = join(home, "workspace");
|
||||
mkdirSync(workspace);
|
||||
const nativeFiles = ["large app.apk", "pasted app.apk", "dropped app.apk"].map(name => {
|
||||
const path = join(home, name);
|
||||
const fd = openSync(path, "w");
|
||||
ftruncateSync(fd, 120 * 1024 * 1024);
|
||||
closeSync(fd);
|
||||
return path;
|
||||
});
|
||||
const [apk] = nativeFiles;
|
||||
const report = { checks: [], errors: [] };
|
||||
const check = name => { report.checks.push(name); console.log(`PASS ${name}`); };
|
||||
const invoke = (page, name, args = []) => page.evaluate(({ name, args }) => window.reasonixDesktop.invoke(name, args), { name, args });
|
||||
async function until(predicate) {
|
||||
const deadline = Date.now() + 60000;
|
||||
while (Date.now() < deadline) {
|
||||
if (await predicate()) return;
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
}
|
||||
throw new Error("Attachment smoke condition timed out");
|
||||
}
|
||||
const shell = await _electron.launch({ executablePath: process.env.REASONIX_TEST_ELECTRON, args: [root, `--user-data-dir=${join(home, "electron")}`], env: {
|
||||
...process.env, REASONIX_HOME: home, REASONIX_STATE_HOME: home, REASONIX_CACHE_HOME: join(home, "cache"),
|
||||
REASONIX_DEV: "1", REASONIX_DESKTOP_SERVICE: process.env.REASONIX_DESKTOP_SERVICE || resolve(root, "../build/bin/reasonix-desktop-service" + (process.platform === "win32" ? ".exe" : "")),
|
||||
}, timeout: 60000 });
|
||||
try {
|
||||
const page = await shell.firstWindow();
|
||||
page.setDefaultTimeout(30000);
|
||||
page.on("pageerror", error => report.errors.push(error.message));
|
||||
await page.waitForURL("reasonix://app/index.html", { waitUntil: "domcontentloaded", timeout: 60000 });
|
||||
await page.waitForFunction(() => Boolean(window.reasonixDesktop) && !document.querySelector(".boot-shell"), null, { timeout: 60000 });
|
||||
await until(async () => { try { await invoke(page, "Version"); return true; } catch { return false; } });
|
||||
const operationId = "attachment-native-smoke";
|
||||
await invoke(page, "BeginManualSessionCreation", [{ operationId, scope: "project", workspaceRoot: workspace }]);
|
||||
await until(async () => {
|
||||
const creation = await invoke(page, "GetManualSessionCreation", [operationId]);
|
||||
if (creation.phase === "failed") throw new Error(creation.error);
|
||||
return creation.phase === "ready";
|
||||
});
|
||||
const creation = await invoke(page, "GetManualSessionCreation", [operationId]);
|
||||
report.session = creation.ref;
|
||||
const tabs = await invoke(page, "ListTabs");
|
||||
const tab = tabs.find(item => item.sessionId === creation.ref.sessionId);
|
||||
assert.ok(tab, JSON.stringify(tabs));
|
||||
await invoke(page, "SetActiveTab", [tab.id]);
|
||||
await page.reload();
|
||||
const input = page.locator(".composer-content-file-input");
|
||||
await input.waitFor({ state: "attached", timeout: 60000 });
|
||||
await page.waitForFunction(() => !document.querySelector(".composer-content-file-input")?.disabled);
|
||||
await page.locator(".startup-splash").waitFor({ state: "hidden", timeout: 60000 });
|
||||
await page.locator("#composer-input").waitFor({ state: "visible" });
|
||||
let uploads = 0;
|
||||
page.on("request", request => { if (request.url().includes("/__reasonix_attachment_upload")) uploads++; });
|
||||
await input.setInputFiles(apk);
|
||||
await until(async () => {
|
||||
const state = await invoke(page, "GetSessionComposerState", [creation.ref]);
|
||||
return JSON.parse(state.contentJson).workspaceRefs?.some(item => item.path.endsWith("large app.apk"));
|
||||
});
|
||||
assert.equal(uploads, 0);
|
||||
assert.equal(statSync(apk).size, 120 * 1024 * 1024);
|
||||
check("120 MiB native picker adds a path reference without an HTTP upload");
|
||||
await page.evaluate(() => {
|
||||
const source = document.createElement("input");
|
||||
source.type = "file";
|
||||
source.id = "attachment-smoke-source";
|
||||
document.body.append(source);
|
||||
});
|
||||
for (const [index, kind] of ["paste", "drop"].entries()) {
|
||||
await page.locator("#attachment-smoke-source").setInputFiles(nativeFiles[index + 1]);
|
||||
await page.evaluate(kind => {
|
||||
const transfer = new DataTransfer();
|
||||
transfer.items.add(document.querySelector("#attachment-smoke-source").files[0]);
|
||||
const event = kind === "paste"
|
||||
? new ClipboardEvent("paste", { clipboardData: transfer, bubbles: true })
|
||||
: new DragEvent("drop", { dataTransfer: transfer, bubbles: true });
|
||||
document.querySelector("#composer-input").dispatchEvent(event);
|
||||
}, kind);
|
||||
await until(async () => {
|
||||
const state = await invoke(page, "GetSessionComposerState", [creation.ref]);
|
||||
return JSON.parse(state.contentJson).workspaceRefs?.length === index + 2;
|
||||
});
|
||||
assert.equal(uploads, 0);
|
||||
check(`120 MiB native ${kind} adds a path reference without an HTTP upload`);
|
||||
}
|
||||
await page.evaluate(() => {
|
||||
const transfer = new DataTransfer();
|
||||
const chunk = new Uint8Array(64 * 1024).fill(37);
|
||||
transfer.items.add(new File(Array(1280).fill(chunk), "virtual.apk", { type: "application/vnd.android.package-archive" }));
|
||||
const input = document.querySelector(".composer-content-file-input");
|
||||
input.files = transfer.files;
|
||||
input.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
});
|
||||
await until(async () => {
|
||||
const state = await invoke(page, "GetSessionComposerState", [creation.ref]);
|
||||
return JSON.parse(state.contentJson).attachments?.some(item => item.displayName === "virtual.apk");
|
||||
});
|
||||
const saved = JSON.parse((await invoke(page, "GetSessionComposerState", [creation.ref])).contentJson);
|
||||
const uploaded = saved.attachments.find(item => item.displayName === "virtual.apk");
|
||||
const stored = join(workspace, uploaded.path);
|
||||
assert.equal(statSync(stored).size, 80 * 1024 * 1024);
|
||||
assert.equal(uploads, 1);
|
||||
assert.equal(readFileSync(stored).every(byte => byte === 37), true);
|
||||
check("80 MiB pathless File streams through the real Electron protocol and Go service byte-for-byte");
|
||||
writeFileSync(join(home, "config.toml"), "[attachments]\nmax_stream_bytes = 4\n");
|
||||
const rejected = page.waitForResponse(response => response.url().includes("/__reasonix_attachment_upload") && response.status() === 413);
|
||||
await page.evaluate(() => {
|
||||
const transfer = new DataTransfer();
|
||||
transfer.items.add(new File(["12345"], "oversize.apk"));
|
||||
const input = document.querySelector(".composer-content-file-input");
|
||||
input.files = transfer.files;
|
||||
input.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
});
|
||||
const failure = await (await rejected).json();
|
||||
assert.equal(failure.code, "attachment.too_large");
|
||||
assert.equal(failure.limit_bytes, 4);
|
||||
assert.equal(failure.limit_source, "user");
|
||||
try {
|
||||
const notice = page.locator(".toast--warn").filter({ hasText: "oversize.apk" });
|
||||
await notice.waitFor({ state: "visible" });
|
||||
assert.ok((await notice.innerText()).includes(String(failure.limit_bytes / (1024 * 1024))));
|
||||
} catch (error) {
|
||||
writeFileSync(join(home, "limit-failure-dom.txt"), await page.locator("body").innerText());
|
||||
await page.screenshot({ path: join(home, "limit-failure.png") });
|
||||
throw error;
|
||||
}
|
||||
assert.equal(readdirSync(join(workspace, ".reasonix", "attachments")).length, 1);
|
||||
check("user byte ceiling returns typed 413, displays its reason, and removes the partial file");
|
||||
writeFileSync(join(home, "config.toml"), "[attachments]\nmax_stream_bytes = 0\n");
|
||||
await page.evaluate(() => {
|
||||
const transfer = new DataTransfer();
|
||||
transfer.items.add(new File(["123456"], "unlimited.apk"));
|
||||
const input = document.querySelector(".composer-content-file-input");
|
||||
input.files = transfer.files;
|
||||
input.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
});
|
||||
await until(async () => {
|
||||
const state = await invoke(page, "GetSessionComposerState", [creation.ref]);
|
||||
return JSON.parse(state.contentJson).attachments?.some(item => item.displayName === "unlimited.apk");
|
||||
});
|
||||
assert.equal(uploads, 3);
|
||||
check("explicit user zero disables the ordinary stream ceiling without restarting");
|
||||
assert.equal(typeof await invoke(page, "Version"), "string");
|
||||
assert.equal(report.errors.length, 0, report.errors.join("\n"));
|
||||
assert.equal(await page.locator("[data-crash-overlay], .error-boundary").count(), 0);
|
||||
await page.locator("#composer-input").fill("Attachment smoke remains responsive");
|
||||
assert.equal(await page.locator("#composer-input").inputValue(), "Attachment smoke remains responsive");
|
||||
await page.screenshot({ path: join(home, "attachments.png") });
|
||||
check("renderer and service remain responsive after large-file uploads and limit rejection");
|
||||
} finally {
|
||||
await shell.close();
|
||||
writeFileSync(join(home, "attachment-smoke-results.json"), JSON.stringify(report, null, 2));
|
||||
console.log(`Evidence: ${home}`);
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
// Native window close + the real renderer and Go store, using disposable data.
|
||||
// Build frontend, service and shell first; no provider or clipboard is used.
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdtempSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { resolve, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { setTimeout as delay } from "node:timers/promises";
|
||||
import { _electron } from "playwright";
|
||||
|
||||
const root = resolve(fileURLToPath(new URL("..", import.meta.url)));
|
||||
const home = mkdtempSync(join(tmpdir(), "reasonix-composer-close-"));
|
||||
writeFileSync(join(home, "config.toml"), "");
|
||||
const report = { home, platform: process.platform, checks: [] };
|
||||
const check = name => { report.checks.push(name); console.log(`PASS ${name}`); };
|
||||
const rpc = (page, name, ...args) => page.evaluate(({ name, args }) => window.reasonixDesktop.invoke(name, args), { name, args });
|
||||
const inputSelector = "textarea.composer__input:not([aria-hidden=true])";
|
||||
let shell;
|
||||
let shellStderr = "";
|
||||
function terminateFixture() {
|
||||
// Playwright launches a dedicated process group on POSIX. Include its
|
||||
// helpers so an orphaned network service cannot retain the CDP connection.
|
||||
try {
|
||||
if (process.platform === "win32") shell.process().kill("SIGKILL");
|
||||
else process.kill(-shell.process().pid, "SIGKILL");
|
||||
} catch (error) { if (error.code !== "ESRCH") throw error; }
|
||||
}
|
||||
async function launch() {
|
||||
shell = await _electron.launch({ args: [root, `--user-data-dir=${join(home, "electron")}`], env: {
|
||||
...process.env, REASONIX_HOME: home, REASONIX_STATE_HOME: home, REASONIX_CACHE_HOME: join(home, "cache"),
|
||||
REASONIX_DEV: "1", REASONIX_DESKTOP_SERVICE: resolve(root, "../build/bin/reasonix-desktop-service" + (process.platform === "win32" ? ".exe" : "")),
|
||||
}, timeout: 60000 });
|
||||
shellStderr = "";
|
||||
shell.process().stderr.on("data", chunk => { shellStderr += chunk; });
|
||||
const page = await shell.firstWindow();
|
||||
page.setDefaultTimeout(30000);
|
||||
await page.waitForURL("reasonix://app/index.html", { timeout: 60000 });
|
||||
await page.waitForFunction(() => Boolean(window.__reasonixFlushSessionDraft) && !document.querySelector(".boot-shell"));
|
||||
await page.addLocatorHandler(page.locator(".management-screen__back:visible"), button => button.click());
|
||||
await page.locator(".sidebar__quick-action:visible").first().click({ trial: true });
|
||||
return page;
|
||||
}
|
||||
async function closeWindow() {
|
||||
const page = await shell.firstWindow();
|
||||
const mainWindow = await shell.browserWindow(page);
|
||||
const closed = page.waitForEvent("close", { timeout: 30000 });
|
||||
// BrowserWindow.close dispatches the same native close event as the titlebar.
|
||||
await mainWindow.evaluate(win => win.close());
|
||||
await closed;
|
||||
// Native shutdown must destroy the window first. Only then detach
|
||||
// Playwright's Node inspector, which otherwise keeps Electron alive at exit.
|
||||
const closing = shell.close();
|
||||
const detached = await Promise.race([closing.then(() => true), delay(5000).then(() => false)]);
|
||||
if (!detached) {
|
||||
assert.match(shellStderr, /shutdown complete .*reason=user_quit/, "native service shutdown must finish before fixture cleanup");
|
||||
assert.match(shellStderr, /resources cleaned; requesting final shell exit/, "native resource cleanup must finish before fixture cleanup");
|
||||
assert.match(shellStderr, /Waiting for the debugger to disconnect|Debugger ending on/, "only debugger teardown may need fixture cleanup after native shutdown");
|
||||
terminateFixture();
|
||||
await closing;
|
||||
console.log("INFO cleaned up debugger-held fixture after native window close");
|
||||
}
|
||||
shell = undefined;
|
||||
}
|
||||
async function composer(page) {
|
||||
const input = page.locator(inputSelector);
|
||||
await input.waitFor();
|
||||
await page.waitForFunction(selector => !document.querySelector(selector)?.disabled, inputSelector);
|
||||
return input;
|
||||
}
|
||||
|
||||
try {
|
||||
let page = await launch();
|
||||
await rpc(page, "SetCloseBehavior", "background");
|
||||
const mainWindow = await shell.browserWindow(page);
|
||||
await mainWindow.evaluate(win => new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => { clearInterval(poll); reject(new Error("Window did not hide after close")); }, 30000);
|
||||
const poll = setInterval(() => { if (!win.isVisible()) { clearInterval(poll); clearTimeout(timer); resolve(); } }, 50);
|
||||
win.close();
|
||||
}));
|
||||
check("empty welcome closes to background without a save error");
|
||||
await mainWindow.evaluate(win => win.show());
|
||||
// Exercise full shutdown and disk recovery independently of the hide preference.
|
||||
await rpc(page, "SetCloseBehavior", "quit");
|
||||
await closeWindow();
|
||||
check("empty welcome closes through the native window event");
|
||||
|
||||
page = await launch();
|
||||
await page.locator(".sidebar__quick-action:visible").first().click();
|
||||
let input = await composer(page);
|
||||
assert.equal(await input.inputValue(), "");
|
||||
await closeWindow();
|
||||
check("an empty formal session closes without a save error");
|
||||
|
||||
page = await launch();
|
||||
input = await composer(page);
|
||||
const tab = (await rpc(page, "ListTabs")).find(item => item.active);
|
||||
assert.ok(tab?.sessionId);
|
||||
const ref = { hostId: "local", sessionId: tab.sessionId };
|
||||
const text = "Keep the latest unsent draft 中文";
|
||||
await input.fill(text);
|
||||
// Do not wait for the 250 ms autosave or invoke the renderer flush manually.
|
||||
await closeWindow();
|
||||
page = await launch();
|
||||
input = await composer(page);
|
||||
assert.equal(await input.inputValue(), text);
|
||||
assert.equal(JSON.parse((await rpc(page, "GetSessionComposerState", ref)).contentJson).text, text);
|
||||
check("immediate native close saves the latest text and restores it after restart");
|
||||
|
||||
const external = join(home, "external.txt");
|
||||
writeFileSync(external, "disposable external file reference");
|
||||
await page.locator(".composer-content-file-input").setInputFiles(external);
|
||||
// waitForFunction treats an async predicate's Promise as truthy before
|
||||
// its result is known. Poll awaited RPCs and retain the matching snapshot.
|
||||
let content;
|
||||
const deadline = Date.now() + 30000;
|
||||
while (Date.now() < deadline) {
|
||||
const candidate = JSON.parse((await rpc(page, "GetSessionComposerState", ref)).contentJson);
|
||||
if (candidate.workspaceRefs?.some(item => item.path.endsWith("external.txt"))) { content = candidate; break; }
|
||||
await delay(50);
|
||||
}
|
||||
assert.ok(content, "external reference must be saved before restart");
|
||||
assert.equal(content.text, text, "saving a reference must preserve the existing text");
|
||||
await closeWindow();
|
||||
page = await launch();
|
||||
await composer(page);
|
||||
assert.deepEqual(JSON.parse((await rpc(page, "GetSessionComposerState", ref)).contentJson), content);
|
||||
await page.locator(".composer-context__item").first().waitFor();
|
||||
await closeWindow();
|
||||
check("external references survive restart and another normal close without losing saved input");
|
||||
} catch (error) {
|
||||
report.error = String(error);
|
||||
if (shell) {
|
||||
const page = shell.windows()[0];
|
||||
if (page && !page.isClosed()) writeFileSync(join(home, "failure.txt"), await page.locator("body").innerText().catch(() => "renderer unavailable"));
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
// Only terminate this isolated fixture after a failed normal-close check.
|
||||
if (shell) terminateFixture();
|
||||
writeFileSync(join(home, "results.json"), JSON.stringify(report, null, 2));
|
||||
console.log(`Evidence: ${join(home, "results.json")}`);
|
||||
}
|
||||
@@ -86,6 +86,128 @@ test("only the resource prefixes forward to the loopback origin, query included"
|
||||
assert.equal(route("reasonix://app/__shell/quit").kind, "notFound");
|
||||
});
|
||||
|
||||
test("attachment uploads forward only the exact upload route, preserving the filename and target", () => {
|
||||
const query = "?target=chat%2Fsession&name=large%20app.apk";
|
||||
assert.deepEqual(route(`reasonix://app/__reasonix_attachment_upload${query}`), {
|
||||
kind: "forward",
|
||||
target: `/__reasonix_attachment_upload${query}`,
|
||||
});
|
||||
for (const path of ["/__reasonix_attachment_upload/", "/__reasonix_attachment_upload/extra", "/__reasonix_attachment_uploadx"]) {
|
||||
assert.equal(isForwardedPath(path), false, path);
|
||||
assert.equal(route(`reasonix://app${path}`).kind, "notFound", path);
|
||||
}
|
||||
});
|
||||
|
||||
test("upload forwarding delivers chunks before the source finishes and preserves cancellation", async () => {
|
||||
let handler!: (request: Request) => Promise<Response> | Response;
|
||||
let source!: ReadableStreamDefaultController<Uint8Array>;
|
||||
const chunks = [new Uint8Array([0x50, 0x4b, 0x03, 0x04]), new Uint8Array([0, 255, 128, 1])];
|
||||
const stream = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
source = controller;
|
||||
controller.enqueue(chunks[0]);
|
||||
},
|
||||
});
|
||||
const controller = new AbortController();
|
||||
const query = "?target=chat%2Fsession&name=large%20app.apk";
|
||||
const request = new Request(`reasonix://app/__reasonix_attachment_upload${query}`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/octet-stream",
|
||||
authorization: "Bearer renderer-controlled",
|
||||
cookie: "private=value",
|
||||
origin: "https://untrusted.example",
|
||||
"x-untrusted": "not-forwarded",
|
||||
},
|
||||
body: stream,
|
||||
signal: controller.signal,
|
||||
duplex: "half",
|
||||
} as RequestInit);
|
||||
// Reading through any whole-body convenience method would recreate the
|
||||
// large-file allocation that this transport is intended to avoid.
|
||||
request.arrayBuffer = async () => { throw new Error("upload was buffered with arrayBuffer()"); };
|
||||
request.text = async () => { throw new Error("upload was buffered with text()"); };
|
||||
request.blob = async () => { throw new Error("upload was buffered with blob()"); };
|
||||
let forwarded = false;
|
||||
registerAppProtocol({
|
||||
protocol: { handle: (_scheme, callback) => { handler = callback; } },
|
||||
fetch: async (input, init) => {
|
||||
forwarded = true;
|
||||
assert.equal(input, `http://127.0.0.1:12345/__reasonix_attachment_upload${query}`);
|
||||
assert.equal(init.method, "POST");
|
||||
assert.equal(init.body, request.body);
|
||||
assert.equal((init as RequestInit & { duplex?: string }).duplex, "half");
|
||||
assert.equal(init.signal, request.signal);
|
||||
assert.equal(init.bypassCustomProtocolHandlers, true);
|
||||
const headers = new Headers(init.headers);
|
||||
assert.equal(headers.get("authorization"), "Bearer server-secret");
|
||||
assert.equal(headers.get("content-type"), "application/octet-stream");
|
||||
for (const name of ["cookie", "origin", "x-untrusted"]) assert.equal(headers.get(name), null, name);
|
||||
|
||||
const reader = (init.body as ReadableStream<Uint8Array>).getReader();
|
||||
assert.deepEqual(await reader.read(), { done: false, value: chunks[0] });
|
||||
// The final chunk does not exist until the origin has received the first.
|
||||
// Aggregating the body before forwarding cannot complete this exchange.
|
||||
source.enqueue(chunks[1]);
|
||||
source.close();
|
||||
assert.deepEqual(await reader.read(), { done: false, value: chunks[1] });
|
||||
assert.equal((await reader.read()).done, true);
|
||||
controller.abort();
|
||||
assert.equal(init.signal?.aborted, true);
|
||||
return new Response('{"name":"large app.apk"}', { status: 201, headers: { "content-type": "application/json" } });
|
||||
},
|
||||
distRoot: dist,
|
||||
resources: () => ({ origin: "http://127.0.0.1:12345", token: "server-secret" }),
|
||||
log: { info() {}, warn() {}, error() {} },
|
||||
});
|
||||
const response = await handler(request);
|
||||
assert.equal(forwarded, true, "the upload must reach the resource origin");
|
||||
assert.equal(response.status, 201);
|
||||
assert.deepEqual(await response.json(), { name: "large app.apk" });
|
||||
});
|
||||
|
||||
test("upload routes reject methods other than POST and unsupported body formats", async () => {
|
||||
let handler!: (request: Request) => Promise<Response> | Response;
|
||||
let forwarded = 0;
|
||||
registerAppProtocol({
|
||||
protocol: { handle: (_scheme, callback) => { handler = callback; } },
|
||||
fetch: async () => { forwarded++; return new Response("unexpected forward"); },
|
||||
distRoot: dist,
|
||||
resources: () => ({ origin: "http://127.0.0.1:12345", token: "server-secret" }),
|
||||
log: { info() {}, warn() {}, error() {} },
|
||||
});
|
||||
for (const method of ["GET", "HEAD", "PUT", "PATCH", "DELETE"]) {
|
||||
const response = await handler(new Request("reasonix://app/__reasonix_attachment_upload?name=large.apk", { method }));
|
||||
assert.equal(response.status, 405, method);
|
||||
}
|
||||
for (const contentType of ["application/json", "multipart/form-data; boundary=test", "text/plain"]) {
|
||||
const response = await handler(new Request("reasonix://app/__reasonix_attachment_upload?name=large.apk", {
|
||||
method: "POST", headers: { "content-type": contentType }, body: "not raw bytes",
|
||||
}));
|
||||
assert.equal(response.status, 415, contentType);
|
||||
}
|
||||
assert.equal(forwarded, 0);
|
||||
});
|
||||
|
||||
test("asset routes cannot be used to forward writes or request bodies", async () => {
|
||||
let handler!: (request: Request) => Promise<Response> | Response;
|
||||
let forwarded = 0;
|
||||
registerAppProtocol({
|
||||
protocol: { handle: (_scheme, callback) => { handler = callback; } },
|
||||
fetch: async () => { forwarded++; return new Response("unexpected forward"); },
|
||||
distRoot: dist,
|
||||
resources: () => ({ origin: "http://127.0.0.1:12345", token: "server-secret" }),
|
||||
log: { info() {}, warn() {}, error() {} },
|
||||
});
|
||||
for (const path of ["/__reasonix_workspace_media/token/a.png", "/__reasonix_theme_asset/a.png", "/__reasonix_remote_markdown_image?url=https%3A%2F%2Fexample.com%2Fa.png", "/assets/app.js"]) {
|
||||
for (const method of ["POST", "PUT", "PATCH", "DELETE"]) {
|
||||
const response = await handler(new Request(`reasonix://app${path}`, { method, body: "must not reach the origin" }));
|
||||
assert.equal(response.status, 405, `${method} ${path}`);
|
||||
}
|
||||
}
|
||||
assert.equal(forwarded, 0);
|
||||
});
|
||||
|
||||
test("the dist root honours the override, then packaging layout", () => {
|
||||
assert.equal(resolveDistRoot({ env: { REASONIX_FRONTEND_DIST: "/tmp/dist" }, appPath: "/app/electron", resourcesPath: "/res", packaged: true }), "/tmp/dist");
|
||||
assert.equal(resolveDistRoot({ env: {}, appPath: "/app/electron", resourcesPath: "/res", packaged: false }), "/app/frontend/dist");
|
||||
|
||||
@@ -15,6 +15,8 @@ export const FORWARDED_PATHS = [
|
||||
"/__reasonix_remote_markdown_image",
|
||||
] as const;
|
||||
|
||||
const ATTACHMENT_UPLOAD_PATH = "/__reasonix_attachment_upload";
|
||||
|
||||
export type AppRoute =
|
||||
| { kind: "file"; path: string; mime: string }
|
||||
| { kind: "forward"; target: string }
|
||||
@@ -52,7 +54,7 @@ export function mimeFor(path: string): string {
|
||||
}
|
||||
|
||||
export function isForwardedPath(pathname: string): boolean {
|
||||
return FORWARDED_PATHS.some((prefix) =>
|
||||
return pathname === ATTACHMENT_UPLOAD_PATH || FORWARDED_PATHS.some((prefix) =>
|
||||
prefix.endsWith("/") ? pathname.startsWith(prefix) : pathname === prefix || pathname.startsWith(prefix + "/"));
|
||||
}
|
||||
|
||||
@@ -125,6 +127,11 @@ export function registerAppProtocol(deps: AppProtocolDeps): void {
|
||||
deps.log.warn(`404 ${request.url}: ${route.reason}`);
|
||||
return text(404, "Not found");
|
||||
}
|
||||
const upload = new URL(request.url).pathname === ATTACHMENT_UPLOAD_PATH;
|
||||
if (upload ? request.method !== "POST" : request.method !== "GET" && request.method !== "HEAD") {
|
||||
return text(405, "Method not allowed");
|
||||
}
|
||||
if (upload && request.headers.get("content-type") !== "application/octet-stream") return text(415, "Unsupported upload type");
|
||||
if (route.kind === "forward") {
|
||||
const resources = deps.resources();
|
||||
if (!resources) return text(503, "Desktop service unavailable");
|
||||
@@ -133,10 +140,15 @@ export function registerAppProtocol(deps: AppProtocolDeps): void {
|
||||
const value = request.headers.get(name);
|
||||
if (value) headers.set(name, value);
|
||||
}
|
||||
if (upload) headers.set("content-type", "application/octet-stream");
|
||||
try {
|
||||
return await deps.fetch(resources.origin + route.target, { method: request.method, headers, bypassCustomProtocolHandlers: true });
|
||||
const init: RequestInit & { bypassCustomProtocolHandlers?: boolean; duplex?: "half" } = {
|
||||
method: request.method, headers, signal: request.signal, bypassCustomProtocolHandlers: true,
|
||||
...(upload ? { body: request.body, duplex: "half" as const } : {}),
|
||||
};
|
||||
return await deps.fetch(resources.origin + route.target, init);
|
||||
} catch (error) {
|
||||
deps.log.warn(`resource forward failed for ${route.target}: ${errorText(error)}`);
|
||||
deps.log.warn(`resource forward failed for ${new URL(request.url).pathname}: ${errorText(error)}`);
|
||||
return text(502, "Resource origin unreachable");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,6 +25,18 @@ function harness(limit?: number) {
|
||||
return { client, written, requests, notifications, errors, feed, lastFrame };
|
||||
}
|
||||
|
||||
test("oversized outgoing requests never reach or disconnect the service", async () => {
|
||||
const h = harness(256);
|
||||
await assert.rejects(h.client.request("desktop/invoke", { data: "a".repeat(257) }), OversizeFrameError);
|
||||
assert.equal(h.written.length, 0);
|
||||
assert.equal(h.client.pendingCount, 0);
|
||||
assert.equal(h.client.closed, false);
|
||||
const pending = h.client.request("ping", {});
|
||||
const sent = h.lastFrame();
|
||||
h.feed(JSON.stringify({ jsonrpc: "2.0", id: sent.id, result: "pong" }) + "\n");
|
||||
assert.equal(await pending, "pong");
|
||||
});
|
||||
|
||||
test("LineDecoder reassembles partial lines and strips CR", () => {
|
||||
const decoder = new LineDecoder();
|
||||
assert.deepEqual(decoder.push(Buffer.from('{"a":')), []);
|
||||
|
||||
@@ -82,7 +82,7 @@ export class RpcClient {
|
||||
private closedWith: Error | null = null;
|
||||
readonly stats = { ignoredLines: 0, orphanResponses: 0 };
|
||||
|
||||
constructor(private readonly transport: RpcTransport, private readonly handlers: RpcHandlers, limit = MAX_FRAME_BYTES) {
|
||||
constructor(private readonly transport: RpcTransport, private readonly handlers: RpcHandlers, private readonly limit = MAX_FRAME_BYTES) {
|
||||
this.decoder = new LineDecoder(limit);
|
||||
}
|
||||
|
||||
@@ -119,7 +119,7 @@ export class RpcClient {
|
||||
: null;
|
||||
this.pending.set(id, { resolve, reject, timer });
|
||||
try {
|
||||
this.transport.write(JSON.stringify({ jsonrpc: "2.0", id, method, params }) + "\n");
|
||||
this.writeFrame({ jsonrpc: "2.0", id, method, params });
|
||||
} catch (error) {
|
||||
this.pending.delete(id);
|
||||
if (timer) clearTimeout(timer);
|
||||
@@ -130,7 +130,14 @@ export class RpcClient {
|
||||
|
||||
notify(method: string, params: unknown): void {
|
||||
if (this.closedWith) return;
|
||||
this.transport.write(JSON.stringify({ jsonrpc: "2.0", method, params }) + "\n");
|
||||
this.writeFrame({ jsonrpc: "2.0", method, params });
|
||||
}
|
||||
|
||||
private writeFrame(frame: Record<string, unknown>): void {
|
||||
const line = JSON.stringify(frame) + "\n";
|
||||
const bytes = Buffer.byteLength(line);
|
||||
if (bytes > this.limit) throw new OversizeFrameError(bytes, this.limit);
|
||||
this.transport.write(line);
|
||||
}
|
||||
|
||||
close(error: Error): void {
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"reasonix/internal/control"
|
||||
)
|
||||
|
||||
func rewriteDraftExternalFileRef(input, path, token string) string {
|
||||
old := "@" + control.EscapeRefPath(path)
|
||||
pattern := regexp.MustCompile(regexp.QuoteMeta(old) + `($|\s)`)
|
||||
return pattern.ReplaceAllStringFunc(input, func(match string) string {
|
||||
return "@" + control.EscapeRefPath(token) + strings.TrimPrefix(match, old)
|
||||
})
|
||||
}
|
||||
+6
-3
@@ -66,7 +66,8 @@ func (a *App) forkForTabWithOptions(tabID string, turn int, isolateWorkspace boo
|
||||
if scope != "project" || strings.TrimSpace(srcRoot) == "" {
|
||||
result.FallbackToShared = true
|
||||
} else {
|
||||
avail := inspectDeliveryWorktree(a.bootContext(), srcRoot)
|
||||
srcRepo, _ := workspaceRepo(srcRoot, ctrl)
|
||||
avail := inspectDeliveryWorktree(a.bootContext(), srcRepo)
|
||||
if !avail.Available {
|
||||
result.FallbackToShared = true
|
||||
} else if avail.SourceDirty {
|
||||
@@ -80,7 +81,7 @@ func (a *App) forkForTabWithOptions(tabID string, turn int, isolateWorkspace boo
|
||||
return worktree.Result{}, err
|
||||
}
|
||||
defer releaseAdmission()
|
||||
return createDeliveryWorktree(a.bootContext(), srcRoot, config.DeliveryWorktreeDir())
|
||||
return createDeliveryWorktree(a.bootContext(), srcRepo, config.DeliveryWorktreeDir())
|
||||
}()
|
||||
if createErr != nil {
|
||||
return ForkWorktreeResultView{}, fmt.Errorf("create isolated fork worktree: %w", createErr)
|
||||
@@ -280,7 +281,9 @@ func (a *App) openForkedSessionTabWithWorkspace(sourceTab *WorkspaceTab, locator
|
||||
if err := prependTopicInProjectsFile(workspaceRoot, topicID, true); err != nil {
|
||||
slog.Warn("desktop: persist isolated fork topic", "workspace", workspaceRoot, "topic", topicID, "err", err)
|
||||
}
|
||||
a.registerProjectRoot(workspaceRoot)
|
||||
if err := a.registerProjectRoot(workspaceRoot); err != nil {
|
||||
return forkedSessionTabOpen{}, err
|
||||
}
|
||||
}
|
||||
if hook := forkTabBeforePublishHookForTest.Load(); hook != nil {
|
||||
(*hook)()
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
|
||||
"reasonix/internal/agent"
|
||||
"reasonix/internal/config"
|
||||
"reasonix/internal/gitcmd"
|
||||
"reasonix/internal/worktree"
|
||||
)
|
||||
|
||||
@@ -28,10 +29,12 @@ func TestForkWorktreeForTabCreatesIsolatedWorkspace(t *testing.T) {
|
||||
rollbackDeliveryWorktree = origRollback
|
||||
})
|
||||
|
||||
inspectDeliveryWorktree = func(_ context.Context, root string) worktree.Availability {
|
||||
inspectDeliveryWorktree = func(_ context.Context, repo gitcmd.Repo) worktree.Availability {
|
||||
root := repo.Dir
|
||||
return worktree.Availability{Available: true, RepoRoot: root, Branch: "main"}
|
||||
}
|
||||
createDeliveryWorktree = func(_ context.Context, source, gotManaged string) (worktree.Result, error) {
|
||||
createDeliveryWorktree = func(_ context.Context, sourceRepo gitcmd.Repo, gotManaged string) (worktree.Result, error) {
|
||||
source := sourceRepo.Dir
|
||||
return worktree.Result{
|
||||
WorkspaceRoot: isolatedRoot,
|
||||
WorktreeRoot: filepath.Dir(isolatedRoot),
|
||||
@@ -90,11 +93,12 @@ func TestForkWorktreeForTabRefusesDirtySourceWithoutMutation(t *testing.T) {
|
||||
inspectDeliveryWorktree = origInspect
|
||||
createDeliveryWorktree = origCreate
|
||||
})
|
||||
inspectDeliveryWorktree = func(_ context.Context, root string) worktree.Availability {
|
||||
inspectDeliveryWorktree = func(_ context.Context, repo gitcmd.Repo) worktree.Availability {
|
||||
root := repo.Dir
|
||||
return worktree.Availability{Available: true, RepoRoot: root, SourceDirty: true}
|
||||
}
|
||||
createCalls := 0
|
||||
createDeliveryWorktree = func(context.Context, string, string) (worktree.Result, error) {
|
||||
createDeliveryWorktree = func(context.Context, gitcmd.Repo, string) (worktree.Result, error) {
|
||||
createCalls++
|
||||
return worktree.Result{}, nil
|
||||
}
|
||||
@@ -125,10 +129,10 @@ func TestForkWorktreeForTabFallsBackToSharedFork(t *testing.T) {
|
||||
inspectDeliveryWorktree = origInspect
|
||||
createDeliveryWorktree = origCreate
|
||||
})
|
||||
inspectDeliveryWorktree = func(context.Context, string) worktree.Availability {
|
||||
inspectDeliveryWorktree = func(context.Context, gitcmd.Repo) worktree.Availability {
|
||||
return worktree.Availability{Reason: "not a repository"}
|
||||
}
|
||||
createDeliveryWorktree = func(context.Context, string, string) (worktree.Result, error) {
|
||||
createDeliveryWorktree = func(context.Context, gitcmd.Repo, string) (worktree.Result, error) {
|
||||
t.Fatal("fallback must not create a worktree")
|
||||
return worktree.Result{}, nil
|
||||
}
|
||||
@@ -164,7 +168,8 @@ func TestForkWorktreeForTabRollsBackUnusedCreation(t *testing.T) {
|
||||
createDeliveryWorktree = origCreate
|
||||
rollbackDeliveryWorktree = origRollback
|
||||
})
|
||||
inspectDeliveryWorktree = func(_ context.Context, root string) worktree.Availability {
|
||||
inspectDeliveryWorktree = func(_ context.Context, repo gitcmd.Repo) worktree.Availability {
|
||||
root := repo.Dir
|
||||
return worktree.Availability{Available: true, RepoRoot: root}
|
||||
}
|
||||
created := worktree.Result{
|
||||
@@ -174,7 +179,7 @@ func TestForkWorktreeForTabRollsBackUnusedCreation(t *testing.T) {
|
||||
Branch: "reasonix/delivery-test",
|
||||
Head: "deadbeef",
|
||||
}
|
||||
createDeliveryWorktree = func(context.Context, string, string) (worktree.Result, error) {
|
||||
createDeliveryWorktree = func(context.Context, gitcmd.Repo, string) (worktree.Result, error) {
|
||||
return created, nil
|
||||
}
|
||||
rollbackCalls := 0
|
||||
@@ -211,7 +216,8 @@ func TestForkWorktreeForTabPreservesReferencedWorkspaceWhenSourceCloses(t *testi
|
||||
rollbackDeliveryWorktree = origRollback
|
||||
forkTabBeforePublishHookForTest.Store(nil)
|
||||
})
|
||||
inspectDeliveryWorktree = func(_ context.Context, root string) worktree.Availability {
|
||||
inspectDeliveryWorktree = func(_ context.Context, repo gitcmd.Repo) worktree.Availability {
|
||||
root := repo.Dir
|
||||
return worktree.Availability{Available: true, RepoRoot: root}
|
||||
}
|
||||
worktreeRoot := filepath.Join(t.TempDir(), "worktree")
|
||||
@@ -226,7 +232,7 @@ func TestForkWorktreeForTabPreservesReferencedWorkspaceWhenSourceCloses(t *testi
|
||||
Branch: "reasonix/delivery-preserved",
|
||||
Head: "deadbeef",
|
||||
}
|
||||
createDeliveryWorktree = func(context.Context, string, string) (worktree.Result, error) {
|
||||
createDeliveryWorktree = func(context.Context, gitcmd.Repo, string) (worktree.Result, error) {
|
||||
return created, nil
|
||||
}
|
||||
rollbackCalls := 0
|
||||
|
||||
@@ -19,6 +19,7 @@ function Fixture() {
|
||||
</nav>
|
||||
{[
|
||||
["模型连接", "dial tcp: connection refused"],
|
||||
["未生成答复", "no_deliverable_output: model finished without a visible final answer after recovery; this turn did not complete"],
|
||||
["服务限流", "HTTP 429: too many requests"],
|
||||
["历史会话", "读取会话失败:unrecognized storage record revision"],
|
||||
["文件操作", "open /tmp/数据.txt: permission denied"],
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
<!doctype html><html lang="en"><head><meta charset="UTF-8"><title>Remote hydration regression</title></head><body><div id="root"></div><script type="module" src="/bench/session-switch-remote.tsx"></script></body></html>
|
||||
@@ -0,0 +1,57 @@
|
||||
import React, { useState } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { installDesktopHostStub } from "../src/__tests__/desktopHostStub";
|
||||
import { useRemoteSession } from "../src/lib/useRemoteSession";
|
||||
import type { TranscriptFollowResponse } from "../src/generated/desktopContract.generated";
|
||||
|
||||
let revision = 1, reads = 0;
|
||||
let publishReads = (_reads: number) => {};
|
||||
const pending: Array<{ revision: number; resolve: (value: TranscriptFollowResponse) => void }> = [];
|
||||
const status = { running: false, label: "Model", plan: false, toolApprovalMode: "ask", goal: "", takenOver: false };
|
||||
const stub = installDesktopHostStub({
|
||||
RemoteTabHydrationState: async () => ({ state: "ready", hydrationRevision: revision }),
|
||||
RemoteTabStatus: async () => status, RemoteTabMetadata: async () => ({ status }),
|
||||
RemoteTabSnapshot: async () => ({ status, history: [] }),
|
||||
ForkTargetsRemoteTab: async () => ({ targets: [], verifiable: false }),
|
||||
RemoteTranscriptFollowForTab: async (_id, request) => {
|
||||
if (request.close) return { protocolVersion: 2, subscription: request.subscription ?? "", changes: [], resetRequired: false };
|
||||
if (request.subscription) return new Promise(() => {});
|
||||
publishReads(++reads);
|
||||
return new Promise(resolve => pending.push({ revision, resolve }));
|
||||
},
|
||||
});
|
||||
function deliver(entry: typeof pending[number] | undefined) {
|
||||
if (!entry) return;
|
||||
const id = `session-${entry.revision}`;
|
||||
entry.resolve({ protocolVersion: 2, subscription: id, changes: [], resetRequired: false,
|
||||
snapshot: { protocolVersion: 1, snapshotId: id, identity: { sessionId: id, runtimeEpoch: id, rewriteEpoch: 0, headId: "" },
|
||||
projectionRevision: 1, coveredThroughSeq: 1, durableSeq: 1, records: [], activeRecords: [], activeAttempts: [],
|
||||
runtime: { status: "completed", pendingEvents: [], samplingCount: 0, toolCount: 0 }, before: 0, hasOlder: false,
|
||||
totalRecords: 0, totalTurns: 0, stale: false },
|
||||
history: { status: "ready", snapshotSequence: 1, coverageSequence: 1, generation: id, totalTurns: 0,
|
||||
hasOlder: false, hasNewer: false, messages: [] } });
|
||||
}
|
||||
function Fixture() {
|
||||
const session = useRemoteSession("remote-browser", "ready", "remote-session");
|
||||
const [count, setCount] = useState(0);
|
||||
publishReads = setCount;
|
||||
const ready = () => stub.emit("remote-tab:remote-browser:state", { state: "ready", hydrationRevision: revision });
|
||||
return <main style={{ fontFamily: "sans-serif", padding: 40 }}>
|
||||
<h1>Remote session hydration</h1>
|
||||
<button onClick={ready}>Replay ready</button>
|
||||
<button onClick={() => { revision++; ready(); }}>New generation</button>
|
||||
<button onClick={() => {
|
||||
stub.emitServiceState({ phase: "stopping", generation: "test-service" });
|
||||
revision = 1;
|
||||
stub.emitServiceState({ phase: "ready", generation: "replacement-service" });
|
||||
stub.emit("desktop:resync", {});
|
||||
ready();
|
||||
}}>Restart service</button>
|
||||
<button onClick={() => deliver(pending.pop())}>Deliver latest</button>
|
||||
<button onClick={() => deliver(pending.shift())}>Deliver oldest</button>
|
||||
<output data-reads={count} data-hydrated={session.hydrated} data-session={session.transcript.transcriptSessionId}>
|
||||
{JSON.stringify({ reads: count, hydrated: session.hydrated, session: session.transcript.transcriptSessionId })}
|
||||
</output>
|
||||
</main>;
|
||||
}
|
||||
createRoot(document.getElementById("root")!).render(<Fixture />);
|
||||
@@ -0,0 +1,3 @@
|
||||
<!doctype html>
|
||||
<html lang="en"><head><meta charset="UTF-8"><title>Session navigation regression</title></head>
|
||||
<body><div id="root"></div><script type="module" src="/bench/session-switch-sidebar.tsx"></script></body></html>
|
||||
@@ -0,0 +1,86 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdir, writeFile } from "node:fs/promises";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import path from "node:path";
|
||||
import { createServer } from "vite";
|
||||
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
process.env.PLAYWRIGHT_BROWSERS_PATH ??= path.join(root, ".pw-browsers");
|
||||
const { chromium } = await import("playwright");
|
||||
const server = await createServer({ root, logLevel: "error", server: { host: "127.0.0.1", port: 0 } });
|
||||
await server.listen();
|
||||
let browser;
|
||||
try {
|
||||
browser = await chromium.launch({ headless: true });
|
||||
const page = await browser.newPage({ viewport: { width: 1280, height: 800 } });
|
||||
const errors = [];
|
||||
page.on("pageerror", error => errors.push(error.message));
|
||||
page.on("console", event => { if (["warning", "error"].includes(event.type())) errors.push(event.text()); });
|
||||
await page.goto(`http://127.0.0.1:${server.httpServer.address().port}/bench/session-switch-sidebar.html`);
|
||||
await page.getByRole("button", { name: "Open session", exact: true }).waitFor();
|
||||
for (let cycle = 0; cycle < 40; cycle++) {
|
||||
await page.getByRole("button", { name: "Refresh list", exact: true }).click();
|
||||
assert.deepEqual(await page.locator("li").evaluateAll(rows => rows.map(row => row.dataset.session)),
|
||||
cycle % 2 ? ["other", "reviewed", "original"] : ["original", "reviewed"]);
|
||||
}
|
||||
await page.getByRole("button", { name: "Archive original", exact: true }).click();
|
||||
assert.equal(await page.locator('li[data-session="original"]').count(), 0);
|
||||
assert.equal(await page.locator('li[data-session="reviewed"]').count(), 1);
|
||||
await page.getByRole("button", { name: "Refresh sibling", exact: true }).click();
|
||||
assert.equal(await page.locator('li[data-session="original"]').count(), 0);
|
||||
await page.getByRole("button", { name: "Restore original", exact: true }).click();
|
||||
assert.equal(await page.locator('li[data-session="original"]').count(), 1);
|
||||
const samples = [];
|
||||
for (let run = 0; run < 5; run++) {
|
||||
await page.getByRole("button", { name: "Open session", exact: true }).click();
|
||||
await page.locator('output[data-status="opening"]').waitFor();
|
||||
// Keep publication pending across event turns, as real host IPC does.
|
||||
await page.getByRole("button", { name: "Refresh list", exact: true }).click();
|
||||
assert.equal(await page.locator("output").getAttribute("data-premature"), "0");
|
||||
await page.getByRole("button", { name: "Commit binding", exact: true }).click();
|
||||
await page.getByRole("button", { name: "Deliver baseline", exact: true }).click();
|
||||
await page.locator('output[data-status="ready"]').waitFor();
|
||||
await page.getByRole("button", { name: "Replay notifications", exact: true }).click();
|
||||
await page.getByRole("button", { name: "Refresh list", exact: true }).click();
|
||||
assert.equal(await page.locator("output").getAttribute("data-reads"), "1");
|
||||
assert.equal(await page.locator("output").getAttribute("data-premature"), "0");
|
||||
samples.push(JSON.parse(await page.locator("output").textContent()));
|
||||
}
|
||||
assert.deepEqual(errors, [], "browser console is clean");
|
||||
const evidence = process.env.REASONIX_SWITCH_EVIDENCE ?? "/tmp/reasonix-session-switch-sidebar";
|
||||
await mkdir(evidence, { recursive: true });
|
||||
await page.screenshot({ path: path.join(evidence, "verified.png") });
|
||||
await writeFile(path.join(evidence, "results.json"), JSON.stringify({ samples, errors, catalogRefreshes: 50 }, null, 2));
|
||||
console.log(`PASS 50 catalog refreshes, 5 canonical opens, one baseline/open, no premature reads; evidence: ${evidence}`);
|
||||
await page.goto(`http://127.0.0.1:${server.httpServer.address().port}/bench/session-switch-remote.html`);
|
||||
await page.locator('output[data-reads="1"]').waitFor();
|
||||
for (let i = 0; i < 5; i++) await page.getByRole("button", { name: "Replay ready", exact: true }).click();
|
||||
assert.equal(await page.locator("output").getAttribute("data-reads"), "1");
|
||||
await page.getByRole("button", { name: "Deliver latest", exact: true }).click();
|
||||
await page.locator('output[data-hydrated="true"][data-session="session-1"]').waitFor();
|
||||
await page.getByRole("button", { name: "Replay ready", exact: true }).click();
|
||||
assert.equal(await page.locator("output").getAttribute("data-reads"), "1");
|
||||
await page.getByRole("button", { name: "New generation", exact: true }).click();
|
||||
await page.locator('output[data-reads="2"]').waitFor();
|
||||
await page.getByRole("button", { name: "New generation", exact: true }).click();
|
||||
await page.locator('output[data-reads="3"]').waitFor();
|
||||
await page.getByRole("button", { name: "Deliver latest", exact: true }).click();
|
||||
await page.locator('output[data-hydrated="true"][data-session="session-3"]').waitFor();
|
||||
await page.getByRole("button", { name: "Deliver oldest", exact: true }).click();
|
||||
await page.getByRole("button", { name: "Replay ready", exact: true }).click();
|
||||
assert.equal(await page.locator("output").getAttribute("data-session"), "session-3");
|
||||
assert.equal(await page.locator("output").getAttribute("data-reads"), "3");
|
||||
await page.getByRole("button", { name: "Restart service", exact: true }).click();
|
||||
await page.locator('output[data-reads="4"]').waitFor();
|
||||
await page.getByRole("button", { name: "Deliver latest", exact: true }).click();
|
||||
await page.locator('output[data-hydrated="true"][data-session="session-1"]').waitFor();
|
||||
await page.getByRole("button", { name: "Replay ready", exact: true }).click();
|
||||
assert.equal(await page.locator("output").getAttribute("data-reads"), "4");
|
||||
assert.deepEqual(errors, [], "remote browser console is clean");
|
||||
await page.screenshot({ path: path.join(evidence, "remote-verified.png") });
|
||||
await writeFile(path.join(evidence, "remote-results.json"), JSON.stringify({ reads: 4, revisions: 3, duplicateReady: 8, serviceRestarts: 1, staleCutIgnored: true, errors }, null, 2));
|
||||
console.log("PASS remote duplicate ready, genuine resets and stale baseline completion in Chromium");
|
||||
} finally {
|
||||
await browser?.close();
|
||||
await server.close();
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import React, { useState } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { useController } from "../src/lib/useController";
|
||||
import { projectSessionRowKey } from "../src/lib/projectSessionIdentity";
|
||||
import { mergeProjectTopicPage, projectTreeWithoutTopics } from "../src/lib/projectTreeTopic";
|
||||
import { createSessionLifecycleFences } from "../src/lib/sessionLifecycleFences";
|
||||
import { installDesktopHostStub } from "../src/__tests__/desktopHostStub";
|
||||
import { meta, tabMeta } from "../src/__tests__/helpers/sessionSwitchFixtures";
|
||||
import type { ProjectNode } from "../src/lib/types";
|
||||
import type { FollowRequest, TranscriptFollowResponse } from "../src/generated/desktopContract.generated";
|
||||
|
||||
function deferred() {
|
||||
let resolve!: () => void;
|
||||
const promise = new Promise<void>(done => { resolve = done; });
|
||||
return { promise, resolve };
|
||||
}
|
||||
let current = "initial", epoch = "initial-epoch", committed = true;
|
||||
let releaseOpen = deferred(), releaseBaseline = deferred();
|
||||
let reads = 0, premature = 0;
|
||||
const metadata = () => meta({ session: { hostId: "local", sessionId: current }, sessionPath: `session-id:${current}`,
|
||||
sessionGeneration: 1, runtime: { phase: "ready", epoch } });
|
||||
const stub = installDesktopHostStub({
|
||||
RegisterNavigationIntent: async () => {},
|
||||
ListTabs: async () => [tabMeta({ ...metadata(), active: true })], MetaForTab: async () => metadata(),
|
||||
ReplayPendingPrompts: async () => {}, ReplayPendingPromptsForTab: async () => {},
|
||||
ContextUsageForTab: async () => ({ used: 0, window: 100, sessionTokens: 0 }),
|
||||
EffortForTab: async () => ({ supported: false, current: "auto", levels: [] }),
|
||||
BalanceForTab: async () => ({ available: false, display: "" }), JobsForTab: async () => [],
|
||||
CheckpointsForTab: async () => [], ForkTargetsForTab: async () => ({ targets: [], verifiable: false }),
|
||||
OpenSession: async (ref: { sessionId: string }) => {
|
||||
current = ref.sessionId; epoch = `${current}-epoch`; committed = false;
|
||||
stub.emit("runtime:rebuilt", "tab-a", epoch);
|
||||
stub.emit("runtime:rebuilt", "tab-a", epoch);
|
||||
stub.emit("agent:ready", "tab-a");
|
||||
await releaseOpen.promise;
|
||||
committed = true;
|
||||
},
|
||||
TranscriptFollowForTab: async (_tab: string, request: FollowRequest): Promise<TranscriptFollowResponse> => {
|
||||
if (request.close) return { protocolVersion: 2, subscription: request.subscription, changes: [], resetRequired: false };
|
||||
if (request.subscription) return new Promise(() => {});
|
||||
const id = current;
|
||||
if (id !== "initial") reads++;
|
||||
if (!committed) { premature++; throw new Error("runtime changed while following transcript"); }
|
||||
if (id !== "initial") await releaseBaseline.promise;
|
||||
return { protocolVersion: 2, subscription: `${id}-${reads}`, changes: [], resetRequired: false,
|
||||
snapshot: { protocolVersion: 1, snapshotId: id, identity: { sessionId: id, runtimeEpoch: epoch, rewriteEpoch: 0, headId: "" },
|
||||
projectionRevision: 1, coveredThroughSeq: 1, durableSeq: 1, records: [], activeRecords: [], activeAttempts: [],
|
||||
runtime: { status: "completed", pendingEvents: [], samplingCount: 0, toolCount: 0 },
|
||||
before: 0, hasOlder: false, totalRecords: 0, totalTurns: 0, stale: false },
|
||||
history: { status: "ready", snapshotSequence: 1, coverageSequence: 1, generation: id, totalTurns: 0,
|
||||
hasOlder: false, hasNewer: false, messages: [] } };
|
||||
},
|
||||
});
|
||||
const source: ProjectNode = { key: "legacy", kind: "global_topic", label: "Imported history",
|
||||
source: { hostId: "local", sourceKey: "browser-source", path: "/history.jsonl" } };
|
||||
const original: ProjectNode = { key: "original", kind: "global_topic", label: "Imported history",
|
||||
session: { hostId: "local", sessionId: "original" }, identityAliases: ["source\0local\0browser-source"] };
|
||||
const reviewed: ProjectNode = { ...original, key: "reviewed", session: { hostId: "local", sessionId: "reviewed" } };
|
||||
const other: ProjectNode = { key: "other", kind: "global_topic", label: "Other session", session: { hostId: "local", sessionId: "other" } };
|
||||
const fences = createSessionLifecycleFences();
|
||||
|
||||
function Fixture() {
|
||||
const controller = useController();
|
||||
const [cycle, setCycle] = useState(-1);
|
||||
const [status, setStatus] = useState("idle");
|
||||
const [result, setResult] = useState({ reads: 0, premature: 0, elapsed: 0 });
|
||||
const pages = cycle < 0 ? [source] : cycle % 2
|
||||
? mergeProjectTopicPage([other, reviewed], [original], true)
|
||||
: mergeProjectTopicPage([original], [reviewed], true);
|
||||
const rows = projectTreeWithoutTopics(pages, fences.keys());
|
||||
return <main style={{ fontFamily: "system-ui", padding: 32 }}>
|
||||
<h1>Session navigation and sidebar regression</h1>
|
||||
<p>Shared-source sessions remain separate through repeated catalog refreshes.</p>
|
||||
<button onClick={() => setCycle(value => value + 1)}>Refresh list</button>
|
||||
<button onClick={() => { fences.archive(original, { lifecycleGeneration: 1, operationId: "archive-original" }); setCycle(value => value + 2); }}>Archive original</button>
|
||||
<button onClick={() => { fences.observeDirectory([{ ...reviewed, lifecycleGeneration: 99 }]); setCycle(value => value + 2); }}>Refresh sibling</button>
|
||||
<button onClick={() => { fences.observeDirectory([{ ...original, lifecycleGeneration: 2 }]); setCycle(value => value + 2); }}>Restore original</button>
|
||||
<ul>{rows.map(row => <li key={projectSessionRowKey(row)} data-session={row.session?.sessionId ?? "source"}>{row.label}</li>)}</ul>
|
||||
<button disabled={status === "opening" || !controller.state.meta?.ready} onClick={() => {
|
||||
const startedAt = performance.now();
|
||||
reads = 0; premature = 0; releaseOpen = deferred(); releaseBaseline = deferred(); setStatus("opening");
|
||||
const intent = controller.noteNavigationIntent();
|
||||
void controller.openCanonicalSession({ hostId: "local", sessionId: `target-${intent}` }, intent).then(() => {
|
||||
setResult({ reads, premature, elapsed: performance.now() - startedAt }); setStatus("ready");
|
||||
}, () => setStatus("failed"));
|
||||
}}>Open session</button>
|
||||
<button onClick={() => releaseOpen.resolve()}>Commit binding</button>
|
||||
<button onClick={() => releaseBaseline.resolve()}>Deliver baseline</button>
|
||||
<button onClick={() => {
|
||||
stub.emit("runtime:rebuilt", "tab-a", epoch); stub.emit("agent:ready", "tab-a");
|
||||
setResult(value => ({ ...value, reads, premature }));
|
||||
}}>Replay notifications</button>
|
||||
<output data-status={status} data-reads={reads} data-premature={premature} data-cycle={cycle}>
|
||||
{JSON.stringify({ status, ...result })}
|
||||
</output>
|
||||
</main>;
|
||||
}
|
||||
createRoot(document.getElementById("root")!).render(<Fixture />);
|
||||
@@ -32,9 +32,10 @@
|
||||
"test:code-browser": "node bench/code-theme.mjs",
|
||||
"test:dock-responsive-browser": "node bench/responsive-dock.mjs",
|
||||
"test:diagnostics": "tsx src/__tests__/diagnostics-settings.test.tsx",
|
||||
"test:transcript": "tsx src/__tests__/canonical-user-display.test.ts && tsx src/__tests__/steer-transcript.test.ts && tsx src/__tests__/chat-tool-presentation.test.ts && tsx src/__tests__/chat-view-source.test.ts && tsx src/__tests__/maintenance-lifecycle.test.ts && node --import ./scripts/css-stub-register.mjs --import tsx src/__tests__/maintenance-card.test.tsx && tsx src/__tests__/presented-files.test.ts && node --import ./scripts/css-stub-register.mjs --import tsx src/__tests__/presented-files-ui.test.tsx && tsx src/__tests__/chat-content-loader.test.ts && tsx src/__tests__/chat-details-race.test.tsx && tsx src/__tests__/tool-payload-preview.test.ts && tsx src/__tests__/markdown-natural-flow.test.tsx && tsx src/__tests__/chat-natural-flow.test.tsx && tsx src/__tests__/chat-scroll-controller.test.ts && tsx src/__tests__/markdown-pipeline.test.tsx && tsx src/__tests__/markdown-worker-client.test.ts && tsx src/__tests__/transcript-store.test.ts && tsx src/__tests__/transcript-memory-pressure.test.ts && tsx src/__tests__/transcript-tool-association.test.ts && tsx src/__tests__/transcript-follow-client.test.ts && tsx src/__tests__/transcript-session-follower.test.ts && tsx src/__tests__/transcript-scroll-diagnostics.test.ts && tsx src/__tests__/session-export-routing.test.tsx && tsx src/__tests__/session-export-operation.test.ts && tsx src/__tests__/typography-overflow-contract.test.ts && tsx src/__tests__/nested-scroll-handoff.test.ts && tsx src/__tests__/transcript-outline-store.test.ts && tsx src/__tests__/chat-turn-jump.test.ts && node --import ./scripts/css-stub-register.mjs --import tsx src/__tests__/chat-turn-outline-jump.test.tsx && tsx src/__tests__/native-transcript-history.test.ts && tsx src/__tests__/history-read-binding.test.ts",
|
||||
"test:transcript": "tsx src/__tests__/canonical-user-display.test.ts && tsx src/__tests__/steer-transcript.test.ts && tsx src/__tests__/chat-tool-presentation.test.ts && tsx src/__tests__/chat-view-source.test.ts && tsx src/__tests__/maintenance-lifecycle.test.ts && node --import ./scripts/css-stub-register.mjs --import tsx src/__tests__/maintenance-card.test.tsx && tsx src/__tests__/presented-files.test.ts && node --import ./scripts/css-stub-register.mjs --import tsx src/__tests__/presented-files-ui.test.tsx && tsx src/__tests__/chat-content-loader.test.ts && tsx src/__tests__/chat-details-race.test.tsx && tsx src/__tests__/tool-payload-preview.test.ts && tsx src/__tests__/markdown-natural-flow.test.tsx && tsx src/__tests__/chat-natural-flow.test.tsx && tsx src/__tests__/chat-scroll-controller.test.ts && tsx src/__tests__/markdown-pipeline.test.tsx && tsx src/__tests__/markdown-worker-client.test.ts && tsx src/__tests__/transcript-store.test.ts && tsx src/__tests__/transcript-memory-pressure.test.ts && tsx src/__tests__/transcript-tool-association.test.ts && tsx src/__tests__/transcript-follow-client.test.ts && tsx src/__tests__/transcript-session-follower.test.ts && tsx src/__tests__/transcript-scroll-diagnostics.test.ts && tsx src/__tests__/session-export-routing.test.tsx && tsx src/__tests__/session-export-operation.test.ts && tsx src/__tests__/typography-overflow-contract.test.ts && tsx src/__tests__/nested-scroll-handoff.test.ts && tsx src/__tests__/transcript-outline-store.test.ts && tsx src/__tests__/chat-turn-jump.test.ts && node --import ./scripts/css-stub-register.mjs --import tsx src/__tests__/chat-turn-outline-jump.test.tsx && tsx src/__tests__/native-transcript-history.test.ts && tsx src/__tests__/history-read-binding.test.ts && tsx src/__tests__/history-reload-identity.test.ts",
|
||||
"test:session-export-browser": "node bench/session-export.mjs",
|
||||
"test:session-loading-browser": "node bench/session-loading.mjs && node bench/draft-recovery.mjs && node bench/manual-creation.mjs && node bench/manual-creation-recovery.mjs",
|
||||
"test:session-switch-regression": "tsx src/__tests__/session-alias-isolation.test.ts && tsx src/__tests__/session-lifecycle-fences.test.ts && tsx src/__tests__/project-session-row-identity.test.tsx && tsx src/__tests__/canonical-navigation-hydration.test.tsx && tsx src/__tests__/remote-hydration-owner.test.ts && tsx src/__tests__/remote-hydration-ownership.test.tsx",
|
||||
"test:session-loading-browser": "pnpm test:session-switch-regression && node bench/session-loading.mjs && node bench/session-switch-sidebar.mjs && node bench/draft-recovery.mjs && node bench/manual-creation.mjs && node bench/manual-creation-recovery.mjs",
|
||||
"test:draft-recovery-browser": "node bench/draft-recovery.mjs",
|
||||
"test:maintenance-browser": "node bench/maintenance-submit.mjs",
|
||||
"test:turn-outline-browser": "node bench/turn-outline.mjs",
|
||||
@@ -52,7 +53,7 @@
|
||||
"test:popover-browser": "node bench/anchored-popover-lifecycle.mjs",
|
||||
"test:popover-electron": "node bench/anchored-popover-lifecycle.mjs --electron",
|
||||
"test": "node scripts/test-todo-visibility.mjs && node scripts/run-tests.mjs",
|
||||
"test:remote": "tsx src/__tests__/remote-workspace-launch.test.ts && tsx src/__tests__/remote-store.test.ts && tsx src/__tests__/remote-error-ux.test.tsx && tsx src/__tests__/remote-hosts-page.test.tsx && node --import ./scripts/css-stub-register.mjs --import tsx src/__tests__/remote-connect-wizard.test.tsx && tsx src/__tests__/add-project-entries.test.ts && tsx src/__tests__/remote-secret-dialog.test.tsx && node --import ./scripts/svg-stub-register.mjs --import tsx src/__tests__/remote-server-panel.test.tsx && node --import ./scripts/svg-stub-register.mjs --import tsx src/__tests__/remote-session-surface.test.tsx && tsx src/__tests__/remote-running-reconcile.test.ts && tsx src/__tests__/remote-project-tree.test.tsx && tsx src/__tests__/remote-session-cache.test.ts && tsx src/__tests__/remote-tab-opened.test.tsx && tsx src/__tests__/statusbar-workspace.test.tsx",
|
||||
"test:remote": "tsx src/__tests__/remote-workspace-launch.test.ts && tsx src/__tests__/remote-store.test.ts && tsx src/__tests__/remote-error-ux.test.tsx && tsx src/__tests__/remote-hosts-page.test.tsx && node --import ./scripts/css-stub-register.mjs --import tsx src/__tests__/remote-connect-wizard.test.tsx && tsx src/__tests__/add-project-entries.test.ts && tsx src/__tests__/remote-secret-dialog.test.tsx && node --import ./scripts/svg-stub-register.mjs --import tsx src/__tests__/remote-server-panel.test.tsx && node --import ./scripts/svg-stub-register.mjs --import tsx src/__tests__/remote-session-surface.test.tsx && tsx src/__tests__/remote-running-reconcile.test.ts && tsx src/__tests__/remote-session-read-activity.test.tsx && tsx src/__tests__/remote-session-badges.test.tsx && tsx src/__tests__/remote-project-tree.test.tsx && tsx src/__tests__/remote-session-cache.test.ts && tsx src/__tests__/remote-tab-opened.test.tsx && tsx src/__tests__/statusbar-workspace.test.tsx",
|
||||
"test:performance": "tsx src/__tests__/history-performance-benchmark.tsx",
|
||||
"test:bench": "PLAYWRIGHT_BROWSERS_PATH=.pw-browsers node bench/run.mjs",
|
||||
"test:typecheck": "tsc --noEmit -p tsconfig.test.json",
|
||||
@@ -72,14 +73,14 @@
|
||||
"@dnd-kit/sortable": "10.0.0",
|
||||
"@dnd-kit/utilities": "3.2.2",
|
||||
"@modelcontextprotocol/ext-apps": "1.7.5",
|
||||
"@tanstack/react-virtual": "^3.14.10",
|
||||
"@tanstack/react-virtual": "^3.14.13",
|
||||
"@xterm/addon-fit": "0.11.0",
|
||||
"@xterm/xterm": "6.0.0",
|
||||
"anser": "^2.3.5",
|
||||
"hast-util-to-jsx-runtime": "2.3.6",
|
||||
"highlight.js": "^11.12.0",
|
||||
"html-url-attributes": "3.0.1",
|
||||
"katex": "^0.18.6",
|
||||
"katex": "^0.18.9",
|
||||
"lucide-react": "^1.41.0",
|
||||
"mermaid": "^11.17.2",
|
||||
"qrcode.react": "^4.2.0",
|
||||
@@ -103,7 +104,7 @@
|
||||
"@types/node": "^26.4.1",
|
||||
"@types/react": "^19.2.18",
|
||||
"@types/react-dom": "^19.2.7",
|
||||
"@typescript-eslint/parser": "^8.69.0",
|
||||
"@typescript-eslint/parser": "^8.70.1",
|
||||
"@vitejs/plugin-react": "^6.1.1",
|
||||
"decode-named-character-reference": "^1.3.0",
|
||||
"eslint": "^10.10.0",
|
||||
@@ -113,7 +114,7 @@
|
||||
"mdast-util-math": "^3.0.0",
|
||||
"playwright": "^1.63.0",
|
||||
"terser": "^5.51.2",
|
||||
"tsx": "^4.23.13",
|
||||
"tsx": "^4.23.15",
|
||||
"typescript": "^6.0.3",
|
||||
"vite": "^8.2.2"
|
||||
},
|
||||
|
||||
@@ -314,7 +314,10 @@ for (const path of localeChunks) {
|
||||
// 64606 / 65349 B, so both dialect ceilings ratchet to the next tenth.
|
||||
// Model-application copy on the read-pause base measures 64734 / 65499 B,
|
||||
// adding 128 / 150 B. Retain only the next one-decimal ceiling.
|
||||
const budget = name.startsWith("zh-TW-") ? 64.0 * 1024 : 63.3 * 1024;
|
||||
// The localized Studio introduction and the provider-failure causes bring zh
|
||||
// to 64826 B and zh-TW to 65714 B measured. Each ceiling keeps about 100 B of
|
||||
// cross-platform headroom; the compressed initial budgets stay fixed.
|
||||
const budget = name.startsWith("zh-TW-") ? 64.3 * 1024 : 63.4 * 1024;
|
||||
assertBudget(`${name} gzip`, gzipBytes(path), budget);
|
||||
}
|
||||
|
||||
@@ -561,6 +564,14 @@ const rawInitialBytes = [...initialJS, ...initialCSS, ...appShellCSS]
|
||||
// Ready-event cold-history joining adds 163 B locally (2106326 B versus
|
||||
// 2106163 B); retain the producer margin and round to the smallest 2057.2 KiB
|
||||
// ceiling that covers the same Linux build. Other bundle budgets stay fixed.
|
||||
const rawInitialBudgetKiB = 2_057.2;
|
||||
// The Studio introduction and the provider-failure causes measure 2107767 B in
|
||||
// the initial raw boundary; the ceiling keeps 341 B of producer headroom, and
|
||||
// compressed initial JS, per-chunk and CSS ceilings remain unchanged.
|
||||
// Session navigation, alias ownership and remote ready barriers add 1810 B
|
||||
// (0.086%) over isolated main-v2 8cc804e687 with the same toolchain:
|
||||
// 2109672 B versus 2107862 B, including 311 B for service-restart recovery.
|
||||
// Keep the 186 B producer margin at the smallest one-decimal ceiling; gzip,
|
||||
// chunk, CSS, locale, and resident-history limits remain unchanged.
|
||||
const rawInitialBudgetKiB = 2_060.5;
|
||||
assertBudget("initial raw JavaScript and CSS", rawInitialBytes, rawInitialBudgetKiB * 1024);
|
||||
assertBudget("largest initial JavaScript chunk raw", largestInitialJSRaw, 1_000 * 1024);
|
||||
@@ -50,6 +50,8 @@ const OWNED_ELSEWHERE = new Map(Object.entries({
|
||||
"remote-session-surface.test.tsx": "test:remote (needs the svg stub register)",
|
||||
"remote-running-reconcile.test.ts": "test:remote",
|
||||
"remote-project-tree.test.tsx": "test:remote",
|
||||
"remote-session-read-activity.test.tsx": "test:remote",
|
||||
"remote-session-badges.test.tsx": "test:remote",
|
||||
"statusbar-workspace.test.tsx": "test:remote",
|
||||
"updater-shared-state.test.tsx": "test:updater",
|
||||
"window-state-ordering.test.ts": "test:window-state",
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { ordinaryFileSource } from "../lib/attachDedup";
|
||||
import { formatWorkspaceReference, parseWorkspaceReference } from "../lib/workspaceDrag";
|
||||
import { isExternalWorkspaceReference, restoreExternalFolderReferences } from "../lib/attachmentSubmit";
|
||||
import type { AppBindings } from "../lib/bridge";
|
||||
|
||||
const a = new File(["first"], "app.apk", { lastModified: 1 });
|
||||
const b = new File(["other"], "app.apk", { lastModified: 1 });
|
||||
assert.equal(ordinaryFileSource(a, ""), ordinaryFileSource(a, ""));
|
||||
assert.notEqual(ordinaryFileSource(a, ""), ordinaryFileSource(b, ""));
|
||||
assert.equal(ordinaryFileSource(a, "/outside/app.apk"), "path:/outside/app.apk");
|
||||
for (const path of ["/outside/large app.apk", "C:\\Downloads\\large app.apk", "src/main.go"]) {
|
||||
assert.equal(parseWorkspaceReference(formatWorkspaceReference(path))?.path, path);
|
||||
}
|
||||
assert.equal(isExternalWorkspaceReference({ path: "/outside/large app.apk", displayPath: "/outside/large app.apk" }), true);
|
||||
assert.equal(isExternalWorkspaceReference({ path: "C:/Downloads/large app.apk", displayPath: "C:/Downloads/large app.apk" }), true);
|
||||
assert.equal(isExternalWorkspaceReference({ path: "src/main.go" }), false);
|
||||
const calls: string[] = [];
|
||||
const bindings: Partial<AppBindings> = {
|
||||
CaptureAttachmentTarget: async () => ({ token: "captured", capabilities: ["attachments-v2"] }),
|
||||
AttachDroppedForTarget: async (token: string, path: string) => { calls.push(`${token}:${path}`); return { kind: "workspace", path, displayPath: path }; },
|
||||
ReleaseAttachmentTarget: async (token: string) => { calls.push(`release:${token}`); },
|
||||
};
|
||||
await restoreExternalFolderReferences(bindings as AppBindings, { kind: "session", tabId: "source" }, [{ path: "/outside/large app.apk", displayPath: "/outside/large app.apk" }]);
|
||||
assert.deepEqual(calls, ["captured:/outside/large app.apk", "release:captured"]);
|
||||
console.log("attachment references: object identity, escaped paths and restored exact-file authorization passed");
|
||||
@@ -0,0 +1,24 @@
|
||||
import assert from "node:assert/strict";
|
||||
import React, { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { JSDOM } from "jsdom";
|
||||
import { ToastProvider, useToast, type ToastContextValue } from "../lib/toast";
|
||||
import { LocaleProvider } from "../lib/i18n";
|
||||
|
||||
const dom = new JSDOM("<div id='root'></div>", { url: "http://localhost", pretendToBeVisual: true });
|
||||
Object.assign(globalThis, { window: dom.window, document: dom.window.document, localStorage: dom.window.localStorage,
|
||||
Node: dom.window.Node, HTMLElement: dom.window.HTMLElement, IS_REACT_ACT_ENVIRONMENT: true });
|
||||
const root = createRoot(document.getElementById("root")!);
|
||||
let toast!: ToastContextValue;
|
||||
function Fixture() { toast = useToast(); return null; }
|
||||
try {
|
||||
await act(async () => root.render(<LocaleProvider><ToastProvider><Fixture /></ToastProvider></LocaleProvider>));
|
||||
// The filename starts with ASCII; generic exception classification must not
|
||||
// hide this already-localized, typed upload refusal behind a details toggle.
|
||||
const message = "oversize.apk 过大,无法附加(上限 1024 MB)";
|
||||
await act(async () => { toast.showToast(message, "warn", { summary: message }); });
|
||||
assert.equal(document.querySelector(".toast--warn .user-error__summary")?.textContent, message);
|
||||
assert.equal(document.querySelector(".toast--warn .user-error__toggle"), null);
|
||||
await act(async () => root.unmount());
|
||||
console.log("attachment ceiling toast: localized filename and limit remain visible");
|
||||
} finally { dom.window.close(); }
|
||||
@@ -29,6 +29,9 @@ try {
|
||||
assert.deepEqual(captures[0],target);
|
||||
assert.equal(prepared.structured.input,"describe","recovery adds no provider prompt bytes");
|
||||
const external = [{path:restoredPath,isDir:true,displayPath:"/external/folder"}];
|
||||
await assert.rejects(restoreExternalFolderReferences({ ...app,
|
||||
CaptureAttachmentTarget: async () => { throw Error("unsupported: attachments-v2"); },
|
||||
}, target, external), /unsupported: attachments-v2/, "submission must still validate attachment capability");
|
||||
await restoreExternalFolderReferences(app,target,external);
|
||||
restoredPath="__reasonix_external_folder/changed/folder";
|
||||
await assert.rejects(restoreExternalFolderReferences(app,target,external),/folder changed/);
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { attachOrdinaryFile, attachmentUploadFailure } from "../lib/attachmentSubmit";
|
||||
import type { AppBindings } from "../lib/bridge";
|
||||
|
||||
const limit = { code: "attachment.too_large", message: "attachment exceeds 4 byte stream limit (user)", limit_bytes: 4, limit_source: "user" };
|
||||
await assert.rejects(
|
||||
attachOrdinaryFile({} as AppBindings, "captured", new File(["12345"], "app.apk"), undefined,
|
||||
(async () => new Response(JSON.stringify(limit), { status: 413, headers: { "content-type": "application/json" } })) as typeof fetch),
|
||||
(error: unknown) => {
|
||||
assert.ok(error instanceof Error);
|
||||
assert.equal(error.message, limit.message);
|
||||
assert.equal((error as Error & typeof limit).code, limit.code);
|
||||
assert.equal((error as Error & typeof limit).limit_bytes, 4);
|
||||
assert.equal((error as Error & typeof limit).limit_source, "user");
|
||||
assert.equal(attachmentUploadFailure(error, "generic failure"), limit.message);
|
||||
assert.equal(attachmentUploadFailure(error, "generic failure", bytes => `limit ${bytes}`), "limit 4");
|
||||
return true;
|
||||
},
|
||||
);
|
||||
assert.equal(attachmentUploadFailure(new Error("unclassified server error"), "generic failure"), "generic failure");
|
||||
await assert.rejects(
|
||||
attachOrdinaryFile({} as AppBindings, "captured", new File(["12345"], "app.apk"), undefined,
|
||||
(async () => new Response("upstream unavailable", { status: 502 })) as typeof fetch),
|
||||
/File upload failed \(502\)/,
|
||||
);
|
||||
console.log("attachment stream limit: typed server errors retain ceiling and source");
|
||||
@@ -0,0 +1,139 @@
|
||||
import assert from "node:assert/strict";
|
||||
import React, { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { installRemoteSurfaceDom } from "./helpers/remoteSurfaceDom";
|
||||
import { installDesktopHostStub } from "./desktopHostStub";
|
||||
import { meta, tabMeta } from "./helpers/sessionSwitchFixtures";
|
||||
import { useController } from "../lib/useController";
|
||||
import type { FollowRequest, TranscriptFollowResponse } from "../generated/desktopContract.generated";
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
const promise = new Promise<T>(done => { resolve = done; });
|
||||
return { promise, resolve };
|
||||
}
|
||||
const dom = installRemoteSurfaceDom();
|
||||
let current = "first";
|
||||
let epoch = "first-epoch";
|
||||
const reads: string[] = [];
|
||||
let opened = deferred<void>();
|
||||
let releaseOpen = deferred<void>();
|
||||
let baseline = deferred<void>();
|
||||
let bindingCommitted = true;
|
||||
let controller!: ReturnType<typeof useController>;
|
||||
const currentMeta = () => meta({ session: { hostId: "local", sessionId: current }, sessionPath: `session-id:${current}`,
|
||||
sessionGeneration: 1, runtime: { phase: "ready", epoch } });
|
||||
const stub = installDesktopHostStub({
|
||||
RegisterNavigationIntent: async () => {},
|
||||
ListTabs: async () => [tabMeta({ ...currentMeta(), active: true })],
|
||||
MetaForTab: async () => currentMeta(),
|
||||
ReplayPendingPrompts: async () => {}, ReplayPendingPromptsForTab: async () => {},
|
||||
ContextUsageForTab: async () => ({ used: 0, window: 100, sessionTokens: 0 }),
|
||||
EffortForTab: async () => ({ supported: false, current: "auto", levels: [] }),
|
||||
BalanceForTab: async () => ({ available: false, display: "" }), JobsForTab: async () => [],
|
||||
CheckpointsForTab: async () => [], ForkTargetsForTab: async () => ({ targets: [], verifiable: false }),
|
||||
OpenSession: async (ref: { sessionId: string }) => {
|
||||
bindingCommitted = false;
|
||||
current = ref.sessionId; epoch = `${current}-epoch`;
|
||||
stub.emit("runtime:rebuilt", "tab-a", epoch);
|
||||
stub.emit("runtime:rebuilt", "tab-a", epoch);
|
||||
stub.emit("agent:ready", "tab-a");
|
||||
opened.resolve();
|
||||
await releaseOpen.promise;
|
||||
bindingCommitted = true;
|
||||
},
|
||||
TranscriptFollowForTab: async (_tab: string, request: FollowRequest): Promise<TranscriptFollowResponse> => {
|
||||
if (request.close) return { protocolVersion: 2, subscription: request.subscription, changes: [], resetRequired: false };
|
||||
if (request.subscription) return new Promise(() => {});
|
||||
const id = current, boundEpoch = epoch;
|
||||
reads.push(id);
|
||||
if (!bindingCommitted) throw new Error("runtime changed while following transcript");
|
||||
if (id !== "first") await baseline.promise;
|
||||
return { protocolVersion: 2, subscription: `${id}-${reads.length}`, changes: [], resetRequired: false,
|
||||
snapshot: { protocolVersion: 1, snapshotId: id,
|
||||
identity: { sessionId: id, runtimeEpoch: boundEpoch, rewriteEpoch: 0, headId: "" },
|
||||
projectionRevision: 1, coveredThroughSeq: 1, durableSeq: 1, records: [], activeRecords: [], activeAttempts: [],
|
||||
runtime: { status: "completed", pendingEvents: [], samplingCount: 0, toolCount: 0 },
|
||||
before: 0, hasOlder: false, totalRecords: 0, totalTurns: 0, stale: false },
|
||||
history: { status: "ready", snapshotSequence: 1, coverageSequence: 1, generation: id,
|
||||
totalTurns: 0, hasOlder: false, hasNewer: false, messages: [] } };
|
||||
},
|
||||
});
|
||||
const root = createRoot(document.getElementById("root")!);
|
||||
function Probe() { controller = useController(); return null; }
|
||||
async function drain() { await act(async () => { for (let i = 0; i < 40; i++) await new Promise(resolve => setImmediate(resolve)); }); }
|
||||
try {
|
||||
await act(async () => root.render(<Probe />));
|
||||
await drain();
|
||||
assert.ok(reads.includes("first"), "startup is hydrated");
|
||||
reads.length = 0;
|
||||
let navigation!: Promise<void>;
|
||||
await act(async () => {
|
||||
const intent = controller.noteNavigationIntent();
|
||||
navigation = controller.openCanonicalSession({ hostId: "local", sessionId: "second" }, intent);
|
||||
await opened.promise;
|
||||
});
|
||||
await drain();
|
||||
act(() => releaseOpen.resolve());
|
||||
await drain();
|
||||
await act(async () => { baseline.resolve(); await navigation; });
|
||||
await drain();
|
||||
assert.equal(reads.filter(id => id === "second").length, 1,
|
||||
"ready/rebuilt notifications and explicit navigation share one target baseline");
|
||||
assert.equal(controller.state.meta?.session?.sessionId, "second");
|
||||
assert.equal(controller.state.hydrateError, undefined);
|
||||
await act(async () => {
|
||||
stub.emit("runtime:rebuilt", "tab-a", epoch);
|
||||
stub.emit("agent:ready", "tab-a");
|
||||
});
|
||||
await drain();
|
||||
assert.equal(reads.length, 1, "late notifications for the installed runtime do not reload its baseline");
|
||||
epoch = "second-replacement-epoch";
|
||||
await act(async () => stub.emit("runtime:rebuilt", "tab-a", epoch));
|
||||
await drain();
|
||||
assert.equal(reads.length, 2, "an actual runtime replacement still establishes a new subscription");
|
||||
|
||||
const slowOpened = deferred<void>(), slowReply = deferred<void>();
|
||||
const lastOpened = deferred<void>(), lastReply = deferred<void>();
|
||||
stub.commands.OpenSession = async (ref: { sessionId: string }) => {
|
||||
if (ref.sessionId === "superseded") {
|
||||
slowOpened.resolve(); await slowReply.promise; return;
|
||||
}
|
||||
current = ref.sessionId; epoch = `${current}-epoch`; bindingCommitted = false;
|
||||
stub.emit("runtime:rebuilt", "tab-a", epoch);
|
||||
stub.emit("agent:ready", "tab-a");
|
||||
lastOpened.resolve(); await lastReply.promise; bindingCommitted = true;
|
||||
};
|
||||
let slow!: Promise<void>, last!: Promise<void>;
|
||||
await act(async () => {
|
||||
slow = controller.openCanonicalSession({ hostId: "local", sessionId: "superseded" }, controller.noteNavigationIntent());
|
||||
await slowOpened.promise;
|
||||
});
|
||||
await act(async () => {
|
||||
last = controller.openCanonicalSession({ hostId: "local", sessionId: "last" }, controller.noteNavigationIntent());
|
||||
await lastOpened.promise;
|
||||
});
|
||||
await act(async () => { slowReply.resolve(); await slow; stub.emit("agent:ready", "tab-a"); });
|
||||
await drain();
|
||||
assert.equal(reads.filter(id => id === "last").length, 0, "stale finally cannot release the newer navigation's hydration ownership");
|
||||
await act(async () => { lastReply.resolve(); await last; });
|
||||
await drain();
|
||||
assert.equal(reads.filter(id => id === "last").length, 1);
|
||||
assert.equal(controller.state.meta?.session?.sessionId, "last", "last canonical selection wins");
|
||||
|
||||
stub.commands.OpenSession = async () => { throw new Error("session not found"); };
|
||||
await act(async () => {
|
||||
await assert.rejects(controller.openCanonicalSession({ hostId: "local", sessionId: "missing" }, controller.noteNavigationIntent()), /session not found/);
|
||||
});
|
||||
epoch = "after-failed-open";
|
||||
const beforeRecovery = reads.length;
|
||||
await act(async () => { stub.emit("runtime:rebuilt", "tab-a", epoch); });
|
||||
await drain();
|
||||
assert.equal(reads.length, beforeRecovery + 1, "failed navigation releases ownership so later runtime recovery works");
|
||||
assert.equal(controller.state.meta?.session?.sessionId, "last", "failed target never replaces the source identity");
|
||||
} finally {
|
||||
baseline.resolve(); releaseOpen.resolve();
|
||||
await act(async () => root.unmount());
|
||||
stub.uninstall(); dom.window.close();
|
||||
}
|
||||
console.log("PASS canonical navigation performs one target baseline");
|
||||
@@ -0,0 +1,56 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { act } from "react";
|
||||
import { createTranscriptHarness } from "./transcript-dom-harness";
|
||||
import type { Item } from "../lib/useController";
|
||||
|
||||
const page: Item[] = [];
|
||||
for (let turn = 1; turn <= 4; turn += 1) {
|
||||
page.push(
|
||||
{ kind: "user", id: `u${turn}`, text: `prompt ${turn}`, checkpointTurn: turn },
|
||||
{ kind: "assistant", id: `a${turn}`, text: `answer ${turn}`, reasoning: "", streaming: false },
|
||||
);
|
||||
}
|
||||
const harness = await createTranscriptHarness({ deterministic: true });
|
||||
try {
|
||||
let loads = 0;
|
||||
const window = { hasNewerHistory: true, historyStartTurn: 0, historyEndTurn: 4, totalTurns: 20, geometrySessionKey: "newer-auto" };
|
||||
const props = { ...window, onLoadNewerHistory: async () => { loads += 1; return "loaded" as const; } };
|
||||
await harness.render(page, props);
|
||||
await harness.settle();
|
||||
assert.equal(loads, 1, "a reader sitting at the bottom of a window with newer history requests the next page once");
|
||||
await harness.render([...page], props);
|
||||
await harness.settle();
|
||||
assert.equal(loads, 1, "a republished window does not request another page");
|
||||
|
||||
await harness.render(page, { ...props, hasNewerHistory: false });
|
||||
await harness.settle();
|
||||
assert.equal(loads, 1, "nothing is requested once the window reaches the latest turn");
|
||||
|
||||
let blocked = 0;
|
||||
const guarded = { ...window, geometrySessionKey: "newer-auto-selected", onLoadNewerHistory: async () => { blocked += 1; return "loaded" as const; } };
|
||||
await harness.render(page, { ...guarded, hasNewerHistory: false });
|
||||
await harness.settle();
|
||||
const selected = harness.container.querySelector<HTMLElement>('[data-chat-anchor-key="a2"] .md p')!;
|
||||
const range = harness.dom.window.document.createRange();
|
||||
range.selectNodeContents(selected.firstChild!);
|
||||
const selection = harness.dom.window.getSelection()!;
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(range);
|
||||
await harness.render(page, guarded);
|
||||
await harness.settle();
|
||||
assert.equal(blocked, 0, "a native selection in the transcript blocks the automatic request");
|
||||
|
||||
let failed = 0;
|
||||
selection.removeAllRanges();
|
||||
const failing = { ...window, geometrySessionKey: "newer-auto-failed", onLoadNewerHistory: async () => { failed += 1; return "empty" as const; } };
|
||||
await harness.render(page, failing);
|
||||
await harness.settle();
|
||||
await harness.render([...page], failing);
|
||||
await harness.settle();
|
||||
assert.equal(failed, 1, "a page that did not load is not retried without the reader leaving and returning to the bottom");
|
||||
|
||||
const manual = harness.container.querySelector<HTMLButtonElement>(".chat-history-newer .btn")!;
|
||||
await act(async () => manual.click());
|
||||
assert.equal(failed, 2, "the manual button still requests a page");
|
||||
console.log("chat newer auto-load: bottom request, no repeat, selection guard and manual button passed");
|
||||
} finally { await harness.unmount(); await harness.close(); }
|
||||
@@ -0,0 +1,98 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { act, createElement } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { createTranscriptHarness } from "./transcript-dom-harness";
|
||||
import type { Item } from "../lib/useController";
|
||||
|
||||
const harness = await createTranscriptHarness({ deterministic: true });
|
||||
const { ChatSource } = await harness.loadModule<typeof import("../lib/chatViewSource")>("/src/lib/chatViewSource.ts");
|
||||
const { ChatNodeList } = await harness.loadModule<typeof import("../components/ChatNodes")>("/src/components/ChatNodes.tsx");
|
||||
const { ChatMountedOrder } = await harness.loadModule<typeof import("../lib/chatMountedOrder")>("/src/lib/chatMountedOrder.ts");
|
||||
const { ChatContentLoader } = await harness.loadModule<typeof import("../lib/chatContentLoader")>("/src/lib/chatContentLoader.ts");
|
||||
const { ChatScrollController } = await harness.loadModule<typeof import("../lib/chatScrollController")>("/src/lib/chatScrollController.ts");
|
||||
const { LocaleProvider } = await harness.loadModule<typeof import("../lib/i18n")>("/src/lib/i18n.tsx");
|
||||
|
||||
const source = new ChatSource("reasoning-load-full");
|
||||
let fail = false;
|
||||
let release: (() => void) | undefined;
|
||||
const loads: string[] = [];
|
||||
const loader = new ChatContentLoader(undefined, (item, field) => {
|
||||
if (field !== "reasoning") return Promise.resolve("");
|
||||
loads.push(item.id);
|
||||
if (fail) return Promise.reject(new Error("unavailable"));
|
||||
return new Promise<string>(resolve => { release = () => resolve((item as { reasoning: string }).reasoning); });
|
||||
});
|
||||
const scroll = new ChatScrollController("reasoning-load-full");
|
||||
const mounts = new ChatMountedOrder();
|
||||
await harness.unmount();
|
||||
const root = createRoot(harness.container);
|
||||
const question: Item = { kind: "user", id: "q", text: "think" };
|
||||
const live = (reasoning: string, streaming = true): Item => ({ kind: "assistant", id: "a", text: "", reasoning, streaming });
|
||||
const publish = async (items: Item[], running: boolean) => {
|
||||
await act(async () => source.update({ items, running, hydrating: false, hasOlder: false, loadingOlder: false }));
|
||||
await harness.settle();
|
||||
};
|
||||
const body = () => harness.container.querySelector(".chat-reasoning__body")?.textContent ?? "";
|
||||
const loadButton = () => [...harness.container.querySelectorAll<HTMLButtonElement>(".chat-reasoning__body > .btn")][0];
|
||||
|
||||
try {
|
||||
const first = "a".repeat(9000);
|
||||
await publish([question, live(first)], true);
|
||||
await act(async () => root.render(createElement(LocaleProvider, null,
|
||||
createElement(ChatNodeList, { source, mounts, loader, scroll, actions: { openDetails: () => {}, recover: () => {} } }))));
|
||||
await harness.settle();
|
||||
await act(async () => harness.container.querySelector<HTMLElement>(".chat-reasoning [data-disclosure-row]")!.click());
|
||||
assert.ok(loadButton(), "long streaming reasoning offers a full-text button");
|
||||
await act(async () => loadButton()!.click());
|
||||
await harness.settle();
|
||||
assert.ok(body().length >= first.length, "clicking load shows the whole reasoning while it streams");
|
||||
assert.equal(loadButton(), undefined, "the button is gone once the full text is shown");
|
||||
|
||||
const grown = first + "b".repeat(500);
|
||||
await publish([question, live(grown)], true);
|
||||
assert.ok(body().length >= grown.length, "the loaded view follows the growing stream instead of snapping back");
|
||||
assert.equal(loadButton(), undefined, "streaming chunks do not bring the button back");
|
||||
|
||||
const final = grown + "c".repeat(500);
|
||||
await publish([question, live(final, false)], false);
|
||||
assert.ok(body().length >= final.length, "the body never shrinks while the settled load is pending");
|
||||
assert.equal(loadButton(), undefined, "no button while the settled load is pending");
|
||||
await act(async () => { release?.(); });
|
||||
await harness.settle();
|
||||
assert.ok(body().length >= final.length, "the loaded view survives the settled load");
|
||||
assert.equal(loadButton(), undefined, "settling does not bring the button back");
|
||||
assert.equal(loads.length, 1, "the settled load runs once");
|
||||
|
||||
const copies = loads.length;
|
||||
await publish([question, { ...live("d".repeat(9000)), id: "b" }], true);
|
||||
await act(async () => harness.container.querySelectorAll<HTMLButtonElement>(".chat-reasoning .copybtn")[1]?.click());
|
||||
assert.equal(loads.length, copies, "copying while streaming does not start a load");
|
||||
|
||||
const start = loads.length;
|
||||
await publish([question, { ...live("e".repeat(9000)), id: "c" }], true);
|
||||
await act(async () => harness.container.querySelector<HTMLElement>(".chat-reasoning [data-disclosure-row]")!.click());
|
||||
await act(async () => loadButton()!.click());
|
||||
const idle = loadButton() ? "" : "gone";
|
||||
fail = true;
|
||||
const failedFinal = "e".repeat(9500);
|
||||
await publish([question, { ...live(failedFinal, false), id: "c" }], false);
|
||||
await harness.settle();
|
||||
assert.equal(loads.length, start + 1, "the failing load ran once");
|
||||
assert.ok(body().length >= failedFinal.length, "a failed load does not shrink the body");
|
||||
const retry = loadButton();
|
||||
assert.ok(retry, `a failed load keeps a retry button (${idle})`);
|
||||
assert.equal(retry!.disabled, false, "the retry button is enabled");
|
||||
const failedLabel = retry!.textContent;
|
||||
fail = false;
|
||||
await act(async () => retry!.click());
|
||||
await act(async () => { release?.(); });
|
||||
await harness.settle();
|
||||
assert.equal(loads.length, start + 2, "retry loads again");
|
||||
assert.equal(loadButton(), undefined, "a successful retry removes the button");
|
||||
assert.ok(failedLabel, "the failed state is labelled");
|
||||
} finally {
|
||||
await act(async () => root.unmount());
|
||||
source.dispose(); mounts.dispose(); loader.dispose(); scroll.dispose();
|
||||
await harness.unmount(); await harness.close();
|
||||
}
|
||||
console.log("reasoning load full: stays loaded across streaming chunks and settlement");
|
||||
@@ -0,0 +1,61 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { ChatSource, type ChatInput } from "../lib/chatViewSource";
|
||||
import type { Item } from "../lib/useController";
|
||||
|
||||
const tool = (id: string): Item => ({ kind: "tool", id, name: "read_file", args: "", readOnly: true, status: "done", output: "ok" });
|
||||
const answer = (id: string, streaming = false): Item => ({ kind: "assistant", id, text: "done", reasoning: "", streaming });
|
||||
const base: ChatInput = { items: [], running: false, hydrating: false, hasOlder: true, loadingOlder: false };
|
||||
const process = (source: ChatSource, key: string) => {
|
||||
const node = source.getNodeSnapshot(`${key}:process`);
|
||||
assert.equal(node?.kind, "process");
|
||||
return node as Extract<NonNullable<typeof node>, { kind: "process" }>;
|
||||
};
|
||||
|
||||
const head: Item[] = [tool("t1"), tool("t2"), answer("a1")];
|
||||
const settled = new ChatSource("head-settled");
|
||||
settled.update({ ...base, items: head });
|
||||
await Promise.resolve();
|
||||
assert.ok(process(settled, "history-head").foldable, "a head group with tools can fold");
|
||||
assert.ok(process(settled, "history-head").collapsed, "a settled head group starts folded");
|
||||
settled.toggleProcess("history-head");
|
||||
assert.equal(process(settled, "history-head").collapsed, false, "the head group folds and unfolds by hand");
|
||||
settled.toggleProcess("history-head");
|
||||
settled.dispose();
|
||||
|
||||
const running = new ChatSource("head-running");
|
||||
running.update({ ...base, items: [tool("t1"), answer("a1", true)], running: true });
|
||||
await Promise.resolve();
|
||||
const live = process(running, "history-head");
|
||||
assert.ok(!live.foldable && !live.collapsed, "a head group still running stays open");
|
||||
running.dispose();
|
||||
|
||||
const chosen = new ChatSource("head-choice");
|
||||
chosen.update({ ...base, items: head });
|
||||
await Promise.resolve();
|
||||
chosen.toggleProcess("history-head");
|
||||
const user: Item = { kind: "user", id: "u0", text: "long task", checkpointTurn: 1 };
|
||||
chosen.update({ ...base, items: [user, ...head], hasOlder: false });
|
||||
await Promise.resolve();
|
||||
assert.equal(chosen.getNodeSnapshot("history-head:process"), undefined, "the head key is gone once the user message arrives");
|
||||
assert.equal(process(chosen, "u0").collapsed, false, "the manual choice follows the turn when its user message loads");
|
||||
chosen.dispose();
|
||||
|
||||
const folded = new ChatSource("head-folded");
|
||||
folded.update({ ...base, items: head });
|
||||
await Promise.resolve();
|
||||
folded.update({ ...base, items: [user, ...head], hasOlder: false });
|
||||
await Promise.resolve();
|
||||
assert.ok(process(folded, "u0").collapsed, "an untouched head stays folded after the user message loads");
|
||||
folded.dispose();
|
||||
|
||||
const unrelated = new ChatSource("head-unrelated");
|
||||
unrelated.update({ ...base, items: head });
|
||||
await Promise.resolve();
|
||||
unrelated.toggleProcess("history-head");
|
||||
const other: Item[] = [{ kind: "user", id: "u9", text: "other", checkpointTurn: 9 }, tool("x1"), answer("ax")];
|
||||
unrelated.update({ ...base, items: other });
|
||||
await Promise.resolve();
|
||||
assert.ok(process(unrelated, "u9").collapsed, "a choice made on the head does not leak onto a turn that does not contain it");
|
||||
unrelated.dispose();
|
||||
|
||||
console.log("chat view: history head fold, running head and choice migration passed");
|
||||
@@ -48,12 +48,12 @@ const warning: Item = { kind: "notice", id: "warning", level: "warn", text: "int
|
||||
source.update({ ...input, items: [...settled, warning], running: false });
|
||||
await Promise.resolve();
|
||||
const failed = source.getNodeSnapshot("u2:process");
|
||||
assert.ok(failed?.kind === "process" && !failed.foldable);
|
||||
assert.ok(failed?.kind === "process" && failed.foldable && !failed.collapsed, "an interrupted turn stays open but can be folded");
|
||||
assert.ok(source.getOrderSnapshot().includes("warning"));
|
||||
source.update({ ...input, items: [settled[1]], running: false });
|
||||
await Promise.resolve();
|
||||
const incomplete = source.getNodeSnapshot("history-head:process");
|
||||
assert.ok(incomplete?.kind === "process" && !incomplete.foldable);
|
||||
assert.ok(incomplete?.kind === "process" && incomplete.foldable, "a group without its user message still has a fold seat");
|
||||
source.update(input);
|
||||
source.dispose();
|
||||
const notified = active;
|
||||
@@ -120,7 +120,8 @@ const restored = new ChatSource("recovered-weather");
|
||||
restored.update({ ...input, items: weather, running: false });
|
||||
assert.equal((restored.getNodeSnapshot("weather-user:process") as typeof compact).collapsed, false, "manual open survives session revisit");
|
||||
restored.update({ ...input, items: [...weather, { kind: "notice", id: "stopped", level: "info", text: "Stopped" }], running: false });
|
||||
assert.equal((restored.getNodeSnapshot("weather-user:process") as typeof compact).foldable, false, "terminal interruption is not hidden by a partial answer");
|
||||
const interrupted = restored.getNodeSnapshot("weather-user:process") as typeof compact;
|
||||
assert.ok(interrupted.foldable && !interrupted.collapsed, "terminal interruption stays open and keeps the manual choice");
|
||||
restored.dispose();
|
||||
|
||||
const deliverables = new ChatSource("deliverables");
|
||||
@@ -199,3 +200,34 @@ const maintenanceProcess = maintenanceSource.getNodeSnapshot("compact-user:proce
|
||||
assert.ok(maintenanceProcess?.kind === "process" && !maintenanceProcess.members.includes("maintenance:op"), "maintenance card is independent from the previous process fold");
|
||||
assert.ok(maintenanceSource.getOrderSnapshot().includes("maintenance:op"), "pending maintenance remains mounted");
|
||||
maintenanceSource.dispose();
|
||||
|
||||
const endings: Record<string, Item[]> = {
|
||||
clean: [{ kind: "assistant", id: "clean-answer", text: "done", reasoning: "", streaming: false, turnFinal: true }],
|
||||
stopped: [{ kind: "notice", id: "stopped-note", level: "info", text: "Stopped" }],
|
||||
errored: [{ kind: "notice", id: "errored-note", level: "warn", text: "provider error" }],
|
||||
unfinishedTool: [{ kind: "tool", id: "hung", name: "fleet", args: "{}", readOnly: true, status: "stopped" }],
|
||||
streamingLeftover: [{ kind: "assistant", id: "leftover", text: "partial", reasoning: "", streaming: true }],
|
||||
};
|
||||
for (const [ending, tail] of Object.entries(endings)) {
|
||||
const source = new ChatSource(`ending-${ending}`);
|
||||
const turn: Item[] = [
|
||||
{ kind: "user", id: "q", text: "go", checkpointTurn: 1 },
|
||||
{ kind: "tool", id: "t1", name: "bash", args: "{}", readOnly: true, status: "done", output: "ok" },
|
||||
...tail,
|
||||
];
|
||||
source.update({ ...input, items: turn, running: false });
|
||||
await Promise.resolve();
|
||||
const seat = () => source.getNodeSnapshot("q:process") as Extract<NonNullable<ReturnType<typeof source.getNodeSnapshot>>, { kind: "process" }>;
|
||||
assert.ok(seat().foldable, `${ending}: every ended turn has a fold control`);
|
||||
const startsFolded = seat().collapsed;
|
||||
assert.equal(startsFolded, ending === "clean", `${ending}: only a settled turn starts folded`);
|
||||
source.toggleProcess("q");
|
||||
assert.equal(seat().collapsed, !startsFolded, `${ending}: the control flips the turn`);
|
||||
source.update({ ...input, items: [...turn], running: false });
|
||||
await Promise.resolve();
|
||||
assert.equal(seat().collapsed, !startsFolded, `${ending}: the manual choice survives republication`);
|
||||
source.toggleProcess("q");
|
||||
assert.equal(seat().collapsed, startsFolded, `${ending}: the control flips back`);
|
||||
source.dispose();
|
||||
}
|
||||
console.log("chat view: every ended turn is foldable by hand");
|
||||
@@ -11,6 +11,7 @@ import type { AppBindings } from "../lib/bridge";
|
||||
import type { ComposerInvocation, StructuredInvocationSubmit } from "../lib/invocationDisplay";
|
||||
import type { CollaborationMode, CommandInfo, DirEntry, ToolApprovalMode } from "../lib/types";
|
||||
import { dispatchNativeFileDrop, installDesktopHostStub, type DesktopHostStubOptions } from "./desktopHostStub";
|
||||
import { createFileUploadMock, dispatchPasteFile } from "./fileUploadStub";
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
@@ -165,19 +166,7 @@ function mockApp(methods: Partial<AppBindings>, stubOptions?: DesktopHostStubOpt
|
||||
}).main.App, stubOptions ?? { getPathForFile: (file) => file.name });
|
||||
}
|
||||
|
||||
function dispatchPasteFile(textarea: HTMLTextAreaElement, file: File) {
|
||||
const event = new Event("paste", { bubbles: true, cancelable: true });
|
||||
Object.defineProperty(event, "clipboardData", {
|
||||
configurable: true,
|
||||
value: {
|
||||
files: [file],
|
||||
items: [],
|
||||
types: ["Files"],
|
||||
getData: () => "",
|
||||
},
|
||||
});
|
||||
textarea.dispatchEvent(event);
|
||||
}
|
||||
const mockFileUpload = createFileUploadMock("goal-attachment-target", { ok, eq });
|
||||
|
||||
function dispatchPasteText(input: HTMLElement, text: string) {
|
||||
const event = new Event("paste", { bubbles: true, cancelable: true });
|
||||
@@ -394,9 +383,8 @@ console.log("\ncomposer goal toggle");
|
||||
{
|
||||
// Attachment-only first Goal: no text, no skill — attachment refs are valid task context.
|
||||
const dom = installDom();
|
||||
mockApp({
|
||||
SavePastedFileForTarget: async () => ".reasonix/attachments/notes.txt",
|
||||
});
|
||||
mockApp({}, { getPathForFile: () => "" });
|
||||
const restoreUpload = mockFileUpload(async () => ".reasonix/attachments/notes.txt");
|
||||
const { root, calls } = await renderComposer({ collaborationMode: "goal", goal: "" });
|
||||
const textarea = document.querySelector("textarea") as HTMLTextAreaElement | null;
|
||||
if (!textarea) throw new Error("composer textarea did not render for attachment-only goal");
|
||||
@@ -423,6 +411,7 @@ console.log("\ncomposer goal toggle");
|
||||
root.unmount();
|
||||
});
|
||||
dom.window.close();
|
||||
restoreUpload();
|
||||
}
|
||||
|
||||
{
|
||||
@@ -750,10 +739,9 @@ console.log("\ncomposer goal toggle");
|
||||
|
||||
{
|
||||
const dom = installDom();
|
||||
mockApp({
|
||||
SavePastedFileForTarget: async () => {
|
||||
throw new Error("/Users/example/private.pdf: permission denied");
|
||||
},
|
||||
mockApp({}, { getPathForFile: () => "" });
|
||||
const restoreUpload = mockFileUpload(async () => {
|
||||
throw new Error("/Users/example/private.pdf: permission denied");
|
||||
});
|
||||
const { root, rerender } = await renderComposer();
|
||||
await rerender({ insertRequest: { id: 2, text: "keep this draft", mode: "replace" } });
|
||||
@@ -769,7 +757,7 @@ console.log("\ncomposer goal toggle");
|
||||
});
|
||||
await waitFor("pasted file failure toast", () => document.body.textContent?.includes("File attach failed") === true);
|
||||
|
||||
ok(document.body.textContent?.includes("File attach failed") === true, "SavePastedFile rejection shows a visible error");
|
||||
ok(document.body.textContent?.includes("File attach failed") === true, "raw upload rejection shows a visible error");
|
||||
eq(textarea.value, "keep this draft", "failed pasted file attach preserves composer text");
|
||||
ok(sendButton.disabled === false, "failed pasted file attach clears the pending state");
|
||||
ok(document.body.textContent?.includes("/Users/example") === false, "pasted file failure toast does not expose the local path");
|
||||
@@ -778,13 +766,13 @@ console.log("\ncomposer goal toggle");
|
||||
root.unmount();
|
||||
});
|
||||
dom.window.close();
|
||||
restoreUpload();
|
||||
}
|
||||
|
||||
{
|
||||
const dom = installDom();
|
||||
mockApp({
|
||||
SavePastedFileForTarget: async () => ".reasonix/attachments/notes.txt",
|
||||
});
|
||||
mockApp({}, { getPathForFile: () => "" });
|
||||
const restoreUpload = mockFileUpload(async () => ".reasonix/attachments/notes.txt");
|
||||
const { root } = await renderComposer();
|
||||
|
||||
const textarea = document.querySelector("textarea") as HTMLTextAreaElement | null;
|
||||
@@ -802,6 +790,7 @@ console.log("\ncomposer goal toggle");
|
||||
root.unmount();
|
||||
});
|
||||
dom.window.close();
|
||||
restoreUpload();
|
||||
}
|
||||
|
||||
{
|
||||
@@ -894,7 +883,7 @@ console.log("\ncomposer goal toggle");
|
||||
await flushTimers();
|
||||
});
|
||||
|
||||
eq(calls.send.join(","), "inspect @/Users/example/Folder With Spaces/", "external folder display text uses the real folder path");
|
||||
eq(calls.send.join(","), "inspect @/Users/example/Folder\\ With\\ Spaces/", "external folder display text preserves the real folder path as one escaped reference");
|
||||
eq(calls.submit.join(","), "inspect @__reasonix_external_folder/mock/Folder-With-Spaces/", "external folder submit text uses the session ref token");
|
||||
|
||||
await act(async () => {
|
||||
@@ -2099,16 +2088,16 @@ console.log("\ncomposer goal toggle");
|
||||
{
|
||||
const dom = installDom();
|
||||
let savedFiles = 0;
|
||||
const restoreUpload = mockFileUpload(async () => {
|
||||
savedFiles += 1;
|
||||
return ".reasonix/attachments/notes.txt";
|
||||
});
|
||||
mockApp({
|
||||
Commands: async () => [{ name: "skill", description: "Manage skills", kind: "builtin" }],
|
||||
ListDirForTarget: async () => [fileEntry("README.md")],
|
||||
SearchFileRefsForTarget: async () => [],
|
||||
ListSessions: async () => [{ path: "/sessions/recent.jsonl", title: "Recent session", current: false }],
|
||||
SavePastedFileForTarget: async () => {
|
||||
savedFiles += 1;
|
||||
return ".reasonix/attachments/notes.txt";
|
||||
},
|
||||
});
|
||||
}, { getPathForFile: () => "" });
|
||||
const { root, rerender } = await renderComposer();
|
||||
const textarea = document.querySelector("textarea") as HTMLTextAreaElement | null;
|
||||
if (!textarea) throw new Error("composer textarea did not render");
|
||||
@@ -2175,7 +2164,7 @@ console.log("\ncomposer goal toggle");
|
||||
await flushTimers();
|
||||
});
|
||||
await waitFor("attachment chosen from add-content menu", () => savedFiles === 1);
|
||||
eq(savedFiles, 1, "attachment action reuses the existing file-save path");
|
||||
eq(savedFiles, 1, "attachment action streams the chosen File exactly once");
|
||||
|
||||
await replaceComposerDraft(rerender, 3001, "@");
|
||||
await waitFor("workspace menu before plus toggle", () => Boolean(document.querySelector(".slashmenu")));
|
||||
@@ -2257,6 +2246,7 @@ console.log("\ncomposer goal toggle");
|
||||
root.unmount();
|
||||
});
|
||||
dom.window.close();
|
||||
restoreUpload();
|
||||
}
|
||||
|
||||
{
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import React, { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { JSDOM } from "jsdom";
|
||||
import { Composer } from "../components/Composer";
|
||||
import { LocaleProvider } from "../lib/i18n";
|
||||
import { ToastProvider } from "../lib/toast";
|
||||
import { flushAllSessionComposers, resumeSessionComposerEditing } from "../lib/sessionComposerPersistence";
|
||||
import { makeSessionUIMock } from "../lib/sessionUIMock";
|
||||
import { installDesktopHostStub } from "./desktopHostStub";
|
||||
import { createFileUploadMock, dispatchPasteFile } from "./fileUploadStub";
|
||||
|
||||
function deferred<T = void>() {
|
||||
let resolve!: (value: T) => void;
|
||||
const promise = new Promise<T>(done => { resolve = done; });
|
||||
return { promise, resolve };
|
||||
}
|
||||
const tick = () => new Promise<void>(done => setImmediate(done));
|
||||
const textarea = () => document.querySelector<HTMLTextAreaElement>("textarea")!;
|
||||
const sendButton = () => document.querySelector<HTMLButtonElement>(".composer__btn--send")!;
|
||||
async function menu(index: number) {
|
||||
await act(async () => {
|
||||
textarea().dispatchEvent(new window.MouseEvent("contextmenu", { bubbles: true, cancelable: true, clientX: 20, clientY: 20 }));
|
||||
});
|
||||
await act(async () => { document.querySelectorAll<HTMLButtonElement>(".context-menu__item")[index].click(); await tick(); });
|
||||
}
|
||||
async function fixture(session: string, commands: Record<string, unknown> = {}, formal = true) {
|
||||
const dom = new JSDOM("<div id='root'></div>", { url: "http://localhost/", pretendToBeVisual: true });
|
||||
Object.assign(globalThis, {
|
||||
window: dom.window, document: dom.window.document, localStorage: dom.window.localStorage,
|
||||
IS_REACT_ACT_ENVIRONMENT: true,
|
||||
requestAnimationFrame: dom.window.requestAnimationFrame.bind(dom.window),
|
||||
cancelAnimationFrame: dom.window.cancelAnimationFrame.bind(dom.window),
|
||||
ResizeObserver: class { observe() {} unobserve() {} disconnect() {} },
|
||||
});
|
||||
for (const name of ["Node", "HTMLElement", "HTMLTextAreaElement", "Event", "CustomEvent", "File", "FileReader", "MutationObserver"]) {
|
||||
Object.defineProperty(globalThis, name, { configurable: true, value: Reflect.get(dom.window, name) });
|
||||
}
|
||||
Object.defineProperty(globalThis, "navigator", { configurable: true, value: dom.window.navigator });
|
||||
Object.defineProperty(dom.window.HTMLElement.prototype, "attachEvent", { configurable: true, value() {} });
|
||||
Object.defineProperty(dom.window.HTMLElement.prototype, "detachEvent", { configurable: true, value() {} });
|
||||
window.matchMedia = (() => ({ matches: true, addEventListener() {}, removeEventListener() {} })) as typeof window.matchMedia;
|
||||
const backend = makeSessionUIMock(async () => {});
|
||||
const host = installDesktopHostStub({
|
||||
Commands: async () => [], Models: async () => [], ModelsForTab: async () => [],
|
||||
ListDir: async () => [], ListDirForTab: async () => [], SearchFileRefs: async () => [], SearchFileRefsForTab: async () => [],
|
||||
...backend, ...commands,
|
||||
}, { clipboardWriteResult: () => false });
|
||||
const ref = { hostId: "local", sessionId: session };
|
||||
const sent: string[] = [];
|
||||
let props: Parameters<typeof Composer>[0] = {
|
||||
running: false, collaborationMode: "normal", toolApprovalMode: "ask", goal: "", cwd: "/repo",
|
||||
modelLabel: "DeepSeek-R1", tabId: session, sessionKey: session, ready: true,
|
||||
formalSessionRef: formal ? ref : undefined,
|
||||
onSend: async text => { sent.push(text); },
|
||||
onCancel: async () => ({ discardedItemIds: [] }), onCycleMode() {}, onSetMode() {},
|
||||
onSetCollaborationMode() {}, onSetToolApprovalMode() {}, onClearGoal() {}, onSwitchModel() {}, onSetEffort() {},
|
||||
};
|
||||
const root = createRoot(document.getElementById("root")!);
|
||||
const paint = async (next: Partial<typeof props> = {}) => {
|
||||
props = { ...props, ...next };
|
||||
await act(async () => { root.render(<LocaleProvider><ToastProvider><Composer {...props}/></ToastProvider></LocaleProvider>); await tick(); });
|
||||
};
|
||||
await paint();
|
||||
let insertId = 0;
|
||||
return { ref, backend, sent, paint,
|
||||
async insert(text: string) { await paint({ insertRequest: { id: ++insertId, text, mode: "replace" } }); },
|
||||
async select(from: number, to = from) {
|
||||
await act(async () => { await new Promise<void>(done => requestAnimationFrame(() => done())); });
|
||||
textarea().setSelectionRange(from, to);
|
||||
},
|
||||
saved: async () => JSON.parse((await backend.GetSessionComposerState(ref)).contentJson),
|
||||
async dispose() {
|
||||
await act(async () => { resumeSessionComposerEditing(); root.unmount(); });
|
||||
host.uninstall(); dom.window.close();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
for (const kind of ["file", "image", "clipboard", "native"] as const) {
|
||||
test(`accepted ${kind} input blocks sending through publication and close`, { timeout: 15000 }, async () => {
|
||||
const entered = deferred(), release = deferred();
|
||||
const f = await fixture(kind, {
|
||||
CaptureAttachmentTarget: async () => { entered.resolve(); await release.promise; return { token: "input-target", capabilities: ["attachments-v2"] }; },
|
||||
ReleaseAttachmentTarget: async () => {},
|
||||
StageImageForTarget: async () => ({ draftId: "0123456789abcdef0123456789abcdef", displayName: "accepted.png", mime: "image/png", width: 1, height: 1, bytes: 12 }),
|
||||
SavePastedImageForComposerTarget: async () => ".reasonix/attachments/accepted.png",
|
||||
SaveClipboardImageForTarget: async () => ".reasonix/attachments/native.png",
|
||||
AttachmentDataURLForTarget: async () => "data:image/png;base64,aGVsbG8=",
|
||||
ReadDraftImageForTarget: async () => "data:image/png;base64,aGVsbG8=",
|
||||
});
|
||||
const restoreUpload = createFileUploadMock("input-target", {
|
||||
ok: (value, message) => assert.ok(value, message), eq: (a, b, message) => assert.equal(a, b, message),
|
||||
})(async () => ".reasonix/attachments/accepted.txt");
|
||||
Object.defineProperty(navigator, "clipboard", { configurable: true, value: {
|
||||
read: async () => kind === "native" ? [{ types: ["image/png"] }] : [],
|
||||
readText: async () => { entered.resolve(); await release.promise; return " ACCEPTED PASTE"; },
|
||||
}});
|
||||
try {
|
||||
await f.insert("original message");
|
||||
await f.select(16);
|
||||
if (kind === "clipboard" || kind === "native") await menu(4);
|
||||
else await act(async () => { dispatchPasteFile(textarea(), new File(["hello"], kind === "image" ? "accepted.png" : "accepted.txt", { type: kind === "image" ? "image/png" : "text/plain" })); });
|
||||
await entered.promise;
|
||||
assert.equal(sendButton().disabled, true, "send is blocked even before target capture/read completes");
|
||||
await act(async () => {
|
||||
sendButton().click();
|
||||
textarea().dispatchEvent(new window.KeyboardEvent("keydown", { key: "Enter", bubbles: true }));
|
||||
await tick();
|
||||
});
|
||||
assert.deepEqual(f.sent, []);
|
||||
let closed = false, closing!: Promise<void>;
|
||||
await act(async () => { closing = flushAllSessionComposers().then(() => { closed = true; }); await tick(); });
|
||||
assert.equal(closed, false, "close waits for accepted input");
|
||||
await act(async () => { release.resolve(); await closing; });
|
||||
const saved = await f.saved();
|
||||
if (kind === "clipboard") assert.equal(saved.text, "original message ACCEPTED PASTE");
|
||||
else { assert.equal(saved.text, "original message"); assert.equal(saved.attachments.length, 1); }
|
||||
await act(async () => resumeSessionComposerEditing());
|
||||
assert.equal(sendButton().disabled, false, "completed input releases the send guard");
|
||||
if (kind === "clipboard") {
|
||||
await act(async () => { sendButton().click(); await tick(); });
|
||||
await act(async () => { await flushAllSessionComposers(); });
|
||||
assert.deepEqual(f.sent, ["original message ACCEPTED PASTE"]);
|
||||
assert.deepEqual(await f.saved(), {}, "only the complete submitted draft is cleared");
|
||||
}
|
||||
} finally { release.resolve(); restoreUpload(); await f.dispose(); }
|
||||
});
|
||||
}
|
||||
|
||||
for (const kind of ["changed", "unchanged", "background", "local-changed", "clipboard-failed"] as const) {
|
||||
test(`cut preserves ${kind} content and participates in close draining`, { timeout: 15000 }, async () => {
|
||||
const entered = deferred(), release = deferred();
|
||||
const f = await fixture("cut-" + kind, {}, kind !== "local-changed");
|
||||
let copied = "";
|
||||
Object.defineProperty(navigator, "clipboard", { configurable: true, value: {
|
||||
writeText: async (text: string) => {
|
||||
entered.resolve(); await release.promise;
|
||||
if (kind === "clipboard-failed") throw Error("clipboard unavailable");
|
||||
copied = text;
|
||||
},
|
||||
}});
|
||||
try {
|
||||
await f.insert("abcdef");
|
||||
await f.select(1, 4);
|
||||
await menu(2);
|
||||
await entered.promise;
|
||||
if (kind === "changed" || kind === "local-changed") await f.insert("NEW CONTENT");
|
||||
if (kind === "background") await f.paint({
|
||||
tabId: "other", sessionKey: "other", formalSessionRef: { hostId: "local", sessionId: "other" }, insertRequest: null,
|
||||
});
|
||||
let closed = false, closing!: Promise<void>;
|
||||
if (kind !== "local-changed") {
|
||||
await act(async () => { closing = flushAllSessionComposers().then(() => { closed = true; }); await tick(); });
|
||||
assert.equal(closed, false, "close waits for an admitted cut");
|
||||
}
|
||||
await act(async () => { release.resolve(); if (closing) await closing; else await tick(); });
|
||||
assert.equal(copied, kind === "clipboard-failed" ? "" : "bcd");
|
||||
if (kind === "local-changed") assert.equal(textarea().value, "NEW CONTENT");
|
||||
else {
|
||||
const expected = kind === "changed" ? "NEW CONTENT" : kind === "clipboard-failed" ? "abcdef" : "aef";
|
||||
assert.equal((await f.saved()).text, expected);
|
||||
await act(async () => resumeSessionComposerEditing());
|
||||
if (kind === "background") {
|
||||
assert.equal(textarea().value, "", "background cut never mutates the current composer");
|
||||
await f.paint({ tabId: f.ref.sessionId, sessionKey: f.ref.sessionId, formalSessionRef: f.ref });
|
||||
}
|
||||
assert.equal(textarea().value, expected);
|
||||
}
|
||||
} finally { release.resolve(); await f.dispose(); }
|
||||
});
|
||||
}
|
||||
|
||||
test("native paste fallback owns input before its timer fires", { timeout: 15000 }, async () => {
|
||||
const entered = deferred(), release = deferred();
|
||||
const f = await fixture("native-timer", {
|
||||
CaptureAttachmentTarget: async () => { entered.resolve(); await release.promise; return { token: "timer-target", capabilities: ["attachments-v2"] }; },
|
||||
ReleaseAttachmentTarget: async () => {},
|
||||
SaveClipboardImageForTarget: async () => ".reasonix/attachments/native.png",
|
||||
AttachmentDataURLForTarget: async () => "data:image/png;base64,aGVsbG8=",
|
||||
});
|
||||
const originalTimer = window.setTimeout;
|
||||
let fire!: () => void;
|
||||
try {
|
||||
await f.insert("with native image");
|
||||
window.setTimeout = ((handler: TimerHandler, delay?: number, ...args: unknown[]) => {
|
||||
if (delay === 160) { fire = handler as () => void; return -1; }
|
||||
return originalTimer(handler, delay, ...args);
|
||||
}) as typeof window.setTimeout;
|
||||
await act(async () => { textarea().dispatchEvent(new window.KeyboardEvent("keydown", { key: "v", ctrlKey: true, bubbles: true })); });
|
||||
assert.equal(typeof fire, "function");
|
||||
assert.equal(sendButton().disabled, true);
|
||||
let closed = false, closing!: Promise<void>;
|
||||
await act(async () => { closing = flushAllSessionComposers().then(() => { closed = true; }); await tick(); });
|
||||
assert.equal(closed, false);
|
||||
await act(async () => { fire(); await entered.promise; });
|
||||
assert.equal(closed, false);
|
||||
await act(async () => { release.resolve(); await closing; });
|
||||
const saved = await f.saved();
|
||||
assert.equal(saved.text, "with native image");
|
||||
assert.equal(saved.attachments[0].path, ".reasonix/attachments/native.png");
|
||||
} finally { window.setTimeout = originalTimer; release.resolve(); await f.dispose(); }
|
||||
});
|
||||
@@ -0,0 +1,346 @@
|
||||
// Run: tsx src/__tests__/composer-oversize-attachment.test.tsx
|
||||
|
||||
import { JSDOM } from "jsdom";
|
||||
import React from "react";
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { Composer } from "../components/Composer";
|
||||
import { LocaleProvider } from "../lib/i18n";
|
||||
import { ToastProvider } from "../lib/toast";
|
||||
import type { CollaborationMode, ToolApprovalMode } from "../lib/types";
|
||||
import { installDesktopHostStub, type DesktopHostStubOptions } from "./desktopHostStub";
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
function ok(value: boolean, label: string) {
|
||||
if (value) {
|
||||
process.stdout.write(` PASS ${label}\n`);
|
||||
passed += 1;
|
||||
} else {
|
||||
process.stdout.write(` FAIL ${label}\n`);
|
||||
failed += 1;
|
||||
}
|
||||
}
|
||||
|
||||
function eq(actual: unknown, expected: unknown, label: string) {
|
||||
if (actual === expected) ok(true, label);
|
||||
else ok(false, `${label}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`);
|
||||
}
|
||||
|
||||
function flushTimers(): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
|
||||
async function waitFor(check: () => boolean, attempts = 10): Promise<void> {
|
||||
for (let i = 0; i < attempts; i++) {
|
||||
if (check()) return;
|
||||
await act(async () => {
|
||||
await flushTimers();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
class TestResizeObserver {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
}
|
||||
|
||||
function installDom() {
|
||||
const dom = new JSDOM("<!doctype html><html><body><div id=\"root\"></div></body></html>", {
|
||||
pretendToBeVisual: true,
|
||||
url: "http://localhost/",
|
||||
});
|
||||
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
globalThis.window = dom.window as unknown as Window & typeof globalThis;
|
||||
globalThis.document = dom.window.document;
|
||||
Object.defineProperty(globalThis, "navigator", { configurable: true, value: dom.window.navigator });
|
||||
globalThis.Node = dom.window.Node;
|
||||
globalThis.HTMLElement = dom.window.HTMLElement;
|
||||
globalThis.HTMLTextAreaElement = dom.window.HTMLTextAreaElement;
|
||||
globalThis.Event = dom.window.Event;
|
||||
globalThis.CustomEvent = dom.window.CustomEvent;
|
||||
globalThis.KeyboardEvent = dom.window.KeyboardEvent;
|
||||
globalThis.InputEvent = dom.window.InputEvent;
|
||||
globalThis.MouseEvent = dom.window.MouseEvent;
|
||||
globalThis.File = dom.window.File;
|
||||
globalThis.FileReader = dom.window.FileReader;
|
||||
globalThis.PointerEvent = dom.window.MouseEvent as unknown as typeof PointerEvent;
|
||||
globalThis.MutationObserver = dom.window.MutationObserver;
|
||||
globalThis.localStorage = dom.window.localStorage;
|
||||
globalThis.requestAnimationFrame = dom.window.requestAnimationFrame.bind(dom.window);
|
||||
globalThis.cancelAnimationFrame = dom.window.cancelAnimationFrame.bind(dom.window);
|
||||
globalThis.ResizeObserver = TestResizeObserver;
|
||||
Object.defineProperty(dom.window.HTMLElement.prototype, "attachEvent", { configurable: true, value: () => {} });
|
||||
Object.defineProperty(dom.window.HTMLElement.prototype, "detachEvent", { configurable: true, value: () => {} });
|
||||
Object.defineProperty(window, "matchMedia", {
|
||||
configurable: true,
|
||||
value: () => ({
|
||||
matches: true,
|
||||
media: "(prefers-reduced-motion: reduce)",
|
||||
onchange: null,
|
||||
addEventListener() {},
|
||||
removeEventListener() {},
|
||||
addListener() {},
|
||||
removeListener() {},
|
||||
dispatchEvent: () => false,
|
||||
}),
|
||||
});
|
||||
return dom;
|
||||
}
|
||||
|
||||
function installBridgeApp(methods: Record<string, unknown>, options?: DesktopHostStubOptions) {
|
||||
const legacySave = methods.SavePastedImageForTarget as ((token: string, dataURL: string) => Promise<string>) | undefined;
|
||||
const legacyPreview = methods.AttachmentDataURLForTarget as ((token: string, path: string) => Promise<string>) | undefined;
|
||||
const unscopedPreview = methods.AttachmentDataURL as ((path: string) => Promise<string>) | undefined;
|
||||
return installDesktopHostStub({
|
||||
Commands: async () => [],
|
||||
Models: async () => [],
|
||||
ModelsForTab: async () => [],
|
||||
CaptureAttachmentTarget: async () => ({ token: "test-attachment-target", capabilities: ["attachments-v2"] }),
|
||||
ReleaseAttachmentTarget: async () => {},
|
||||
StageImageForTarget: async (token: string, _operationID: string, displayName: string, mime: string, dataURL: string) => ({
|
||||
draftId: "",
|
||||
path: legacySave ? await legacySave(token, dataURL) : ".reasonix/attachments/mock.png",
|
||||
displayName,
|
||||
mime,
|
||||
width: 1,
|
||||
height: 1,
|
||||
bytes: dataURL.length,
|
||||
}),
|
||||
ReadDraftImageForTarget: async () => "data:image/png;base64,iVBORw0KGgo=",
|
||||
AttachmentDataURLForTarget: legacyPreview ?? (async () => "data:image/png;base64,iVBORw0KGgo="),
|
||||
AttachmentDataURLForTab: async (_tabID: string, path: string) => unscopedPreview ? unscopedPreview(path) : "data:image/png;base64,iVBORw0KGgo=",
|
||||
...methods,
|
||||
}, options);
|
||||
}
|
||||
|
||||
async function renderComposer(props: Partial<Parameters<typeof Composer>[0]> = {}) {
|
||||
const rootEl = document.getElementById("root");
|
||||
if (!rootEl) throw new Error("missing root");
|
||||
const root = createRoot(rootEl);
|
||||
let currentProps: Parameters<typeof Composer>[0] = {
|
||||
running: false,
|
||||
collaborationMode: "normal",
|
||||
toolApprovalMode: "ask" as ToolApprovalMode,
|
||||
|
||||
goal: "",
|
||||
cwd: "/repo",
|
||||
modelLabel: "DeepSeek-R1",
|
||||
imageInputEnabled: true,
|
||||
tabId: "single-surface-tab",
|
||||
sessionKey: "session:project:/repo:topic-a:session-a",
|
||||
onSend: () => {},
|
||||
onCancel: async () => ({ discardedItemIds: [] }),
|
||||
onCycleMode: () => {},
|
||||
onSetMode: () => {},
|
||||
onSetCollaborationMode: (_mode: CollaborationMode) => {},
|
||||
onSetToolApprovalMode: () => {},
|
||||
onClearGoal: () => {},
|
||||
onSwitchModel: () => {},
|
||||
onSetEffort: () => {},
|
||||
|
||||
ready: true,
|
||||
...props,
|
||||
};
|
||||
const paint = async (nextProps: Partial<Parameters<typeof Composer>[0]> = {}) => {
|
||||
currentProps = { ...currentProps, ...nextProps };
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<LocaleProvider>
|
||||
<ToastProvider>
|
||||
<div className="chat-pane">
|
||||
<Composer {...currentProps} />
|
||||
</div>
|
||||
</ToastProvider>
|
||||
</LocaleProvider>,
|
||||
);
|
||||
await flushTimers();
|
||||
});
|
||||
};
|
||||
await paint();
|
||||
return { root, rerender: paint };
|
||||
}
|
||||
|
||||
function textarea(): HTMLTextAreaElement {
|
||||
const node = document.querySelector("textarea") as HTMLTextAreaElement | null;
|
||||
if (!node) throw new Error("composer textarea did not render");
|
||||
return node;
|
||||
}
|
||||
|
||||
function sendButton(): HTMLButtonElement {
|
||||
const node = document.querySelector(".composer__btn--send") as HTMLButtonElement | null;
|
||||
if (!node) throw new Error("send button did not render");
|
||||
return node;
|
||||
}
|
||||
|
||||
function contextItemCount(): number {
|
||||
return document.querySelectorAll(".composer-context__item").length;
|
||||
}
|
||||
|
||||
function toastText(): string {
|
||||
const items = Array.from(document.querySelectorAll(".toast__text"));
|
||||
return (items.at(-1)?.textContent ?? "").trim();
|
||||
}
|
||||
|
||||
function imagePasteEvent(file: File): Event {
|
||||
const event = new Event("paste", { bubbles: true, cancelable: true });
|
||||
Object.defineProperty(event, "clipboardData", {
|
||||
configurable: true,
|
||||
value: {
|
||||
files: [file],
|
||||
items: [],
|
||||
types: [file.type],
|
||||
getData: () => "",
|
||||
},
|
||||
});
|
||||
return event;
|
||||
}
|
||||
|
||||
|
||||
|
||||
function pasteEvent(file: File): Event {
|
||||
const event = new Event("paste", { bubbles: true, cancelable: true });
|
||||
Object.defineProperty(event, "clipboardData", {
|
||||
configurable: true,
|
||||
value: { files: [file], items: [], types: [file.type], getData: () => "" },
|
||||
});
|
||||
return event;
|
||||
}
|
||||
|
||||
function oversized(name: string, type: string, bytes: number): File {
|
||||
const file = new File(["x"], name, { type, lastModified: 1 });
|
||||
Object.defineProperty(file, "size", { configurable: true, value: bytes });
|
||||
return file;
|
||||
}
|
||||
|
||||
console.log("\ncomposer oversize attachment");
|
||||
|
||||
for (const [name, type, bytes] of [
|
||||
["huge.png", "image/png", 64 * 1024 * 1024 + 1],
|
||||
] as const) {
|
||||
const dom = installDom();
|
||||
let reads = 0;
|
||||
let digests = 0;
|
||||
let saves = 0;
|
||||
const origRead = dom.window.FileReader.prototype.readAsDataURL;
|
||||
dom.window.FileReader.prototype.readAsDataURL = function (this: FileReader, blob: Blob) {
|
||||
reads += 1;
|
||||
return origRead.call(this, blob);
|
||||
};
|
||||
Object.defineProperty(globalThis, "crypto", {
|
||||
configurable: true,
|
||||
value: { randomUUID: () => "id", subtle: { digest: async () => { digests += 1; return new ArrayBuffer(32); } } },
|
||||
});
|
||||
installBridgeApp({
|
||||
SavePastedFileForTarget: async () => { saves += 1; return ".reasonix/attachments/x.bin"; },
|
||||
StageImageForTarget: async () => { saves += 1; throw new Error("must not stage"); },
|
||||
});
|
||||
const { root } = await renderComposer({ imageInputEnabled: true });
|
||||
const file = oversized(name, type, bytes);
|
||||
const blobRead = file.arrayBuffer;
|
||||
let buffered = 0;
|
||||
file.arrayBuffer = () => { buffered += 1; return blobRead.call(file); };
|
||||
|
||||
await act(async () => {
|
||||
textarea().dispatchEvent(pasteEvent(file));
|
||||
await flushTimers();
|
||||
await flushTimers();
|
||||
});
|
||||
await waitFor(() => toastText() !== "");
|
||||
eq(reads, 0, `${name}: FileReader never started`);
|
||||
eq(buffered, 0, `${name}: file bytes never buffered for hashing`);
|
||||
eq(digests, 0, `${name}: no digest computed`);
|
||||
eq(saves, 0, `${name}: nothing sent to the kernel`);
|
||||
eq(contextItemCount(), 0, `${name}: no attachment added`);
|
||||
ok(/too large/i.test(toastText()), `${name}: toast says the file is too large (got "${toastText()}")`);
|
||||
await act(async () => root.unmount());
|
||||
dom.window.close();
|
||||
}
|
||||
|
||||
for (const entry of ["pick", "paste"] as const) {
|
||||
for (const nativePath of ["", "/repo/releases/large app.apk"]) {
|
||||
for (const [name, type, bytes] of [
|
||||
["large app.apk", "application/vnd.android.package-archive", 900 * 1024 * 1024],
|
||||
["pack.zip", "application/zip", 25 * 1024 * 1024 + 1],
|
||||
["small.zip", "application/zip", 2],
|
||||
] as const) {
|
||||
const dom = installDom();
|
||||
const label = `${entry} ${nativePath ? "native" : "pathless"} ${name}`;
|
||||
let reads = 0;
|
||||
let digests = 0;
|
||||
let buffered = 0;
|
||||
let legacySaves = 0;
|
||||
const nativeCalls: Array<{ token: string; path: string }> = [];
|
||||
const uploads: Array<{ input: string; init: RequestInit }> = [];
|
||||
const originalFetch = globalThis.fetch;
|
||||
globalThis.fetch = async (input, init = {}) => {
|
||||
uploads.push({ input: String(input), init });
|
||||
return new Response(JSON.stringify({ path: ".reasonix/attachments/uploaded.apk" }), {
|
||||
status: 201, headers: { "content-type": "application/json" },
|
||||
});
|
||||
};
|
||||
const originalReader = dom.window.FileReader.prototype.readAsDataURL;
|
||||
dom.window.FileReader.prototype.readAsDataURL = function (this: FileReader, blob: Blob) {
|
||||
reads++;
|
||||
return originalReader.call(this, blob);
|
||||
};
|
||||
Object.defineProperty(globalThis, "crypto", {
|
||||
configurable: true,
|
||||
value: { randomUUID: () => "id", subtle: { digest: async () => { digests++; return new ArrayBuffer(32); } } },
|
||||
});
|
||||
installBridgeApp({
|
||||
SavePastedFileForTarget: async () => { legacySaves++; return ".reasonix/attachments/legacy.bin"; },
|
||||
AttachDroppedForTarget: async (token: string, path: string) => {
|
||||
nativeCalls.push({ token, path });
|
||||
return { kind: "workspace", path: "releases/large app.apk", isDir: false };
|
||||
},
|
||||
}, { getPathForFile: () => nativePath });
|
||||
const { root } = await renderComposer({ imageInputEnabled: true });
|
||||
const file = oversized(name, type, bytes);
|
||||
file.arrayBuffer = async () => { buffered++; return new ArrayBuffer(1); };
|
||||
await act(async () => {
|
||||
if (entry === "paste") textarea().dispatchEvent(pasteEvent(file));
|
||||
else {
|
||||
const input = document.querySelector<HTMLInputElement>("input[type=file]");
|
||||
if (!input) throw new Error("file picker did not render");
|
||||
Object.defineProperty(input, "files", { configurable: true, value: [file] });
|
||||
input.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
}
|
||||
await flushTimers();
|
||||
await flushTimers();
|
||||
});
|
||||
await waitFor(() => contextItemCount() > 0);
|
||||
eq(contextItemCount(), 1, `${label}: appears in chat context`);
|
||||
eq(toastText(), "", `${label}: no size or upload error`);
|
||||
eq(reads, 0, `${label}: FileReader never starts`);
|
||||
eq(buffered, 0, `${label}: file bytes never buffered`);
|
||||
eq(digests, 0, `${label}: no full-file digest`);
|
||||
eq(legacySaves, 0, `${label}: no Base64 RPC`);
|
||||
eq(nativeCalls.length, nativePath ? 1 : 0, `${label}: native reference used when available`);
|
||||
eq(uploads.length, nativePath ? 0 : 1, `${label}: HTTP upload only for a pathless file`);
|
||||
if (nativeCalls[0]) {
|
||||
eq(nativeCalls[0].token, "test-attachment-target", `${label}: native reference bound to source target`);
|
||||
eq(nativeCalls[0].path, nativePath, `${label}: original native path preserved`);
|
||||
ok(document.body.textContent?.includes("large app.apk") === true, `${label}: workspace reference is visible`);
|
||||
}
|
||||
if (uploads[0]) {
|
||||
const uploaded = uploads[0];
|
||||
const url = new URL(uploaded.input, "http://localhost/");
|
||||
eq(url.pathname, "/__reasonix_attachment_upload", `${label}: raw upload endpoint`);
|
||||
eq(url.searchParams.get("target"), "test-attachment-target", `${label}: target token preserved`);
|
||||
eq(url.searchParams.get("name"), name, `${label}: filename preserved`);
|
||||
eq(uploaded.init.method, "POST", `${label}: POST upload`);
|
||||
eq(uploaded.init.body, file, `${label}: original File is the request body`);
|
||||
eq(new Headers(uploaded.init.headers).get("content-type"), "application/octet-stream", `${label}: raw binary content type`);
|
||||
}
|
||||
await act(async () => root.unmount());
|
||||
dom.window.close();
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`);
|
||||
if (failed > 0) process.exit(1);
|
||||
@@ -0,0 +1,55 @@
|
||||
// A busy-window submit (the desktop thinks idle, the serve is still finishing)
|
||||
// is answered with a durable queue receipt. The composer must show the queue
|
||||
// entry immediately and treat the message as delivered-to-queue, never as a
|
||||
// failure — local busy sends behave the same way.
|
||||
import assert from "node:assert/strict";
|
||||
import { act } from "react";
|
||||
import { installDom, installBridgeApp, renderComposer } from "./composerInboxHarness";
|
||||
|
||||
const flush = () => new Promise<void>(resolve => setTimeout(resolve, 0));
|
||||
|
||||
async function paste(text: string) {
|
||||
await act(async () => {
|
||||
const event = new window.Event("paste", { bubbles: true, cancelable: true });
|
||||
Object.defineProperty(event, "clipboardData", { value: { files: [], items: [], types: ["text/plain"], getData: () => text } });
|
||||
document.querySelector("textarea.composer__input:not([aria-hidden=true])")!.dispatchEvent(event);
|
||||
await flush();
|
||||
});
|
||||
}
|
||||
async function send() {
|
||||
await act(async () => { (document.querySelector(".composer__btn--send") as HTMLButtonElement).click(); await flush(); });
|
||||
}
|
||||
const emptySnapshot = { revision: 1, paused: false, recovered: false, items: [], itemsCount: 0, bytes: 0, maxItems: 64, maxBytes: 1024 };
|
||||
const queuedSnapshot = {
|
||||
revision: 2, paused: false, recovered: false, itemsCount: 1, bytes: 12, maxItems: 64, maxBytes: 1024,
|
||||
items: [{ id: "queued-1", intent: "followup", state: "queued", preview: "queued while busy" }],
|
||||
};
|
||||
const queuedError = () => Object.assign(new Error("reasonix_error:queued_followup"), {
|
||||
code: "business",
|
||||
data: { method: "SubmitRemoteTabWithSubmission", queuedFollowup: { itemId: "queued-1", disposition: "queued_followup", position: 0, paused: false } },
|
||||
});
|
||||
|
||||
installDom();
|
||||
installBridgeApp({
|
||||
InboxQueueForTarget: async () => ({ outcome: "applied", snapshot: emptySnapshot }),
|
||||
InboxSnapshot: async () => queuedSnapshot,
|
||||
});
|
||||
await renderComposer({
|
||||
running: false,
|
||||
inboxSessionPath: "session-a",
|
||||
onSend: async () => { throw queuedError(); },
|
||||
});
|
||||
await paste("queued while busy");
|
||||
await send();
|
||||
|
||||
// The queue strip reconciles through the snapshot refresh; let it settle.
|
||||
await act(async () => { await flush(); await flush(); await new Promise(r => setTimeout(r, 60)); await flush(); });
|
||||
const body = document.body.textContent ?? "";
|
||||
assert.ok(body.includes("queued while busy"), "the queued message is visible in the pending queue");
|
||||
assert.equal(body.includes("Send failed"), false, "a queued submit is not reported as a failed send");
|
||||
assert.equal(body.includes("Refresh the session"), false, "a queued submit never asks the user to refresh");
|
||||
assert.equal((document.querySelector("textarea.composer__input:not([aria-hidden=true])") as HTMLTextAreaElement).value, "",
|
||||
"the composer clears once the message is queued");
|
||||
|
||||
console.log("composer queued follow-up outcome: busy submits surface as a visible queue entry");
|
||||
process.exit(0);
|
||||
@@ -10,6 +10,7 @@ import { resetCustomShortcuts, saveCustomShortcut } from "../lib/keyboardShortcu
|
||||
import { ToastProvider } from "../lib/toast";
|
||||
import type { CollaborationMode, ToolApprovalMode } from "../lib/types";
|
||||
import { installDesktopHostStub } from "./desktopHostStub";
|
||||
import { createFileUploadMock, dispatchPasteFile } from "./fileUploadStub";
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
@@ -941,11 +942,11 @@ console.log("\ncomposer session draft");
|
||||
const saveStarted = deferred<void>();
|
||||
const savePastedFile = deferred<string>();
|
||||
const sent: string[] = [];
|
||||
installBridgeApp({
|
||||
SavePastedFileForTarget: async () => {
|
||||
saveStarted.resolve();
|
||||
return savePastedFile.promise;
|
||||
},
|
||||
installBridgeApp({});
|
||||
const restoreUpload = createFileUploadMock("test-attachment-target", { ok, eq })(async (file) => {
|
||||
eq(file.name, "draft.txt", "pending upload preserves the source file");
|
||||
saveStarted.resolve();
|
||||
return savePastedFile.promise;
|
||||
});
|
||||
const { root, rerender } = await renderComposer({
|
||||
onSend: (text) => {
|
||||
@@ -953,19 +954,9 @@ console.log("\ncomposer session draft");
|
||||
},
|
||||
});
|
||||
const file = new File(["draft attachment"], "draft.txt", { type: "text/plain", lastModified: 1 });
|
||||
const event = new Event("paste", { bubbles: true, cancelable: true });
|
||||
Object.defineProperty(event, "clipboardData", {
|
||||
configurable: true,
|
||||
value: {
|
||||
files: [file],
|
||||
items: [],
|
||||
types: [],
|
||||
getData: () => "",
|
||||
},
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
textarea().dispatchEvent(event);
|
||||
dispatchPasteFile(textarea(), file);
|
||||
await saveStarted.promise;
|
||||
});
|
||||
await rerender({ insertRequest: { id: 10, text: "session A waits for its attachment", mode: "replace" } });
|
||||
@@ -993,6 +984,7 @@ console.log("\ncomposer session draft");
|
||||
root.unmount();
|
||||
});
|
||||
dom.window.close();
|
||||
restoreUpload();
|
||||
}
|
||||
|
||||
{
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
// A transient inbox-target fence (session switching, reconnecting, route
|
||||
// adoption) clears by itself. The composer must wait it out and deliver the
|
||||
// message instead of surfacing "the message was not sent" for a window the
|
||||
// user cannot act on.
|
||||
import assert from "node:assert/strict";
|
||||
import { act } from "react";
|
||||
import { installDom, installBridgeApp, renderComposer } from "./composerInboxHarness";
|
||||
import { runtimeStateStore, type RuntimeProjection } from "../lib/runtimeStateStore";
|
||||
import { acceptRuntimeState } from "../lib/runtimeStateReducer";
|
||||
import { pendingFollowups, followupSessionKey, type InboxTarget } from "../lib/pendingFollowup";
|
||||
import { formatInboxError } from "../lib/inboxError";
|
||||
const TRANSIENT_INBOX_TARGET_CODE = "inbox_target_transient";
|
||||
const TRANSIENT_GUIDANCE_RETRY_DELAYS_MS = [150, 350, 500, 500];
|
||||
|
||||
const pendingSession = followupSessionKey("session-a");
|
||||
const DEAD_END_COPY = "Refresh the session";
|
||||
|
||||
const flush = () => new Promise<void>(resolve => setTimeout(resolve, 0));
|
||||
const settle = (ms: number) => new Promise<void>(resolve => setTimeout(resolve, ms));
|
||||
|
||||
async function paste(text: string) {
|
||||
await act(async () => {
|
||||
const event = new window.Event("paste", { bubbles: true, cancelable: true });
|
||||
Object.defineProperty(event, "clipboardData", { value: { files: [], items: [], types: ["text/plain"], getData: () => text } });
|
||||
document.querySelector("textarea.composer__input:not([aria-hidden=true])")!.dispatchEvent(event);
|
||||
await flush();
|
||||
});
|
||||
}
|
||||
async function send() {
|
||||
await act(async () => { (document.querySelector(".composer__btn--send") as HTMLButtonElement).click(); await flush(); });
|
||||
}
|
||||
function transientError() {
|
||||
return Object.assign(new Error(`reasonix_error:${TRANSIENT_INBOX_TARGET_CODE}`), { code: "business", data: { transient: true } });
|
||||
}
|
||||
function state(scenario: number, phase: "idle" | "executing" | "finishing", revision: number): RuntimeProjection {
|
||||
return { epoch: `transient-test-${scenario}`, revision, topics: [], sessions: [{
|
||||
tabId: "tab-a", scope: "project", workspaceRoot: "/repo", topicId: "topic", sessionPath: "session-a", sessionGeneration: 1,
|
||||
open: true, remote: true, freshness: "synced", state: { schemaVersion: 1, runtimeEpoch: "controller", revision,
|
||||
phase, running: phase !== "idle", turnId: "turn", turnStatus: "completed", turnEventSeq: 3, turnStartedAt: 0,
|
||||
pendingPrompt: false, cancelRequested: false, cancellable: phase === "executing", backgroundJobs: 0, activity: "" },
|
||||
}] };
|
||||
}
|
||||
const target: InboxTarget = { tabId: "tab-a", sessionPath: "session-a", generation: 7, selection: 3, remote: true };
|
||||
const receipt = { itemId: "item-1", disposition: "queued_followup", position: 0, paused: false };
|
||||
|
||||
// The classifier and the copy table must recognize the bridge's transient mark.
|
||||
{
|
||||
assert.equal(formatInboxError(new Error(`reasonix_error:${TRANSIENT_INBOX_TARGET_CODE}`), "en").includes("switching"), true);
|
||||
}
|
||||
|
||||
// 1. A capture-phase fence is waited out: the send completes normally.
|
||||
{
|
||||
installDom();
|
||||
let captures = 0, posts = 0;
|
||||
installBridgeApp({
|
||||
CaptureInboxTarget: async () => { captures++; if (captures <= 2) throw transientError(); return target; },
|
||||
EnqueueInboxFollowupForTarget: async () => { posts++; return receipt; },
|
||||
InboxSnapshot: async () => ({ revision: 1, paused: false, recovered: false, items: [], itemsCount: 0, bytes: 0, maxItems: 64, maxBytes: 1024 }),
|
||||
});
|
||||
const SCENARIO = 1;
|
||||
await renderComposer({ inboxSessionPath: "session-a" });
|
||||
await act(async () => { acceptRuntimeState(runtimeStateStore, state(SCENARIO, "finishing", 1), true); await flush(); });
|
||||
await paste("held during switch");
|
||||
await send();
|
||||
await act(async () => { await settle(TRANSIENT_GUIDANCE_RETRY_DELAYS_MS[0] + TRANSIENT_GUIDANCE_RETRY_DELAYS_MS[1] + 80); });
|
||||
assert.equal(captures, 3, "capture retried until the tab settled");
|
||||
assert.equal(posts, 1, "the settled send enqueued exactly once");
|
||||
assert.equal(document.body.textContent?.includes(DEAD_END_COPY), false, "no dead-end copy for a self-clearing window");
|
||||
}
|
||||
|
||||
// 2. An enqueue-phase fence holds the request and auto-delivers on retry.
|
||||
{
|
||||
installDom();
|
||||
let posts = 0;
|
||||
installBridgeApp({
|
||||
CaptureInboxTarget: async () => target,
|
||||
EnqueueInboxFollowupForTarget: async () => { posts++; if (posts === 1) throw transientError(); return receipt; },
|
||||
InboxSnapshot: async () => ({ revision: 1, paused: false, recovered: false, items: [], itemsCount: 0, bytes: 0, maxItems: 64, maxBytes: 1024 }),
|
||||
});
|
||||
const SCENARIO = 2;
|
||||
await renderComposer({ inboxSessionPath: "session-a" });
|
||||
await act(async () => { acceptRuntimeState(runtimeStateStore, state(SCENARIO, "finishing", 1), true); await flush(); });
|
||||
await paste("retried until queued");
|
||||
await send();
|
||||
await act(async () => { await settle(TRANSIENT_GUIDANCE_RETRY_DELAYS_MS[0] + 80); });
|
||||
assert.equal(posts, 2, "the held request retried the enqueue");
|
||||
assert.equal(pendingFollowups.get(pendingSession), undefined, "a delivered request clears its pending identity");
|
||||
assert.equal(document.body.textContent?.includes(DEAD_END_COPY), false, "delivery never surfaced the dead-end copy");
|
||||
}
|
||||
|
||||
// 3. Outlasting the bound keeps the request visible for later reconciliation
|
||||
// instead of telling the user to refresh.
|
||||
{
|
||||
installDom();
|
||||
let posts = 0;
|
||||
installBridgeApp({
|
||||
CaptureInboxTarget: async () => target,
|
||||
EnqueueInboxFollowupForTarget: async () => { posts++; throw transientError(); },
|
||||
InboxSnapshot: async () => ({ revision: 1, paused: false, recovered: false, items: [], itemsCount: 0, bytes: 0, maxItems: 64, maxBytes: 1024 }),
|
||||
});
|
||||
const SCENARIO = 3;
|
||||
await renderComposer({ inboxSessionPath: "session-a" });
|
||||
await act(async () => { acceptRuntimeState(runtimeStateStore, state(SCENARIO, "finishing", 1), true); await flush(); });
|
||||
await paste("switching forever");
|
||||
await send();
|
||||
await act(async () => { await settle(TRANSIENT_GUIDANCE_RETRY_DELAYS_MS.reduce((sum, ms) => sum + ms, 0) + 200); });
|
||||
assert.equal(posts, 1 + TRANSIENT_GUIDANCE_RETRY_DELAYS_MS.length, "the retry bound is finite");
|
||||
assert.ok(pendingFollowups.get(pendingSession), "an undelivered request stays pending for reconciliation");
|
||||
assert.equal(document.body.textContent?.includes(DEAD_END_COPY), false, "exhaustion still avoids the dead-end copy");
|
||||
// The pending store is module-global: retire this scenario's leftover so the
|
||||
// next one starts from a clean session identity.
|
||||
pendingFollowups.clear(pendingSession, pendingFollowups.get(pendingSession));
|
||||
}
|
||||
|
||||
// 4. Permanent refusals keep the historical behavior (clear + report).
|
||||
{
|
||||
installDom();
|
||||
installBridgeApp({
|
||||
CaptureInboxTarget: async () => target,
|
||||
EnqueueInboxFollowupForTarget: async () => { throw new Error("reasonix_error:inbox_item_too_large"); },
|
||||
InboxSnapshot: async () => ({ revision: 1, paused: false, recovered: false, items: [], itemsCount: 0, bytes: 0, maxItems: 64, maxBytes: 1024 }),
|
||||
});
|
||||
const SCENARIO = 4;
|
||||
await renderComposer({ inboxSessionPath: "session-a" });
|
||||
await act(async () => { acceptRuntimeState(runtimeStateStore, state(SCENARIO, "finishing", 1), true); await flush(); });
|
||||
await paste("permanently refused");
|
||||
await send();
|
||||
await act(async () => { await settle(60); });
|
||||
assert.equal(pendingFollowups.get(pendingSession), undefined, "permanent refusals drop the pending identity");
|
||||
assert.equal(document.body.textContent?.includes("too large"), true, "permanent refusals stay visible to the user");
|
||||
}
|
||||
|
||||
console.log("composer transient guidance: retries a self-clearing fence and keeps the message visible");
|
||||
// Mounted roots leave timers behind; exit once the scenarios above ran.
|
||||
process.exit(0);
|
||||
@@ -25,7 +25,7 @@ const background = [{ id: "local-runtime" }] as unknown as BackgroundRuntimeView
|
||||
const view = projectConversation({ local, remote, tab, activeTabId: tab.id, backgroundRuntimes: background, connectingLabel: "connecting" });
|
||||
assert.equal(view.runtime, remote.transcript, "projection shares the canonical state and message arrays");
|
||||
assert.equal(view.context.items, remote.transcript.items);
|
||||
assert.equal(view.context.tabId, undefined, "remote context cannot fetch local telemetry");
|
||||
assert.equal(view.context.tabId, tab.id, "remote context reads the selected remote tab");
|
||||
assert.equal(view.composer.modelLabel, "remote-model");
|
||||
assert.equal(view.composer.cwd, "remote-cwd");
|
||||
assert.equal(view.composer.turnTokens, 7);
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
// Run: tsx src/__tests__/decision-surface-remote.test.ts
|
||||
import assert from "node:assert/strict";
|
||||
import { projectDecisionSurface } from "../app-runtime/decisionSurfaceProjection";
|
||||
|
||||
const base = { approval: undefined, ask: undefined, mcpInteraction: undefined, extensionForm: undefined, workspaceConflict: null, pendingClose: null, clearContextPending: false } as unknown as Parameters<typeof projectDecisionSurface>[0];
|
||||
const ask = { id: "1", questions: [{ id: "q1", prompt: "?", options: [{ label: "a" }, { label: "b" }] }] } as unknown as Parameters<typeof projectDecisionSurface>[0]["ask"];
|
||||
const approval = { id: "call-1", tool: "bash" } as unknown as Parameters<typeof projectDecisionSurface>[0]["approval"];
|
||||
|
||||
assert.equal(projectDecisionSurface({ ...base, ask }), "ask", "a local ask docks in the footer");
|
||||
assert.equal(projectDecisionSurface({ ...base, ask, remote: true }), null, "a remote ask belongs to the remote surface, not a second footer card");
|
||||
assert.equal(projectDecisionSurface({ ...base, approval, remote: true }), null, "a remote approval is not duplicated in the footer");
|
||||
assert.equal(projectDecisionSurface({ ...base, ask, remote: true, clearContextPending: true }), "clear_context", "shell decisions still reach the footer on a remote tab");
|
||||
console.log("decision-surface-remote: 4 passed");
|
||||
@@ -23,6 +23,7 @@ const acceptedTopics: number[] = [];
|
||||
let intent = 0;
|
||||
let registration: ReturnType<typeof deferred<string>> | undefined;
|
||||
let preparationReads = 0;
|
||||
let lastTopicPath = "";
|
||||
let api!: ReturnType<typeof useDesktopNavigation>;
|
||||
const activate = (id: string) => { calls.push(`open:${id}`); const request = deferred<TabMeta>(); pending.set(id, request); return request.promise; };
|
||||
const ports: Parameters<typeof useDesktopNavigation>[0]["ports"] = {
|
||||
@@ -30,7 +31,7 @@ const ports: Parameters<typeof useDesktopNavigation>[0]["ports"] = {
|
||||
registeredNavigationIntent: async seq => registration ? registration.promise : String(seq),
|
||||
openRemoteProject: async (_host, workspace) => activate(`remote:${workspace}`),
|
||||
switchRemoteTab: async (meta, seq) => { calls.push(`remote-switch:${meta.id}:${seq}`); },
|
||||
activateTopic: async (_scope, _workspace, id) => activate(id),
|
||||
activateTopic: async (_scope, _workspace, id, path) => { lastTopicPath = path; return activate(id); },
|
||||
openCanonicalSession: async (ref) => { await activate(`canonical:${ref.sessionId}`); },
|
||||
openTopicSession: async (_scope, _workspace, id) => { calls.push("tab-session"); return activate(id); },
|
||||
openGlobalTab: async id => { calls.push("tab-global"); return activate(id); },
|
||||
@@ -104,6 +105,14 @@ try {
|
||||
await finish("blank:im", validIM);
|
||||
assert.ok(calls.includes("channel:blank:im:channel.jsonl"));
|
||||
|
||||
calls.length = 0;
|
||||
const botConnection = { ...connection, sessionId: "session:local:bot-conversation" };
|
||||
const botNavigation = api.enqueueNavigation({ kind: "sidebar-im", connection: botConnection });
|
||||
await finish("", botNavigation);
|
||||
assert.equal(lastTopicPath, "bot-session:local:bot-conversation");
|
||||
assert.ok(!calls.some(value => value.startsWith("open:blank:") || value.startsWith("channel:")),
|
||||
"canonical bot sessions open through the shared runtime surface, not a new blank tab");
|
||||
|
||||
calls.length = 0;
|
||||
const isolated = api.enqueueNavigation({ kind: "isolated-worktree", workspaceRoot: "dirty" });
|
||||
await paint(); // Normal commits do not change the request epoch.
|
||||
|
||||
@@ -16,12 +16,14 @@ for (const locale of ["en", "zh", "zh-TW"] as const) {
|
||||
const translate: Translator = key => dict[key];
|
||||
for (const [error, key] of [
|
||||
["dial tcp: connection refused", "error.connection"],
|
||||
[{ code: "transport_protocol", message: "connection error: PROTOCOL_ERROR" }, "error.transportProtocol"],
|
||||
["lookup host: no such host", "error.dns"],
|
||||
["deepseek: status 503: invalid api key mentioned in upstream diagnostics", "error.service"],
|
||||
["HTTP 429: too many requests", "error.rateLimit"],
|
||||
["HTTP 401", "error.auth"], ["HTTP 403", "error.forbidden"],
|
||||
["HTTP 402", "error.quota"], ["HTTP 404", "error.endpoint"],
|
||||
["request timed out", "error.timeout"], ["empty provider response", "error.empty"],
|
||||
["no_deliverable_output: model finished without a visible final answer after recovery", "error.noDeliverable"],
|
||||
["model stream interrupted: unexpected EOF", "error.interrupted"],
|
||||
["open /tmp/file: permission denied", "error.permission"],
|
||||
["open /tmp/file: no such file or directory", "error.fileMissing"],
|
||||
@@ -36,6 +38,26 @@ for (const locale of ["en", "zh", "zh-TW"] as const) {
|
||||
] as const) {
|
||||
assert.equal(presentError(error, translate, locale).summary, dict[key], `${locale}: ${JSON.stringify(error)}`);
|
||||
}
|
||||
assert.deepEqual(presentError("opaque failure", translate, locale, { kind: "transport_protocol", transportCode: "PROTOCOL_ERROR" }), {
|
||||
summary: dict["error.transportProtocol"], detail: "opaque failure\nHTTP/2: PROTOCOL_ERROR",
|
||||
});
|
||||
assert.deepEqual(presentError("saved failure", translate, locale, { kind: "no_deliverable_output" }), {
|
||||
summary: dict["error.noDeliverable"], detail: "saved failure",
|
||||
});
|
||||
assert.deepEqual(presentError("Image processing failed: configured image understanding model does not accept images", translate, locale, { kind: "image_input" }), {
|
||||
summary: dict["error.imageInput"], detail: "Image processing failed: configured image understanding model does not accept images",
|
||||
});
|
||||
for (const [kind, key] of [
|
||||
["request_send_timeout", "error.requestSendTimeout"],
|
||||
["transport_eof", "error.transportEOF"],
|
||||
["transport_reset", "error.transportReset"],
|
||||
["transport_timeout", "error.timeout"],
|
||||
["transport_dns", "error.dns"],
|
||||
] as const) {
|
||||
assert.equal(presentError("saved provider failure", translate, locale, { kind }).summary, dict[key]);
|
||||
}
|
||||
assert.deepEqual(presentError("unknown provider failure", translate, locale, { kind: "future_transport_reason" }), presentError("unknown provider failure", translate, locale));
|
||||
assert.notEqual(presentError("connection error: PROTOCOL_ERROR", translate, locale).summary, dict["error.transportProtocol"], "untyped wording is not transport evidence");
|
||||
if (locale !== "en") {
|
||||
for (const raw of ["unknown backend failure", "read /tmp/timeout/404.txt: unsupported format", "read /tmp/中文文件: unsupported format", "<img src=x onerror=alert(1)>"]) {
|
||||
assert.deepEqual(presentError(raw, translate, locale), { summary: dict["error.unknown"], detail: raw });
|
||||
@@ -59,10 +81,12 @@ await Promise.all([preloadLocale("zh"), preloadLocale("zh-TW")]);
|
||||
let changeLocale: (locale: Locale) => void;
|
||||
let toast: ReturnType<typeof useToast>["showToast"];
|
||||
const raw = "unknown library failure <img src=x onerror=alert(1)>";
|
||||
const protocolError = "connection error: PROTOCOL_ERROR <img src=x onerror=alert(1)>";
|
||||
const imageError = "Image processing failed: unknown vision model fixture/vision";
|
||||
function Probe() {
|
||||
changeLocale = useI18n().setPref;
|
||||
toast = useToast().showToast;
|
||||
return <><p id="inline-error"><ErrorMessage error={raw} /></p><p id="summary-only"><ErrorMessage error={undefined} summary="仅有说明" /></p></>;
|
||||
return <><p id="inline-error"><ErrorMessage error={raw} /></p><p id="transport-error"><ErrorMessage error={protocolError} diagnostic={{ kind: "transport_protocol", transportCode: "PROTOCOL_ERROR" }} /></p><p id="image-error"><ErrorMessage error={imageError} diagnostic={{ kind: "image_input" }} /></p><p id="summary-only"><ErrorMessage error={undefined} summary="仅有说明" /></p></>;
|
||||
}
|
||||
const root = createRoot(document.getElementById("root")!);
|
||||
await act(async () => root.render(<LocaleProvider><ToastProvider><Probe /></ToastProvider></LocaleProvider>));
|
||||
@@ -76,9 +100,17 @@ assert.equal(toggle.getAttribute("aria-expanded"), "true");
|
||||
assert.equal(document.querySelector(".user-error__detail")?.textContent, raw);
|
||||
assert.equal(document.querySelector("#inline-error img"), null, "diagnostics are text, never executable markup");
|
||||
await act(async () => changeLocale("zh-TW"));
|
||||
assert.equal(document.querySelector("#transport-error .user-error__summary")?.textContent, zhTW["error.transportProtocol"]);
|
||||
assert.equal(document.querySelector("#inline-error .user-error__summary")?.textContent, zhTW["error.unknown"]);
|
||||
assert.equal(document.querySelector(".user-error__detail")?.textContent, raw, "language switching preserves diagnostics");
|
||||
await act(async () => changeLocale("en"));
|
||||
assert.equal(document.querySelector("#transport-error .user-error__summary")?.textContent, en["error.transportProtocol"]);
|
||||
await act(async () => document.querySelector<HTMLButtonElement>("#transport-error .user-error__toggle")!.click());
|
||||
assert.equal(document.querySelector("#transport-error .user-error__detail")?.textContent, `${protocolError}\nHTTP/2: PROTOCOL_ERROR`);
|
||||
assert.equal(document.querySelector("#transport-error img"), null);
|
||||
assert.equal(document.querySelector("#image-error .user-error__summary")?.textContent, en["error.imageInput"]);
|
||||
await act(async () => document.querySelector<HTMLButtonElement>("#image-error .user-error__toggle")!.click());
|
||||
assert.equal(document.querySelector("#image-error .user-error__detail")?.textContent, imageError);
|
||||
assert.equal(document.querySelector("#inline-error .user-error__summary")?.textContent, raw, "English errors follow the active English UI locale");
|
||||
await act(async () => changeLocale("zh-TW"));
|
||||
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
type Checks = {
|
||||
ok(value: boolean, label: string): void;
|
||||
eq(actual: unknown, expected: unknown, label: string): void;
|
||||
};
|
||||
|
||||
export function createFileUploadMock(target: string, { ok, eq }: Checks) {
|
||||
return (upload: (file: File) => Promise<string>) => {
|
||||
const previous = globalThis.fetch;
|
||||
globalThis.fetch = async (input, init = {}) => {
|
||||
const url = new URL(String(input), "http://localhost/");
|
||||
eq(url.pathname, "/__reasonix_attachment_upload", "ordinary file uses the raw upload endpoint");
|
||||
eq(url.searchParams.get("target"), target, "upload carries the source attachment target");
|
||||
eq(init.method, "POST", "ordinary file upload uses POST");
|
||||
ok(init.body instanceof File, "ordinary file upload sends the File directly");
|
||||
eq(new Headers(init.headers).get("content-type"), "application/octet-stream", "ordinary file upload uses raw binary content type");
|
||||
const file = init.body as File;
|
||||
eq(url.searchParams.get("name"), file.name, "upload preserves the filename");
|
||||
const path = await upload(file);
|
||||
return new Response(JSON.stringify({ path }), { status: 201, headers: { "content-type": "application/json" } });
|
||||
};
|
||||
return () => { globalThis.fetch = previous; };
|
||||
};
|
||||
}
|
||||
|
||||
export function dispatchPasteFile(textarea: HTMLTextAreaElement, file: File) {
|
||||
const event = new Event("paste", { bubbles: true, cancelable: true });
|
||||
Object.defineProperty(event, "clipboardData", {
|
||||
configurable: true,
|
||||
value: { files: [file], items: [], types: ["Files"], getData: () => "" },
|
||||
});
|
||||
textarea.dispatchEvent(event);
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import { createRoot } from "react-dom/client";
|
||||
import { JSDOM } from "jsdom";
|
||||
import { installDesktopHostStub } from "./desktopHostStub";
|
||||
import { makeSessionUIMock } from "../lib/sessionUIMock";
|
||||
import { sendPersistedComposer, useSessionComposerPersistence } from "../lib/sessionComposerPersistence";
|
||||
import { beginPersistedComposerSubmission, sendPersistedComposer, useSessionComposerPersistence } from "../lib/sessionComposerPersistence";
|
||||
|
||||
const dom = new JSDOM("<div id='root'></div>", { url: "http://localhost" });
|
||||
Object.assign(globalThis, { window: dom.window, document: dom.window.document, IS_REACT_ACT_ENVIRONMENT: true });
|
||||
@@ -18,11 +18,22 @@ try {
|
||||
await act(async () => { root.render(<Probe />); });
|
||||
const target = editor.target!;
|
||||
await act(async () => { target.onPatch!(target.draftId, target.generation, { text: "你好" }); });
|
||||
let finishInput!: () => void;
|
||||
const pending = new Promise<void>(done => { finishInput = done; });
|
||||
const input = target.trackTask!(target.draftId, target.generation, pending.then(() => {
|
||||
target.onPatch!(target.draftId, target.generation, { text: "你好 with accepted input" });
|
||||
}), true);
|
||||
await act(async () => {
|
||||
await assert.rejects(beginPersistedComposerSubmission(ref.sessionId, "premature", "{}"), /Input is still being prepared/);
|
||||
});
|
||||
assert.equal((await backend.GetSessionComposerState(ref)).submissionId, undefined);
|
||||
await act(async () => { finishInput(); await input; });
|
||||
assert.equal(editor.target!.initial.text, "你好 with accepted input");
|
||||
let sends = 0;
|
||||
await act(async () => {
|
||||
// As in Composer.submit, track the submission itself for exit persistence.
|
||||
await target.trackTask!(target.draftId, target.generation,
|
||||
sendPersistedComposer(ref.sessionId, "你好", "你好", "formal-diagnostic-send", async () => { sends++; }));
|
||||
sendPersistedComposer(ref.sessionId, "你好 with accepted input", "你好 with accepted input", "formal-diagnostic-send", async () => { sends++; }));
|
||||
});
|
||||
assert.equal(sends, 1);
|
||||
assert.equal(editor.blocked, false);
|
||||
|
||||
@@ -1,11 +1,6 @@
|
||||
// Run: tsx src/__tests__/goal-lifecycle-actions.test.tsx
|
||||
|
||||
import { JSDOM } from "jsdom";
|
||||
import React from "react";
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { GoalLifecycleActions } from "../components/GoalLifecycleActions";
|
||||
import { LocaleProvider } from "../lib/i18n";
|
||||
|
||||
const dom = new JSDOM("<!doctype html><html><body><div id=\"root\"></div></body></html>", { url: "http://localhost/" });
|
||||
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
@@ -14,10 +9,16 @@ globalThis.document = dom.window.document;
|
||||
globalThis.HTMLElement = dom.window.HTMLElement;
|
||||
globalThis.Event = dom.window.Event;
|
||||
|
||||
const React = await import("react");
|
||||
const { act } = React;
|
||||
const { createRoot } = await import("react-dom/client");
|
||||
const { GoalLifecycleActions } = await import("../components/GoalLifecycleActions");
|
||||
const { LocaleProvider } = await import("../lib/i18n");
|
||||
|
||||
let edit: { objective: string; maxGoalRounds: number | null } | undefined;
|
||||
let paused = 0;
|
||||
const prompts = ["finish the migration safely", "12"];
|
||||
window.prompt = () => prompts.shift() ?? null;
|
||||
window.prompt = () => { throw new Error("prompt() is not supported."); };
|
||||
window.alert = () => { throw new Error("alert() is not supported."); };
|
||||
|
||||
await act(async () => {
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
@@ -45,7 +46,28 @@ const pauseButton = buttons.find((button) => button.textContent === "Pause goal"
|
||||
if (!editButton || !pauseButton) throw new Error("active goal lifecycle actions did not render");
|
||||
if (pauseButton.disabled) throw new Error("running automatic Goal round is not pausable");
|
||||
|
||||
const setValue = async (el: HTMLInputElement | HTMLTextAreaElement, value: string) => {
|
||||
const proto = el instanceof dom.window.HTMLTextAreaElement ? dom.window.HTMLTextAreaElement.prototype : dom.window.HTMLInputElement.prototype;
|
||||
await act(async () => {
|
||||
Object.getOwnPropertyDescriptor(proto, "value")!.set!.call(el, value);
|
||||
el.dispatchEvent(new dom.window.Event("input", { bubbles: true }));
|
||||
});
|
||||
};
|
||||
|
||||
await act(async () => { editButton.click(); });
|
||||
const objectiveInput = document.querySelector<HTMLTextAreaElement>("[data-goal-edit-objective]");
|
||||
const roundsInput = document.querySelector<HTMLInputElement>("[data-goal-edit-rounds]");
|
||||
if (!objectiveInput || !roundsInput) throw new Error("edit goal did not open an in-app form");
|
||||
if (objectiveInput.value !== "finish the migration") throw new Error("objective not prefilled");
|
||||
await setValue(roundsInput, "0");
|
||||
const form = document.querySelector<HTMLFormElement>("form[data-goal-edit-form]")!;
|
||||
await act(async () => { form.dispatchEvent(new dom.window.Event("submit", { bubbles: true, cancelable: true })); });
|
||||
if (edit) throw new Error("invalid rounds must not submit");
|
||||
if (!document.querySelector("[data-goal-edit-error]")) throw new Error("invalid rounds error not shown");
|
||||
await setValue(objectiveInput, "finish the migration safely");
|
||||
await setValue(roundsInput, "12");
|
||||
await act(async () => { form.dispatchEvent(new dom.window.Event("submit", { bubbles: true, cancelable: true })); });
|
||||
if (document.querySelector("form[data-goal-edit-form]")) throw new Error("form should close after save");
|
||||
if (edit?.objective !== "finish the migration safely" || edit.maxGoalRounds !== 12) {
|
||||
throw new Error(`edit action returned ${JSON.stringify(edit)}`);
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ let navigationEpoch = 0;
|
||||
let cancelled = 0;
|
||||
const opened: string[] = [];
|
||||
const preparedSelectors: unknown[] = [];
|
||||
let preparedSource: SessionPreparationView | undefined;
|
||||
const source = { hostId: "local", sourceKey: "legacy", path: "/fixture/legacy.jsonl" };
|
||||
const host = installDesktopHostStub({
|
||||
CheckHistoricalSourceUpdate: async () => ({ sourceKey: "legacy", status: "available", version: "v2", source, retryable: false }),
|
||||
@@ -33,6 +34,7 @@ const host = installDesktopHostStub({
|
||||
},
|
||||
PrepareSession: async (selector: unknown) => {
|
||||
preparedSelectors.push(selector);
|
||||
if (preparedSource) return preparedSource;
|
||||
return { operationId: "prepare-source", sourceKey: "legacy", status: "ready", revision: 8,
|
||||
target: { hostId: "local", sessionId: "imported-source" }, retryable: false };
|
||||
},
|
||||
@@ -133,6 +135,29 @@ const [canonicalTab] = seedActiveTabMetaList([{ ...baseProps.tab, historicalSour
|
||||
assert.equal(canonicalTab.historicalSource, undefined, "canonical metadata omission clears the old preparation state");
|
||||
await act(async () => root.render(<LocaleProvider><HistoricalSessionBanners {...baseProps} tab={canonicalTab} /></LocaleProvider>));
|
||||
assert.equal(document.getElementById("reasonix-prepare-restored-session"), null, "successful activation removes the preparation action");
|
||||
const failingSourceTab = { ...baseProps.tab, sessionId: undefined, ready: false, historicalSource: source };
|
||||
let recoveryOpened = 0;
|
||||
const openFailures: unknown[] = [];
|
||||
preparedSource = { operationId: "prepare-conflict", sourceKey: "legacy", status: "failed", revision: 9, errorCode: "workspace_conflict",
|
||||
errorDetail: "session workspace identity is inconsistent; the session files were left unchanged", retryable: true };
|
||||
await act(async () => root.render(<LocaleProvider><HistoricalSessionBanners tab={failingSourceTab}
|
||||
navigate={async intent => { openFailures.push(intent); throw new Error("open refused: workspace registry busy"); }}
|
||||
openRecoveryDetails={() => { recoveryOpened++; }} /></LocaleProvider>));
|
||||
assert.equal(document.body.textContent?.includes("Review recovery details"), false, "recovery details appear only with a failure");
|
||||
await act(async () => document.getElementById("reasonix-prepare-restored-session")!.click());
|
||||
const failureAlert = () => document.querySelector('[role="alert"]');
|
||||
assert.ok(failureAlert()?.textContent?.includes("records a different project folder"), "the banner names the failure class the host reported");
|
||||
await act(async () => failureAlert()!.querySelector<HTMLButtonElement>(".user-error__toggle")!.click());
|
||||
assert.ok(failureAlert()?.textContent?.includes("session workspace identity is inconsistent"), "the host's failure detail is reachable from the banner");
|
||||
await act(async () => [...document.querySelectorAll<HTMLButtonElement>("button")].find(button => button.textContent === "Review recovery details")!.click());
|
||||
assert.equal(recoveryOpened, 1, "the promised recovery details open");
|
||||
assert.deepEqual(openFailures, [], "a failed preparation never navigates");
|
||||
preparedSource = { operationId: "prepare-ready", sourceKey: "legacy", status: "ready", revision: 10,
|
||||
target: { hostId: "local", sessionId: "imported-source" }, retryable: false };
|
||||
await act(async () => document.getElementById("reasonix-prepare-restored-session")!.click());
|
||||
assert.ok(failureAlert()?.textContent?.includes("Imported, but the conversation could not be opened"), "an open failure is not reported as an import failure");
|
||||
await act(async () => failureAlert()!.querySelector<HTMLButtonElement>(".user-error__toggle")!.click());
|
||||
assert.ok(failureAlert()?.textContent?.includes("open refused: workspace registry busy"), "the open failure keeps its cause");
|
||||
await act(async () => root.unmount());
|
||||
host.uninstall();
|
||||
dom.window.close();
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { JSDOM } from "jsdom";
|
||||
import { installDesktopHostStub } from "./desktopHostStub";
|
||||
import { getTranscriptStore } from "../lib/transcriptStore";
|
||||
import { loadHistoryWindow, type HistoryWindowAction } from "../lib/historyWindowController";
|
||||
import { initialState, reducer, type State } from "../lib/useController";
|
||||
|
||||
const dom = new JSDOM("", { url: "http://localhost/" });
|
||||
globalThis.window = dom.window as unknown as Window & typeof globalThis;
|
||||
|
||||
let revision = 1;
|
||||
const stub = installDesktopHostStub({
|
||||
SessionHistoryWindowForTab: async () => ({
|
||||
status: "ready",
|
||||
snapshotSequence: revision,
|
||||
generation: `generation-${revision}`,
|
||||
totalTurns: 30,
|
||||
hasOlder: true,
|
||||
olderCursor: "older-cursor",
|
||||
messages: [{ messageId: `user-${revision}`, position: 30, version: 1, role: "user", eventSequence: 30,
|
||||
visibleTurn: 30, preview: `revision ${revision}`, inline: { id: `user-${revision}`, role: "user", content: `revision ${revision}` } }],
|
||||
}),
|
||||
});
|
||||
|
||||
try {
|
||||
const store = getTranscriptStore();
|
||||
for (const protocol of [1, 2] as const) {
|
||||
revision = 1;
|
||||
const tabId = `history-reload-tab-${protocol}`;
|
||||
const sessionPath = `/fixture/history-reload-${protocol}.jsonl`;
|
||||
const initial = await store.loadLatest(tabId, sessionPath);
|
||||
assert.equal(initial?.revision, 1);
|
||||
store.evictTab(tabId);
|
||||
revision = 2;
|
||||
let state: State = {
|
||||
...initialState,
|
||||
transcriptProtocol: protocol,
|
||||
historyStartTurn: initial!.startTurn,
|
||||
historyTotalTurns: initial!.totalTurns,
|
||||
historyHasOlder: true,
|
||||
historyHasNewer: false,
|
||||
historyOlderLoading: false,
|
||||
historyNewerLoading: false,
|
||||
historyRevision: 1,
|
||||
historyDigest: "generation-1",
|
||||
running: false,
|
||||
meta: { label: "fixture", ready: true, eventChannel: "fixture", cwd: "/fixture", sessionPath,
|
||||
sessionRevision: 1, sessionDigest: "generation-1" },
|
||||
};
|
||||
const actions: HistoryWindowAction[] = [];
|
||||
const outcome = await loadHistoryWindow({
|
||||
tabId, direction: "older", trigger: "retry", state, requestSeq: 1,
|
||||
isCurrent: () => true,
|
||||
currentState: () => state,
|
||||
dispatch: action => {
|
||||
actions.push(action);
|
||||
state = reducer(state, action);
|
||||
},
|
||||
});
|
||||
assert.equal(outcome, "loaded", `protocol ${protocol}: refreshed page should replace an evicted old window: ${JSON.stringify(actions)}`);
|
||||
assert.equal(actions[actions.length - 1]?.type, protocol === 2 ? "transcript_records" : "history_replace");
|
||||
assert.equal(actions.find(action => action.type === "history_older_error"), undefined);
|
||||
assert.equal(state.historyRevision, 2);
|
||||
assert.equal(state.historyDigest, "generation-2");
|
||||
assert.equal(state.historyOlderLoading, false);
|
||||
assert.ok(state.items.some(item => item.kind === "user" && item.text === "revision 2"));
|
||||
}
|
||||
} finally {
|
||||
stub.uninstall();
|
||||
dom.window.close();
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
// Run: tsx src/__tests__/hydrate-failure-attribution.test.ts
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import test from "node:test";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import type { FollowRequest, TranscriptFollowResponse } from "../generated/desktopContract.generated";
|
||||
import { hydrateFailureDetail } from "../lib/hydrateErrorState";
|
||||
import { projectSessionAvailability } from "../lib/sessionAvailability";
|
||||
import { TranscriptFollowClient } from "../lib/transcriptFollowClient";
|
||||
import { initialState, reducer } from "../lib/useController";
|
||||
|
||||
const SUMMARY = "Failed to load conversation history.";
|
||||
|
||||
async function localFollowFailure(cause: unknown): Promise<unknown> {
|
||||
const client = new TranscriptFollowClient((request: FollowRequest) =>
|
||||
request.close ? Promise.resolve({ protocolVersion: 2, subscription: "", changes: [], resetRequired: false } as TranscriptFollowResponse)
|
||||
: Promise.reject(cause), { transport: "local" });
|
||||
try {
|
||||
await client.start({ install: () => {}, changes: () => {}, connection: () => {} });
|
||||
} catch (error) {
|
||||
return error;
|
||||
}
|
||||
assert.fail("follow start resolved although the host rejected the baseline read");
|
||||
}
|
||||
|
||||
test("a failed local follow keeps its stage, reason and host cause in the recovery detail", async () => {
|
||||
const failure = await localFollowFailure(new Error("session store: database is locked"));
|
||||
const detail = hydrateFailureDetail(SUMMARY, failure);
|
||||
assert.ok(detail.startsWith(SUMMARY), detail);
|
||||
assert.match(detail, /baseline_read\.transport_rejected/);
|
||||
assert.match(detail, /session store: database is locked/);
|
||||
|
||||
const failed = reducer({ ...initialState, hydrating: true }, { type: "hydrate_error", reason: "startup", error: detail });
|
||||
const availability = projectSessionAvailability({ local: failed });
|
||||
assert.deepEqual({ kind: availability.kind, source: availability.source }, { kind: "error", source: "history" });
|
||||
assert.equal(availability.detail, detail, "the banner's details show the cause, not only the summary");
|
||||
});
|
||||
|
||||
test("causes without a follow identity keep their message; an absent cause keeps the summary alone", () => {
|
||||
assert.equal(hydrateFailureDetail(SUMMARY, new Error("resident snapshot unreadable")), `${SUMMARY}\nresident snapshot unreadable`);
|
||||
assert.equal(hydrateFailureDetail(SUMMARY, "plain rejection"), `${SUMMARY}\nplain rejection`);
|
||||
assert.equal(hydrateFailureDetail(SUMMARY, undefined), SUMMARY);
|
||||
assert.equal(hydrateFailureDetail(SUMMARY, new Error(" ")), SUMMARY);
|
||||
});
|
||||
|
||||
test("every local history-read failure hands its cause to the recovery detail", () => {
|
||||
const root = join(dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const controller = readFileSync(join(root, "lib/useController.ts"), "utf8");
|
||||
assert.doesNotMatch(controller, /type: "hydrate_error", reason, error: t\("history\.failedLoadHistory"\) \}/,
|
||||
"a history-read failure must not replace its cause with the generic summary");
|
||||
assert.doesNotMatch(controller, /const error = t\("history\.failedLoadHistory"\);/,
|
||||
"the follow failure must not be replaced by the generic summary");
|
||||
assert.equal(controller.match(/hydrateFailureDetail\(t\("history\.failedLoadHistory"\), /g)?.length, 2,
|
||||
"both the follow path and the readable-baseline path attribute their failure");
|
||||
});
|
||||
@@ -51,7 +51,7 @@ type ControllerState = Parameters<typeof reducer>[0];
|
||||
const appSource = readFileSync(new URL("../AppRuntime.tsx", import.meta.url), "utf8");
|
||||
const sessionCompositionSource = readFileSync(new URL("../app-runtime/useAppSessionComposition.ts", import.meta.url), "utf8");
|
||||
ok(
|
||||
/\[clearContextPending, pendingClose, state\.approval, state\.ask, state\.extensionForm, state\.mcpInteraction, workspaceConflict\]/.test(sessionCompositionSource),
|
||||
/\[clearContextPending, pendingClose, remoteSurfaceActive, state\.approval, state\.ask, state\.extensionForm, state\.mcpInteraction, workspaceConflict\]/.test(sessionCompositionSource),
|
||||
"App decision surface recomputes when an MCP interaction arrives",
|
||||
);
|
||||
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { JSDOM } from "jsdom";
|
||||
import React, { act } from "react";
|
||||
import type { ModelSettingsChange, ProviderView } from "../lib/types";
|
||||
import { selectSettingsValue, settingsOptionValues } from "./settingsSelectTestUtils";
|
||||
|
||||
const dom = new JSDOM("<html><body><div id='root'></div></body></html>", { pretendToBeVisual: true, url: "http://localhost/" });
|
||||
Object.assign(globalThis, {
|
||||
IS_REACT_ACT_ENVIRONMENT: true, window: dom.window, document: dom.window.document,
|
||||
Node: dom.window.Node, HTMLElement: dom.window.HTMLElement, Event: dom.window.Event,
|
||||
CustomEvent: dom.window.CustomEvent, KeyboardEvent: dom.window.KeyboardEvent, MouseEvent: dom.window.MouseEvent,
|
||||
localStorage: dom.window.localStorage, sessionStorage: dom.window.sessionStorage,
|
||||
requestAnimationFrame: dom.window.requestAnimationFrame.bind(dom.window),
|
||||
cancelAnimationFrame: dom.window.cancelAnimationFrame.bind(dom.window),
|
||||
});
|
||||
Object.defineProperty(globalThis, "navigator", { configurable: true, value: dom.window.navigator });
|
||||
window.scrollTo = () => {};
|
||||
window.matchMedia = (() => ({ matches: true, addEventListener() {}, removeEventListener() {} })) as unknown as typeof window.matchMedia;
|
||||
Object.defineProperty(dom.window.HTMLElement.prototype, "getBoundingClientRect", { configurable: true, value: () => ({x:10,y:10,top:10,left:10,right:310,bottom:54,width:300,height:44,toJSON(){return {};}}) });
|
||||
const { createRoot } = await import("react-dom/client");
|
||||
const { LocaleProvider } = await import("../lib/i18n");
|
||||
const { ModelsSection, ProviderEditor } = await import("../components/SettingsPanel");
|
||||
const { baseSettings, installCanvasMock } = await import("../test-support/settingsTestFixtures");
|
||||
const { installDesktopHostStub } = await import("./desktopHostStub");
|
||||
installCanvasMock(dom.window as unknown as Window);
|
||||
const changes: Array<{ field?: string; ref?: string }> = [];
|
||||
installDesktopHostStub({ ApplyModelSettings: async (change: ModelSettingsChange) => {
|
||||
changes.push(change);
|
||||
return {requestId:change.requestId, persisted:true, revision:"saved", application:"not_required", targets:[], issues:[], appliedCatalogs:[]};
|
||||
} });
|
||||
|
||||
const provider: ProviderView = {
|
||||
name: "gateway", kind: "openai", baseUrl: "https://gateway.invalid/v1",
|
||||
models: ["reasoner", "text-model"], default: "text-model", added: true, builtIn: false,
|
||||
modelsUrl: "", apiKeyEnv: "", keySet: false, requiresKey: false, configured: true,
|
||||
visionModels: [], balanceUrl: "", contextWindow: 0, reasoningProtocol: "", thinking: "",
|
||||
supportedEfforts: [], defaultEffort: "low",
|
||||
modelOverrides: [{model:"text-model", reasoningProtocol:"none", supportedEfforts:[], defaultEffort:""}],
|
||||
modelCapabilities: [
|
||||
{model:"reasoner", inputModalities:[], state:"unknown", source:"unknown",
|
||||
reasoning:{state:"supported", options:[{id:"low",name:"low"},{id:"high",name:"high"}]}},
|
||||
{model:"text-model", inputModalities:[], state:"unsupported", source:"override",
|
||||
reasoning:{state:"unsupported", options:[]}},
|
||||
],
|
||||
};
|
||||
const host = document.getElementById("root")!;
|
||||
const root = createRoot(host);
|
||||
const settings = baseSettings();
|
||||
settings.providers = [provider];
|
||||
settings.defaultModel = "gateway/reasoner";
|
||||
settings.plannerModel = settings.defaultModel;
|
||||
settings.subagentModel = settings.defaultModel;
|
||||
const render = async () => act(async () => {
|
||||
root.render(<LocaleProvider><ModelsSection s={{...settings}} busy={false} subtab="usage"
|
||||
apply={async fn => { await fn(); return true; }} backgroundApply={async () => true} /></LocaleProvider>);
|
||||
});
|
||||
const effortControl = () => host.querySelector<HTMLButtonElement>('button[aria-label="Subagent reasoning effort"]')!;
|
||||
await render();
|
||||
const planner = host.querySelector<HTMLButtonElement>('button[aria-label="Dedicated planner model"]')!;
|
||||
assert.ok(planner, "planner control exists");
|
||||
assert.equal(planner.textContent, "reasoner", "explicit planner remains visible even when equal to default");
|
||||
await selectSettingsValue(planner, "");
|
||||
assert.equal(changes.at(-1)?.field, "planner");
|
||||
assert.equal(changes.at(-1)?.ref, "", "follow clears the explicit planner");
|
||||
assert.deepEqual(await settingsOptionValues(effortControl()), ["", "low", "high"], "resolved options work without legacy levels");
|
||||
await selectSettingsValue(effortControl(), "high");
|
||||
assert.equal(changes.at(-1)?.field, "subagent_effort");
|
||||
assert.equal(changes.at(-1)?.ref, "high");
|
||||
settings.subagentModel = "";
|
||||
await render();
|
||||
assert.deepEqual(await settingsOptionValues(effortControl()), ["", "low", "high"], "follow-parent selection uses the backend's default-model validation target");
|
||||
settings.subagentModel = "gateway/text-model";
|
||||
provider.supportedEfforts = ["low", "high"];
|
||||
await render();
|
||||
assert.deepEqual(await settingsOptionValues(effortControl()), [""], "unsupported model does not inherit stale connection levels");
|
||||
settings.subagentEffort = "high";
|
||||
await render();
|
||||
await act(async () => effortControl().click());
|
||||
assert.equal(document.querySelector('[role="option"][data-value="high"]')?.getAttribute("aria-disabled"), "true", "stale selection is visible but unavailable");
|
||||
await act(async () => effortControl().click());
|
||||
await selectSettingsValue(effortControl(), "");
|
||||
assert.equal(changes.at(-1)?.ref, "", "stale effort can be cleared");
|
||||
settings.subagentEffort = "";
|
||||
settings.subagentModel = "gateway/reasoner";
|
||||
provider.modelCapabilities![0].reasoning = {state:"unknown", options:[]};
|
||||
await render();
|
||||
assert.deepEqual(await settingsOptionValues(effortControl()), [""], "unknown resolved capability does not fall back to stale levels");
|
||||
settings.subagentModel = "gateway/text-model";
|
||||
provider.modelCapabilities = [];
|
||||
await render();
|
||||
assert.deepEqual(await settingsOptionValues(effortControl()), [""], "legacy per-model reasoning=none still disables levels");
|
||||
settings.subagentModel = "gateway/reasoner";
|
||||
await render();
|
||||
assert.deepEqual(await settingsOptionValues(effortControl()), ["", "low", "high"], "older hosts retain declared levels");
|
||||
|
||||
async function saveRename(initial: ProviderView, disableReasoning = false): Promise<ProviderView> {
|
||||
let saved: ProviderView | undefined;
|
||||
await act(async () => root.render(<LocaleProvider><ProviderEditor key={initial.name} initial={initial} kinds={["openai"]} busy={false}
|
||||
onCancel={() => {}} onSave={value => { saved = value; }} /></LocaleProvider>));
|
||||
const name = host.querySelector<HTMLInputElement>(".provider-name-input")!;
|
||||
await act(async () => {
|
||||
Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, "value")!.set!.call(name, "Renamed only");
|
||||
name.dispatchEvent(new window.Event("input", { bubbles: true }));
|
||||
});
|
||||
if (disableReasoning) {
|
||||
await selectSettingsValue(host.querySelector<HTMLButtonElement>('button[aria-label="Model capability mode"]')!, "none");
|
||||
}
|
||||
const save = Array.from(host.querySelectorAll<HTMLButtonElement>("button")).find(el => el.textContent?.trim() === "Save changes")!;
|
||||
assert.ok(save && !save.disabled);
|
||||
await act(async () => save.click());
|
||||
assert.ok(saved);
|
||||
return saved;
|
||||
}
|
||||
const saved = await saveRename({...provider, name:"preserve", supportedEfforts:[]});
|
||||
assert.equal(saved.default, "text-model", "rename preserves non-first default model");
|
||||
assert.equal(saved.defaultEffort, "low", "rename preserves default with inferred levels");
|
||||
assert.deepEqual(saved.supportedEfforts, [], "automatic options are not frozen into config");
|
||||
const removed = await saveRename({...provider, name:"removed", models:["reasoner"]});
|
||||
assert.equal(removed.default, "reasoner", "removed default falls back to an enabled model");
|
||||
const invalid = await saveRename({...provider, name:"diagnostic", defaultEffort:"future-level"});
|
||||
assert.equal(invalid.defaultEffort, "future-level", "unrelated edit preserves an invalid declaration for backend diagnostics");
|
||||
const disabled = await saveRename({...provider, name:"explicit-off"}, true);
|
||||
assert.equal(disabled.defaultEffort, "", "explicitly disabling reasoning clears its default");
|
||||
assert.deepEqual(disabled.supportedEfforts, []);
|
||||
await act(async () => root.unmount());
|
||||
dom.window.close();
|
||||
console.log("model preferences capability and preservation regressions passed");
|
||||
@@ -0,0 +1,44 @@
|
||||
import assert from "node:assert/strict";
|
||||
import React, { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { JSDOM } from "jsdom";
|
||||
import { projectSessionRowKey } from "../lib/projectSessionIdentity";
|
||||
import type { ProjectNode } from "../lib/types";
|
||||
|
||||
const dom = new JSDOM("<div id='root'></div>");
|
||||
Object.assign(globalThis, { window: dom.window, document: dom.window.document, IS_REACT_ACT_ENVIRONMENT: true });
|
||||
const source: ProjectNode = { key: "source", kind: "global_topic", label: "History",
|
||||
source: { hostId: "local", sourceKey: "row-identity-source", path: "/history.jsonl" } };
|
||||
const sourceKey = projectSessionRowKey(source);
|
||||
const original: ProjectNode = { key: "original", kind: "global_topic", label: "History",
|
||||
session: { hostId: "local", sessionId: "row-original" }, identityAliases: [sourceKey] };
|
||||
const reviewed: ProjectNode = { ...original, key: "reviewed",
|
||||
session: { hostId: "local", sessionId: "row-reviewed" } };
|
||||
assert.equal(projectSessionRowKey(original), sourceKey, "adoption preserves the mounted source row");
|
||||
assert.notEqual(projectSessionRowKey({ ...source,
|
||||
source: { ...source.source!, sourceKey: "unowned-source" }, identityAliases: [sourceKey] }), sourceKey,
|
||||
"an unowned source cannot borrow an already canonical key through a secondary alias");
|
||||
const root = createRoot(document.getElementById("root")!);
|
||||
const warnings: unknown[][] = [];
|
||||
const oldError = console.error;
|
||||
console.error = (...args) => { warnings.push(args); };
|
||||
try {
|
||||
// Old peers/previously loaded pages may still publish a conflicting alias.
|
||||
// Reorder siblings and insert/remove a third row as catalog pushes do.
|
||||
const other: ProjectNode = { key: "other", kind: "global_topic", label: "Other",
|
||||
session: { hostId: "local", sessionId: "row-other" } };
|
||||
for (let cycle = 0; cycle < 20; cycle++) {
|
||||
const rows = cycle % 2 ? [source, other, reviewed, original] : [original, reviewed];
|
||||
await act(async () => root.render(<ul>{rows.map(row =>
|
||||
<li key={projectSessionRowKey(row)} data-session={row.session?.sessionId ?? "source"}>{row.label}</li>)}</ul>));
|
||||
assert.deepEqual(Array.from(document.querySelectorAll("li"), row => row.getAttribute("data-session")),
|
||||
rows.map(row => row.session?.sessionId ?? "source"), "catalog refresh must not multiply mounted rows");
|
||||
}
|
||||
assert.notEqual(projectSessionRowKey(original), projectSessionRowKey(reviewed), "canonical sessions never share a React key");
|
||||
assert.equal(warnings.length, 0, "no duplicate-key or reconciliation warnings");
|
||||
} finally {
|
||||
console.error = oldError;
|
||||
await act(async () => root.unmount());
|
||||
dom.window.close();
|
||||
}
|
||||
console.log("PASS source adoption preserves identity without merging independent canonical rows");
|
||||
@@ -0,0 +1,131 @@
|
||||
// Run: tsx src/__tests__/project-tree-organization-group-drop.test.tsx
|
||||
|
||||
import { JSDOM } from "jsdom";
|
||||
import React from "react";
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { useProjectTreeOrganization } from "../components/ProjectTreeOrganization";
|
||||
import type { ProjectNode, ProjectTreeOrganizationBindings, SessionGroup } from "../lib/types";
|
||||
import type { SessionOrganizationMutation, SessionOrganizationSnapshot } from "../generated/desktopContract.generated";
|
||||
import { ToastProvider } from "../lib/toast";
|
||||
import { LocaleProvider } from "../lib/i18n";
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
function ok(value: boolean, label: string) {
|
||||
process.stdout.write(` ${value ? "PASS" : "FAIL"} ${label}\n`);
|
||||
if (value) passed += 1; else failed += 1;
|
||||
}
|
||||
|
||||
function installDom() {
|
||||
const dom = new JSDOM("<!doctype html><html><body><div id=\"root\"></div></body></html>", {
|
||||
pretendToBeVisual: true,
|
||||
url: "http://localhost/",
|
||||
});
|
||||
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
globalThis.window = dom.window as unknown as Window & typeof globalThis;
|
||||
globalThis.document = dom.window.document;
|
||||
globalThis.Node = dom.window.Node;
|
||||
globalThis.Element = dom.window.Element;
|
||||
globalThis.HTMLElement = dom.window.HTMLElement;
|
||||
globalThis.Event = dom.window.Event;
|
||||
globalThis.MouseEvent = dom.window.MouseEvent;
|
||||
return dom;
|
||||
}
|
||||
|
||||
function topic(id: string): ProjectNode {
|
||||
return { key: `topic-${id}`, kind: "topic", label: id, root: "/repo", topicId: id, session: { hostId: "local", sessionId: id } } as ProjectNode;
|
||||
}
|
||||
|
||||
const inside = topic("inside");
|
||||
const sibling = topic("sibling");
|
||||
const outside = topic("outside");
|
||||
const loose = topic("loose");
|
||||
const folder: ProjectNode = { key: "project-/repo", kind: "project", label: "Repo", root: "/repo", children: [inside, sibling, outside, loose] };
|
||||
const groups: SessionGroup[] = [{ id: "g1", title: "Group", sessionKeys: ["ref\0local\0inside", "ref\0local\0sibling"] }];
|
||||
|
||||
function Harness({ bindings }: { bindings: ProjectTreeOrganizationBindings }) {
|
||||
const organization = useProjectTreeOrganization({ tree: [folder], refresh: async () => {}, sortMode: "created", bindings });
|
||||
const loaded = organization.groupsFor(folder).length > 0;
|
||||
return <>
|
||||
<output id="loaded">{loaded ? "yes" : "no"}</output>
|
||||
{folder.children!.map((node) => {
|
||||
const row = organization.topicRow(node, false);
|
||||
return <div key={node.key} id={`row-${node.topicId}`} {...row.props}>{node.label}</div>;
|
||||
})}
|
||||
</>;
|
||||
}
|
||||
|
||||
async function flush() {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
|
||||
function fireDrag(type: string, target: Element, data: Map<string, string>) {
|
||||
const event = new window.Event(type, { bubbles: true, cancelable: true });
|
||||
const dataTransfer = {
|
||||
setData: (format: string, value: string) => { data.set(format, value); },
|
||||
getData: (format: string) => data.get(format) ?? "",
|
||||
effectAllowed: "all",
|
||||
dropEffect: "none",
|
||||
};
|
||||
Object.defineProperty(event, "dataTransfer", { value: dataTransfer });
|
||||
Object.defineProperty(event, "clientY", { value: 10 });
|
||||
target.dispatchEvent(event);
|
||||
}
|
||||
|
||||
async function dropOnto(draggedID: string, targetID: string): Promise<SessionOrganizationMutation[]> {
|
||||
const dom = installDom();
|
||||
const mutations: SessionOrganizationMutation[] = [];
|
||||
const snapshot = (): SessionOrganizationSnapshot => ({ groups: structuredClone(groups), revision: 1, applied: true, order: [], manualOrderEnabled: false });
|
||||
const bindings: ProjectTreeOrganizationBindings = {
|
||||
ReorderTopics: async () => {},
|
||||
ListProjectGroups: async () => [],
|
||||
SaveSessionGroups: async () => {},
|
||||
GetSessionOrganization: async () => snapshot(),
|
||||
UpdateSessionOrganization: async (_workspace, _revision, mutation) => { mutations.push(mutation); return snapshot(); },
|
||||
};
|
||||
const root = createRoot(document.getElementById("root")!);
|
||||
await act(async () => {
|
||||
root.render(<LocaleProvider><ToastProvider><Harness bindings={bindings} /></ToastProvider></LocaleProvider>);
|
||||
await flush();
|
||||
});
|
||||
for (let attempt = 0; attempt < 30 && document.getElementById("loaded")?.textContent !== "yes"; attempt += 1) await act(flush);
|
||||
const data = new Map<string, string>();
|
||||
await act(async () => { fireDrag("dragstart", document.getElementById(`row-${draggedID}`)!, data); await flush(); });
|
||||
await act(async () => { fireDrag("dragover", document.getElementById(`row-${targetID}`)!, data); await flush(); });
|
||||
await act(async () => { fireDrag("drop", document.getElementById(`row-${targetID}`)!, data); await flush(); });
|
||||
for (let attempt = 0; attempt < 10; attempt += 1) await act(flush);
|
||||
await act(async () => root.unmount());
|
||||
dom.window.close();
|
||||
return mutations;
|
||||
}
|
||||
|
||||
console.log("\nproject tree drop onto a group member row");
|
||||
|
||||
{
|
||||
const mutations = await dropOnto("outside", "inside");
|
||||
ok(mutations.length === 1 && mutations[0].kind === "set-group" && mutations[0].groupId === "g1",
|
||||
`dropping a non-member on a member row adds it to that group (got ${JSON.stringify(mutations.map((m) => [m.kind, m.groupId ?? ""]))})`);
|
||||
}
|
||||
|
||||
{
|
||||
const mutations = await dropOnto("sibling", "inside");
|
||||
ok(mutations.length === 1 && mutations[0].kind === "move",
|
||||
`dropping a member on another member of its group reorders (got ${JSON.stringify(mutations.map((m) => m.kind))})`);
|
||||
}
|
||||
|
||||
{
|
||||
const mutations = await dropOnto("outside", "loose");
|
||||
ok(mutations.length === 1 && mutations[0].kind === "move",
|
||||
`dropping on an ungrouped row reorders (got ${JSON.stringify(mutations.map((m) => m.kind))})`);
|
||||
}
|
||||
|
||||
{
|
||||
const mutations = await dropOnto("outside", "loose");
|
||||
ok(mutations.length === 1 && mutations[0].sortMode === "created",
|
||||
`a move carries the sort order on screen, so a first manual move starts from it (got ${JSON.stringify(mutations.map((m) => m.sortMode ?? ""))})`);
|
||||
}
|
||||
|
||||
process.stdout.write(`\nproject-tree-organization-group-drop: ${passed} passed, ${failed} failed\n`);
|
||||
if (failed > 0) process.exit(1);
|
||||
Loaded 100 of 1176 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user