mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 10:45:59 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1986da2661 | ||
|
|
690cfe2128 | ||
|
|
26d1c423fd | ||
|
|
f14b6521e8 | ||
|
|
58c8ff60e8 | ||
|
|
ba2c4d31b7 | ||
|
|
44d0b7d82d | ||
|
|
b8c82972f2 | ||
|
|
ac814a5040 | ||
|
|
6c58ef5ef9 | ||
|
|
9eb301102e | ||
|
|
770d414e6a | ||
|
|
36a2dee813 | ||
|
|
c53448e02f | ||
|
|
170b23fbde | ||
|
|
32721fc0e1 | ||
|
|
241d4f5250 | ||
|
|
111497a9b5 | ||
|
|
4ef8971afd | ||
|
|
26b256b5d5 | ||
|
|
87dcb73114 | ||
|
|
fe2ad317fa | ||
|
|
0efda3bb9f | ||
|
|
171343542e | ||
|
|
2fefeef06f | ||
|
|
b907a6b097 | ||
|
|
dc77f2a8c8 | ||
|
|
e57297959b | ||
|
|
e406e719f6 | ||
|
|
76f5414e6b | ||
|
|
84c6484edb | ||
|
|
9968f1e1e2 | ||
|
|
acc4c54009 | ||
|
|
f62790387a | ||
|
|
c846ad5559 | ||
|
|
908ce880e5 | ||
|
|
75c18e41ec | ||
|
|
dcca564ec8 | ||
|
|
cb8dcdc1f7 | ||
|
|
acdef9998a | ||
|
|
6e81c1c79f | ||
|
|
e3c86bacf5 | ||
|
|
aa8c97f75e | ||
|
|
87999b9747 | ||
|
|
a3e0f10be4 | ||
|
|
dadce78d0a | ||
|
|
72a1e32b49 | ||
|
|
4b2ef3dabb | ||
|
|
a6e1459621 | ||
|
|
2c1df5b4f9 | ||
|
|
7663495c91 | ||
|
|
deb679bfcf | ||
|
|
1ae7e48ba6 | ||
|
|
16d71aa475 | ||
|
|
0f42b4b650 | ||
|
|
8eb9707a3f | ||
|
|
217f768ce1 | ||
|
|
035f92897f | ||
|
|
925cb07bae | ||
|
|
04c0d88f4d | ||
|
|
c5940f6319 | ||
|
|
d385432a08 | ||
|
|
4499687209 | ||
|
|
020bf6ebf7 | ||
|
|
3bedc054a3 |
No files matched your search
@@ -1,3 +1,15 @@
|
||||
# Every pull request requests review from the maintainers below.
|
||||
# Either maintainer can review and merge any change.
|
||||
* @SivanCola @esengine
|
||||
|
||||
# Keep the provider-side signing policy and its GitHub control plane under the
|
||||
# same explicit review boundary as the machine-readable contract.
|
||||
/.signpath/ @SivanCola @esengine
|
||||
/.github/workflows/release-stable.yml @SivanCola @esengine
|
||||
/.github/workflows/release-stable-trigger.yml @SivanCola @esengine
|
||||
/.github/workflows/release-desktop.yml @SivanCola @esengine
|
||||
/.github/workflows/release-desktop-trigger.yml @SivanCola @esengine
|
||||
/scripts/complete-signpath-request.ps1 @SivanCola @esengine
|
||||
/scripts/package-windows-desktop.sh @SivanCola @esengine
|
||||
/scripts/verify-windows-authenticode.ps1 @SivanCola @esengine
|
||||
/cmd/signpath-contract/ @SivanCola @esengine
|
||||
@@ -4,6 +4,18 @@ on:
|
||||
pull_request_target:
|
||||
branches: [main-v2]
|
||||
types: [opened, synchronize, reopened, edited, ready_for_review]
|
||||
# The guard only cares about the prompt/tool surfaces listed in
|
||||
# scripts/check-cache-impact.sh; PRs confined to clearly unrelated paths
|
||||
# can never trip it, so skip the run entirely. Keep the ignore list
|
||||
# conservative: when in doubt, run.
|
||||
paths-ignore:
|
||||
- 'docs/**'
|
||||
- 'site/**'
|
||||
- 'release-notes/**'
|
||||
- 'workers/**'
|
||||
- 'benchmarks/**'
|
||||
- 'npm/**'
|
||||
- '*.md'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
+149
-9
@@ -14,22 +14,78 @@ concurrency:
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
# Cheap path gate for pull requests. PRs confined to docs/site/release-notes
|
||||
# (or top-level Markdown) skip the heavy Go jobs, and PRs that cannot affect
|
||||
# the desktop module skip the desktop jobs. Job-level `if` reports skipped,
|
||||
# which satisfies the required status checks (lint, race, test) — a
|
||||
# workflow-level paths-ignore would leave required checks pending and block
|
||||
# merges. Gated jobs skip only on an explicit `false` output: wrapped in
|
||||
# `always()`, a failed `changes` job (or a missing output) makes them run
|
||||
# the full matrix instead of silently passing required checks as skipped.
|
||||
# Pushes to main-v2 always run everything.
|
||||
changes:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
code: ${{ steps.filter.outputs.code }}
|
||||
desktop: ${{ steps.filter.outputs.desktop }}
|
||||
site: ${{ steps.filter.outputs.site }}
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- id: filter
|
||||
run: |
|
||||
code=true; desktop=true; site=true
|
||||
base=""
|
||||
if [ "${{ github.event_name }}" = "pull_request" ]; then
|
||||
base="${{ github.event.pull_request.base.sha }}"
|
||||
elif [ "${{ github.event.before }}" != "0000000000000000000000000000000000000000" ]; then
|
||||
base="${{ github.event.before }}"
|
||||
fi
|
||||
if [ -n "$base" ] && git cat-file -e "$base^{commit}" 2>/dev/null; then
|
||||
files=$(git diff --name-only "$base" HEAD)
|
||||
if [ -n "$files" ]; then
|
||||
code=false; desktop=false; site=false
|
||||
# Root-module CI: desktop/ is a separate module, so only the
|
||||
# clearly unrelated paths below can skip it.
|
||||
if echo "$files" | grep -qvE '^(docs/|site/|release-notes/|desktop/|workers/|[^/]+\.md$)'; then code=true; fi
|
||||
# desktop/ imports the root kernel via `replace reasonix => ../`,
|
||||
# so only this clearly-unrelated set is safe to skip.
|
||||
if echo "$files" | grep -qvE '^(docs/|site/|release-notes/|workers/|benchmarks/|npm/|[^/]+\.md$)'; then desktop=true; fi
|
||||
if echo "$files" | grep -q '^site/'; then site=true; fi
|
||||
fi
|
||||
fi
|
||||
{ echo "code=$code"; echo "desktop=$desktop"; echo "site=$site"; } >> "$GITHUB_OUTPUT"
|
||||
|
||||
# The ruleset requires the per-OS check names (test (ubuntu-latest) etc.).
|
||||
# A matrix job skipped at job level reports no per-leg checks at all, so
|
||||
# those required checks would stay "Expected" and block merging. The job
|
||||
# therefore always runs and the steps do the gating: when the changes
|
||||
# detector reports the diff is unrelated, every step skips and each leg
|
||||
# reports success in seconds. `always()` also keeps the legs alive when
|
||||
# the changes job itself fails (fail-open: an empty output != 'false').
|
||||
test:
|
||||
needs: changes
|
||||
if: always()
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [ubuntu-latest, macos-latest, windows-latest]
|
||||
runs-on: ${{ matrix.os }}
|
||||
env:
|
||||
RUN_STEPS: ${{ github.event_name != 'pull_request' || needs.changes.outputs.code != 'false' }}
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- if: env.RUN_STEPS == 'true'
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- uses: actions/setup-go@v6
|
||||
- if: env.RUN_STEPS == 'true'
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
cache: true
|
||||
|
||||
- name: Install and verify Linux sandbox backend
|
||||
if: runner.os == 'Linux'
|
||||
if: env.RUN_STEPS == 'true' && runner.os == 'Linux'
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y bubblewrap
|
||||
@@ -45,7 +101,7 @@ jobs:
|
||||
# Skipped on Windows: the runner checks out CRLF, so gofmt -l flags every
|
||||
# file. gofmt output is OS-independent, so the Unix legs already cover it.
|
||||
- name: gofmt
|
||||
if: runner.os != 'Windows'
|
||||
if: env.RUN_STEPS == 'true' && runner.os != 'Windows'
|
||||
run: |
|
||||
# Root module only — desktop/ is a separate module with its own tooling.
|
||||
unformatted=$(gofmt -l . | grep -v '^desktop/' || true)
|
||||
@@ -56,13 +112,15 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: vet
|
||||
if: env.RUN_STEPS == 'true'
|
||||
run: go vet ./...
|
||||
|
||||
- name: build
|
||||
if: env.RUN_STEPS == 'true'
|
||||
run: go build ./...
|
||||
|
||||
- name: test
|
||||
if: runner.os != 'Windows'
|
||||
if: env.RUN_STEPS == 'true' && runner.os != 'Windows'
|
||||
env:
|
||||
# Run the prompt-cache prefix-stability guard (TestCacheHit*) in CI: a
|
||||
# regression there silently tanks the cache hit rate the project is
|
||||
@@ -70,8 +128,19 @@ jobs:
|
||||
REASONIX_RELEASE_CACHE_GUARD: "1"
|
||||
run: go test ./...
|
||||
|
||||
- name: test
|
||||
if: runner.os == 'Windows'
|
||||
# Pull requests run a Windows smoke suite: the packages that actually
|
||||
# carry *_windows.go code, plus cmd/. The full ./... sweep stays on
|
||||
# pushes to main-v2; the Unix legs always run the full suite.
|
||||
- name: test (Windows smoke)
|
||||
if: env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name == 'pull_request'
|
||||
timeout-minutes: 10
|
||||
env:
|
||||
REASONIX_RELEASE_CACHE_GUARD: "1"
|
||||
WINDOWS_SANDBOX_WAIT_MS: "20000"
|
||||
run: go test -p 4 -timeout=3m ./internal/agent/... ./internal/cli/... ./internal/control/... ./internal/filelock/... ./internal/fileutil/... ./internal/hook/... ./internal/mcplaunch/... ./internal/notify/... ./internal/proc/... ./internal/remote/... ./internal/repair/... ./internal/sandbox/... ./internal/sysproxy/... ./internal/workspacelease/... ./cmd/...
|
||||
|
||||
- name: test (full)
|
||||
if: env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name != 'pull_request'
|
||||
timeout-minutes: 10
|
||||
env:
|
||||
# Run the prompt-cache prefix-stability guard (TestCacheHit*) in CI: a
|
||||
@@ -84,6 +153,8 @@ jobs:
|
||||
run: go test -p 4 -timeout=3m ./...
|
||||
|
||||
race:
|
||||
needs: changes
|
||||
if: always() && (github.event_name != 'pull_request' || needs.changes.outputs.code != 'false')
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
@@ -108,13 +179,24 @@ jobs:
|
||||
|
||||
# The matrix never runs -race (it needs cgo); the project's concurrency
|
||||
# (plugin fan-out, background phase B, jobs Kill/Wait) would otherwise
|
||||
# ship without race coverage.
|
||||
- name: test -race
|
||||
# ship without race coverage. Pull requests sweep only the
|
||||
# concurrency-heavy packages so this required check stays fast; pushes
|
||||
# to main-v2 keep the full ./... sweep as the safety net.
|
||||
- name: test -race (concurrency packages)
|
||||
if: github.event_name == 'pull_request'
|
||||
env:
|
||||
REASONIX_RELEASE_CACHE_GUARD: "1"
|
||||
run: go test -race ./internal/agent/... ./internal/plugin/... ./internal/jobs/... ./internal/proc/... ./internal/sandbox/... ./internal/filelock/... ./internal/eventwire/... ./internal/remote/...
|
||||
|
||||
- name: test -race (full)
|
||||
if: github.event_name != 'pull_request'
|
||||
env:
|
||||
REASONIX_RELEASE_CACHE_GUARD: "1"
|
||||
run: go test -race ./...
|
||||
|
||||
desktop:
|
||||
needs: changes
|
||||
if: always() && (github.event_name != 'pull_request' || needs.changes.outputs.desktop != 'false')
|
||||
runs-on: ubuntu-22.04
|
||||
defaults:
|
||||
run:
|
||||
@@ -174,6 +256,9 @@ jobs:
|
||||
pnpm --dir frontend install --frozen-lockfile
|
||||
pnpm --dir frontend build
|
||||
|
||||
- name: Test integrated terminal frontend
|
||||
run: pnpm --dir frontend test:terminal
|
||||
|
||||
- name: vet
|
||||
run: go vet ./...
|
||||
|
||||
@@ -190,11 +275,34 @@ jobs:
|
||||
- name: test
|
||||
run: go test ./...
|
||||
|
||||
# desktop/ is a separate module, so the root macOS matrix above does not
|
||||
# compile or exercise the native PTY implementation.
|
||||
desktop-macos:
|
||||
needs: changes
|
||||
if: always() && (github.event_name != 'pull_request' || needs.changes.outputs.desktop != 'false')
|
||||
runs-on: macos-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: desktop
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version-file: desktop/go.mod
|
||||
cache: true
|
||||
cache-dependency-path: desktop/go.sum
|
||||
|
||||
- name: Test integrated terminal and PTY lifecycle
|
||||
run: go test -race -run 'Test(ResolveTerminal|Terminal|EmptyTerminal|UnixTerminalProcess)' .
|
||||
|
||||
# Desktop project-root matching is case-insensitive only on Windows
|
||||
# (sameDesktopPath folds case when os.PathSeparator is '\'), so the
|
||||
# regression tests for that contract are named *OnWindows and can never
|
||||
# run on the ubuntu leg above.
|
||||
desktop-windows:
|
||||
needs: changes
|
||||
if: always() && (github.event_name != 'pull_request' || needs.changes.outputs.desktop != 'false')
|
||||
runs-on: windows-latest
|
||||
defaults:
|
||||
run:
|
||||
@@ -237,14 +345,21 @@ jobs:
|
||||
timeout-minutes: 15
|
||||
run: go test ./...
|
||||
|
||||
# Installer packaging is packaging validation, not a code gate: run it
|
||||
# on pushes to main-v2 (the release pipeline builds installers again
|
||||
# anyway), but let pull requests stop after the test step.
|
||||
- name: Install NSIS
|
||||
if: github.event_name != 'pull_request'
|
||||
run: pwsh -NoProfile -File ../scripts/install-nsis.ps1
|
||||
|
||||
- name: Build Windows installer and portable archive
|
||||
if: github.event_name != 'pull_request'
|
||||
timeout-minutes: 20
|
||||
run: ../scripts/desktop-build.sh windows/amd64 v0.0.0-ci canary
|
||||
|
||||
lint:
|
||||
needs: changes
|
||||
if: always() && (github.event_name != 'pull_request' || needs.changes.outputs.code != 'false')
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
@@ -260,11 +375,30 @@ jobs:
|
||||
version: v2.12.2
|
||||
args: --timeout=5m
|
||||
|
||||
# Report-only complexity monitor for internal/agent. Does not duplicate the
|
||||
# main lint/test/race gates; funlen+cyclop run with issues-exit-code=0 so
|
||||
# the baseline is visible as a CI summary/artifact without blocking merges.
|
||||
- name: agent complexity report
|
||||
if: always()
|
||||
run: |
|
||||
# Reuse the golangci-lint binary installed by the previous step.
|
||||
bash scripts/agent-complexity-report.sh
|
||||
|
||||
- name: upload agent complexity report
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: agent-complexity-report
|
||||
path: agent-complexity-report.txt
|
||||
if-no-files-found: ignore
|
||||
retention-days: 14
|
||||
|
||||
- name: release workflow contracts
|
||||
run: |
|
||||
go run github.com/rhysd/actionlint/cmd/[email protected] \
|
||||
-ignore 'label "windows-11-arm" is unknown' \
|
||||
.github/workflows/release-stable.yml \
|
||||
.github/workflows/release-cli-trigger.yml \
|
||||
.github/workflows/release.yml \
|
||||
.github/workflows/release-npm.yml \
|
||||
.github/workflows/release-desktop.yml
|
||||
@@ -275,6 +409,8 @@ jobs:
|
||||
# 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.
|
||||
site:
|
||||
needs: changes
|
||||
if: always() && (github.event_name != 'pull_request' || needs.changes.outputs.site != 'false')
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
@@ -290,6 +426,8 @@ jobs:
|
||||
run: npm test
|
||||
|
||||
govulncheck:
|
||||
needs: changes
|
||||
if: always() && (github.event_name != 'pull_request' || needs.changes.outputs.code != 'false')
|
||||
runs-on: ubuntu-latest
|
||||
continue-on-error: true # informational — stdlib vulns need a Go patch release
|
||||
steps:
|
||||
@@ -307,6 +445,8 @@ jobs:
|
||||
run: govulncheck ./...
|
||||
|
||||
coverage:
|
||||
needs: changes
|
||||
if: always() && (github.event_name != 'pull_request' || needs.changes.outputs.code != 'false')
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
@@ -1,10 +1,20 @@
|
||||
name: CodeQL
|
||||
|
||||
on:
|
||||
# Docs/release-notes/root-Markdown changes carry no analyzable code; the
|
||||
# weekly cron keeps the full scan cadence regardless.
|
||||
push:
|
||||
branches: ["main-v2"]
|
||||
paths-ignore:
|
||||
- 'docs/**'
|
||||
- 'release-notes/**'
|
||||
- '*.md'
|
||||
pull_request:
|
||||
branches: ["main-v2"]
|
||||
paths-ignore:
|
||||
- 'docs/**'
|
||||
- 'release-notes/**'
|
||||
- '*.md'
|
||||
schedule:
|
||||
- cron: "27 3 * * 1"
|
||||
|
||||
|
||||
@@ -11,6 +11,10 @@ on:
|
||||
description: "Previous release tag (defaults to the newest catalog entry)"
|
||||
required: false
|
||||
type: string
|
||||
target_pr:
|
||||
description: "Optional open same-repository PR into main-v2 to reuse instead of opening a release-only PR"
|
||||
required: false
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
@@ -29,20 +33,54 @@ jobs:
|
||||
with:
|
||||
node-version: "22"
|
||||
|
||||
- name: Prepare the release-notes branch
|
||||
- name: Select the review branch
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
REPOSITORY: ${{ github.repository }}
|
||||
VERSION: ${{ inputs.version }}
|
||||
TARGET_PR: ${{ inputs.target_pr }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
branch="release-notes/v${VERSION#v}"
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
if git ls-remote --exit-code --heads origin "$branch" >/dev/null 2>&1; then
|
||||
git fetch origin "refs/heads/$branch:refs/remotes/origin/$branch" main-v2
|
||||
git fetch origin main-v2
|
||||
|
||||
if [ -n "$TARGET_PR" ]; then
|
||||
if [[ ! "$TARGET_PR" =~ ^[1-9][0-9]*$ ]]; then
|
||||
echo "::error::target_pr must be a positive pull request number"
|
||||
exit 1
|
||||
fi
|
||||
mapfile -t pr_state < <(
|
||||
gh pr view "$TARGET_PR" --repo "$REPOSITORY" \
|
||||
--json state,baseRefName,headRefName,isCrossRepository \
|
||||
--jq '[.state, .baseRefName, .headRefName, (.isCrossRepository | tostring)][]'
|
||||
)
|
||||
if [ "${pr_state[0]}" != "OPEN" ] || [ "${pr_state[1]}" != "main-v2" ]; then
|
||||
echo "::error::target_pr must be an open pull request into main-v2"
|
||||
exit 1
|
||||
fi
|
||||
if [ "${pr_state[3]}" != "false" ]; then
|
||||
echo "::error::target_pr comes from a fork; the repository token cannot safely update that branch"
|
||||
exit 1
|
||||
fi
|
||||
branch="${pr_state[2]}"
|
||||
git fetch origin "refs/heads/$branch:refs/remotes/origin/$branch"
|
||||
git checkout -b "$branch" --track "origin/$branch"
|
||||
git merge --no-edit origin/main-v2
|
||||
if ! git merge-base --is-ancestor origin/main-v2 HEAD; then
|
||||
echo "::error::target_pr must include the latest main-v2 before release notes are generated"
|
||||
exit 1
|
||||
fi
|
||||
echo "RELEASE_NOTES_PR=$TARGET_PR" >> "$GITHUB_ENV"
|
||||
else
|
||||
git checkout -b "$branch"
|
||||
branch="release-notes/v${VERSION#v}"
|
||||
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"
|
||||
git merge --no-edit origin/main-v2
|
||||
else
|
||||
git checkout -b "$branch"
|
||||
fi
|
||||
echo "RELEASE_NOTES_PR=" >> "$GITHUB_ENV"
|
||||
fi
|
||||
echo "RELEASE_NOTES_BRANCH=$branch" >> "$GITHUB_ENV"
|
||||
|
||||
@@ -61,6 +99,11 @@ jobs:
|
||||
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:
|
||||
@@ -71,10 +114,16 @@ jobs:
|
||||
git add release-notes/releases.json
|
||||
if ! git diff --cached --quiet; then
|
||||
git commit -m "docs(release): prepare v${VERSION#v} notes" \
|
||||
-m "Generate a bilingual, product-focused draft from merged pull request metadata. The reviewed catalog will drive the website, desktop app, and GitHub releases."
|
||||
-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 [ -n "$RELEASE_NOTES_PR" ]; then
|
||||
echo "Updated existing release-bound PR #$RELEASE_NOTES_PR"
|
||||
elif ! gh pr list --head "$RELEASE_NOTES_BRANCH" --state open --json number --jq 'length > 0' | grep -q true; then
|
||||
gh pr create \
|
||||
--base main-v2 \
|
||||
--head "$RELEASE_NOTES_BRANCH" \
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
name: Relay CLI prerelease
|
||||
run-name: Relay CLI prerelease ${{ github.ref_name }}
|
||||
|
||||
# Keep native CLI publication on protected main-v2. The tag-triggered workflow
|
||||
# carries only the immutable candidate tag and its classified channel into the
|
||||
# protected release control plane.
|
||||
on:
|
||||
push:
|
||||
tags: ["v*-*"]
|
||||
|
||||
permissions:
|
||||
actions: write
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
relay:
|
||||
name: dispatch protected CLI control plane
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Dispatch release.yml on main-v2
|
||||
uses: actions/github-script@v9
|
||||
env:
|
||||
RELEASE_TAG: ${{ github.ref_name }}
|
||||
CONTROL_PLANE_REF: ${{ github.event.repository.default_branch }}
|
||||
with:
|
||||
script: |
|
||||
if (process.env.CONTROL_PLANE_REF !== 'main-v2') {
|
||||
core.setFailed(`Unexpected default branch: ${process.env.CONTROL_PLANE_REF}`);
|
||||
return;
|
||||
}
|
||||
const tag = process.env.RELEASE_TAG;
|
||||
const channel = /^v(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)-preview\.(?:0|[1-9]\d*)$/.test(tag)
|
||||
? 'preview'
|
||||
: 'stable';
|
||||
await github.rest.actions.createWorkflowDispatch({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
workflow_id: 'release.yml',
|
||||
ref: process.env.CONTROL_PLANE_REF,
|
||||
inputs: {
|
||||
channel,
|
||||
tag,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
name: Relay desktop prerelease
|
||||
run-name: Relay desktop prerelease ${{ github.ref_name }}
|
||||
|
||||
# Keep the production signing build on protected main-v2. The tag-triggered
|
||||
# relay carries only the immutable candidate tag into the protected workflow.
|
||||
on:
|
||||
push:
|
||||
tags: ["desktop-v*-*"]
|
||||
|
||||
permissions:
|
||||
actions: write
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
relay:
|
||||
name: dispatch protected desktop control plane
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Dispatch release-desktop.yml on main-v2
|
||||
uses: actions/github-script@v9
|
||||
env:
|
||||
RELEASE_TAG: ${{ github.ref_name }}
|
||||
CONTROL_PLANE_REF: ${{ github.event.repository.default_branch }}
|
||||
with:
|
||||
script: |
|
||||
if (process.env.CONTROL_PLANE_REF !== 'main-v2') {
|
||||
core.setFailed(`Unexpected default branch: ${process.env.CONTROL_PLANE_REF}`);
|
||||
return;
|
||||
}
|
||||
if (/^desktop-v(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)-preview\.(?:0|[1-9]\d*)$/.test(process.env.RELEASE_TAG)) {
|
||||
core.setFailed(`Desktop Preview must be dispatched without a Git tag: ${process.env.RELEASE_TAG}`);
|
||||
return;
|
||||
}
|
||||
await github.rest.actions.createWorkflowDispatch({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
workflow_id: 'release-desktop.yml',
|
||||
ref: process.env.CONTROL_PLANE_REF,
|
||||
inputs: {
|
||||
channel: 'stable',
|
||||
tag: process.env.RELEASE_TAG,
|
||||
},
|
||||
});
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,42 @@
|
||||
name: Relay stable release
|
||||
run-name: Relay stable release ${{ github.ref_name }}
|
||||
|
||||
# The tag event itself has a tag-shaped origin. Relay the immutable tag name to
|
||||
# release-stable.yml on protected main-v2 so the production SignPath policy can
|
||||
# allow exactly one branch name without trusting wildcard tag-like branches.
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
- '!v*-*'
|
||||
|
||||
permissions:
|
||||
actions: write
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
relay:
|
||||
name: dispatch protected stable control plane
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Dispatch release-stable.yml on main-v2
|
||||
uses: actions/github-script@v9
|
||||
env:
|
||||
RELEASE_TAG: ${{ github.ref_name }}
|
||||
CONTROL_PLANE_REF: ${{ github.event.repository.default_branch }}
|
||||
with:
|
||||
script: |
|
||||
if (process.env.CONTROL_PLANE_REF !== 'main-v2') {
|
||||
core.setFailed(`Unexpected default branch: ${process.env.CONTROL_PLANE_REF}`);
|
||||
return;
|
||||
}
|
||||
await github.rest.actions.createWorkflowDispatch({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
workflow_id: 'release-stable.yml',
|
||||
ref: process.env.CONTROL_PLANE_REF,
|
||||
inputs: {
|
||||
tag: process.env.RELEASE_TAG,
|
||||
allow_recovery: 'false',
|
||||
},
|
||||
});
|
||||
@@ -2,24 +2,42 @@ name: Release stable
|
||||
run-name: Release stable ${{ inputs.tag || github.ref_name }}
|
||||
|
||||
# Stable publication has exactly one GitHub environment gate. Push the CLI,
|
||||
# npm, and desktop tags atomically; the vX.Y.Z tag starts this workflow,
|
||||
# preflight verifies that all three tags point to the current main-v2 commit,
|
||||
# and one GitHub approval releases every channel. A manual recovery runs the
|
||||
# fixed control plane from protected main-v2 while preserving an older tagged
|
||||
# candidate on main-v2 history. SignPath intentionally keeps its separate
|
||||
# manual confirmations. Prerelease and canary workflows remain independently
|
||||
# callable.
|
||||
# npm, and desktop tags atomically; release-stable-trigger.yml relays the vX.Y.Z
|
||||
# tag to this workflow on protected main-v2. Preflight verifies that all three
|
||||
# tags point to the current main-v2 commit, and one GitHub approval releases
|
||||
# every channel. After approval, a zero-publication Windows preflight verifies
|
||||
# both architectures and signing stages before any public channel starts.
|
||||
# Keeping the control-plane ref on main-v2 lets SignPath restrict
|
||||
# production signing to that one protected origin instead of trusting wildcard
|
||||
# tag-like branch names. Manual recovery uses the same fixed control plane while
|
||||
# preserving an older tagged candidate on main-v2 history.
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
- '!v*-*'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: "Existing stable CLI tag to recover (for example v1.18.0)"
|
||||
required: true
|
||||
type: string
|
||||
publish_cli:
|
||||
description: "Recover the CLI/Homebrew channel"
|
||||
required: false
|
||||
default: true
|
||||
type: boolean
|
||||
publish_npm:
|
||||
description: "Recover the npm channel"
|
||||
required: false
|
||||
default: true
|
||||
type: boolean
|
||||
publish_desktop:
|
||||
description: "Recover the Desktop/R2 channel"
|
||||
required: false
|
||||
default: true
|
||||
type: boolean
|
||||
allow_recovery:
|
||||
description: "Allow an existing stable tag on main-v2 history (manual recovery only)"
|
||||
required: false
|
||||
default: true
|
||||
type: boolean
|
||||
|
||||
concurrency:
|
||||
group: stable-release-${{ inputs.tag || github.ref_name }}
|
||||
@@ -61,10 +79,20 @@ jobs:
|
||||
id: release
|
||||
env:
|
||||
RELEASE_TAG: ${{ inputs.tag || github.ref_name }}
|
||||
ALLOW_STABLE_RECOVERY: ${{ github.event_name == 'workflow_dispatch' }}
|
||||
ALLOW_STABLE_RECOVERY: ${{ inputs.allow_recovery }}
|
||||
run: bash scripts/resolve-stable-release.sh
|
||||
- name: Validate reviewed release notes
|
||||
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
|
||||
# validated by this protected control-plane job into both publishers.
|
||||
- name: Upload reviewed release notes
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: stable-reviewed-release-notes
|
||||
path: /tmp/release-notes.md
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
- name: Cache hit guard
|
||||
run: ./scripts/cache-guard.sh
|
||||
|
||||
@@ -102,7 +130,8 @@ jobs:
|
||||
|
||||
cli:
|
||||
name: publish CLI and Homebrew
|
||||
needs: authorize
|
||||
needs: [authorize, signpath-preflight]
|
||||
if: ${{ always() && !cancelled() && needs.authorize.result == 'success' && (needs.signpath-preflight.result == 'success' || needs.signpath-preflight.result == 'skipped') && (github.event_name != 'workflow_dispatch' || inputs.publish_cli) }}
|
||||
uses: ./.github/workflows/release.yml
|
||||
with:
|
||||
tag: ${{ needs.authorize.outputs.cli_tag }}
|
||||
@@ -113,7 +142,8 @@ jobs:
|
||||
|
||||
npm:
|
||||
name: publish npm
|
||||
needs: authorize
|
||||
needs: [authorize, signpath-preflight]
|
||||
if: ${{ always() && !cancelled() && needs.authorize.result == 'success' && (needs.signpath-preflight.result == 'success' || needs.signpath-preflight.result == 'skipped') && (github.event_name != 'workflow_dispatch' || inputs.publish_npm) }}
|
||||
uses: ./.github/workflows/release-npm.yml
|
||||
with:
|
||||
channel: stable
|
||||
@@ -124,9 +154,10 @@ jobs:
|
||||
orchestrated: true
|
||||
secrets: inherit
|
||||
|
||||
desktop:
|
||||
name: publish desktop
|
||||
signpath-preflight:
|
||||
name: verify stable SignPath control plane
|
||||
needs: authorize
|
||||
if: ${{ github.event_name != 'workflow_dispatch' || inputs.publish_desktop }}
|
||||
uses: ./.github/workflows/release-desktop.yml
|
||||
with:
|
||||
channel: stable
|
||||
@@ -134,6 +165,21 @@ jobs:
|
||||
approved_cli_tag: ${{ needs.authorize.outputs.cli_tag }}
|
||||
approved_sha: ${{ needs.authorize.outputs.sha }}
|
||||
orchestrated: true
|
||||
signing_preflight: true
|
||||
secrets: inherit
|
||||
|
||||
desktop:
|
||||
name: publish desktop
|
||||
needs: [authorize, signpath-preflight]
|
||||
if: ${{ always() && !cancelled() && needs.authorize.result == 'success' && needs.signpath-preflight.result == 'success' && (github.event_name != 'workflow_dispatch' || inputs.publish_desktop) }}
|
||||
uses: ./.github/workflows/release-desktop.yml
|
||||
with:
|
||||
channel: stable
|
||||
tag: ${{ needs.authorize.outputs.desktop_tag }}
|
||||
approved_cli_tag: ${{ needs.authorize.outputs.cli_tag }}
|
||||
approved_sha: ${{ needs.authorize.outputs.sha }}
|
||||
orchestrated: true
|
||||
signing_preflight_verified: true
|
||||
secrets: inherit
|
||||
|
||||
postflight:
|
||||
@@ -146,14 +192,25 @@ jobs:
|
||||
steps:
|
||||
- name: Require every publisher to succeed
|
||||
env:
|
||||
PUBLISH_CLI: ${{ github.event_name != 'workflow_dispatch' || inputs.publish_cli }}
|
||||
PUBLISH_NPM: ${{ github.event_name != 'workflow_dispatch' || inputs.publish_npm }}
|
||||
PUBLISH_DESKTOP: ${{ github.event_name != 'workflow_dispatch' || inputs.publish_desktop }}
|
||||
CLI_RESULT: ${{ needs.cli.result }}
|
||||
NPM_RESULT: ${{ needs.npm.result }}
|
||||
DESKTOP_RESULT: ${{ needs.desktop.result }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
for result in "$CLI_RESULT" "$NPM_RESULT" "$DESKTOP_RESULT"; do
|
||||
for channel in cli npm desktop; do
|
||||
selected_var="PUBLISH_${channel^^}"
|
||||
result_var="${channel^^}_RESULT"
|
||||
selected="${!selected_var}"
|
||||
result="${!result_var}"
|
||||
if [ "$selected" != "true" ]; then
|
||||
echo "$channel recovery skipped; public postflight will still verify it"
|
||||
continue
|
||||
fi
|
||||
if [ "$result" != "success" ]; then
|
||||
echo "::error::stable publisher result is $result, expected success"
|
||||
echo "::error::$channel stable publisher result is $result, expected success"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
+254
-26
@@ -1,21 +1,33 @@
|
||||
name: Release
|
||||
|
||||
# CLI binary line. Stable releases are called by release-stable.yml after its
|
||||
# single GitHub `release`-environment approval. Direct tag pushes are retained
|
||||
# only for CLI prereleases so they can still ship independently; those runs keep
|
||||
# their own approval gate. goreleaser auto-skips the cask upload for prereleases
|
||||
# (brew has no prerelease channel — see .goreleaser.yaml).
|
||||
# Native CLI binary line. Stable releases are called by release-stable.yml after
|
||||
# its single GitHub `release`-environment approval. Preview uses immutable
|
||||
# vX.Y.Z-preview.N tags, publishes a GitHub prerelease behind the same `canary`
|
||||
# gate as Desktop Preview, and never moves Homebrew or GitHub Latest. Other
|
||||
# standalone prereleases remain internal stable-channel checkpoints behind the
|
||||
# `release` gate.
|
||||
on:
|
||||
push:
|
||||
tags: ['v*-*']
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
channel:
|
||||
description: "Native CLI release channel"
|
||||
required: true
|
||||
# Preserve existing tag-only recovery dispatches as Stable. Preview must
|
||||
# be an explicit operator choice or arrive through the tag relay.
|
||||
default: stable
|
||||
type: choice
|
||||
options: [stable, preview]
|
||||
tag:
|
||||
description: "Existing CLI tag to publish (for example v1.18.0-rc.1)"
|
||||
description: "Existing protected CLI tag (Preview: v1.18.0-preview.1; Stable/RC: v1.18.0-rc.1)"
|
||||
required: true
|
||||
type: string
|
||||
workflow_call:
|
||||
inputs:
|
||||
channel:
|
||||
description: "Native CLI release channel selected by the approved orchestrator"
|
||||
required: false
|
||||
default: stable
|
||||
type: string
|
||||
tag:
|
||||
description: "Existing CLI tag selected by the approved release orchestrator"
|
||||
required: true
|
||||
@@ -37,9 +49,69 @@ on:
|
||||
permissions:
|
||||
contents: write # create the release and upload archives
|
||||
|
||||
concurrency:
|
||||
# A channel pointer is a monotonic public state machine. Serialize all
|
||||
# publishers for the same channel so an older recovery run cannot pass its
|
||||
# read-before-write window after a newer release has published.
|
||||
group: release-cli-${{ inputs.channel || 'stable' }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
resolve:
|
||||
name: resolve CLI release
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
tag: ${{ steps.release.outputs.tag }}
|
||||
version: ${{ steps.release.outputs.version }}
|
||||
base_version: ${{ steps.release.outputs.base_version }}
|
||||
notes_version: ${{ steps.release.outputs.notes_version }}
|
||||
channel: ${{ steps.release.outputs.channel }}
|
||||
prerelease: ${{ steps.release.outputs.prerelease }}
|
||||
sha: ${{ steps.candidate.outputs.sha }}
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
ref: ${{ github.sha }}
|
||||
- name: Resolve channel and tag
|
||||
id: release
|
||||
env:
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
IN_ORCHESTRATED: ${{ inputs.orchestrated }}
|
||||
IN_CHANNEL: ${{ inputs.channel }}
|
||||
IN_TAG: ${{ inputs.tag }}
|
||||
REF_NAME: ${{ github.ref_name }}
|
||||
CALLER_REF: ${{ github.ref }}
|
||||
CALLER_REF_PROTECTED: ${{ github.ref_protected }}
|
||||
run: bash scripts/resolve-cli-release.sh
|
||||
- name: Record immutable candidate
|
||||
id: candidate
|
||||
env:
|
||||
RELEASE_TAG: ${{ steps.release.outputs.tag }}
|
||||
RELEASE_CHANNEL: ${{ steps.release.outputs.channel }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git fetch origin main-v2
|
||||
sha="$(git rev-parse "$RELEASE_TAG^{commit}")"
|
||||
if ! git merge-base --is-ancestor "$sha" origin/main-v2; then
|
||||
echo "::error::$RELEASE_TAG points to $sha, which is not on main-v2 history"
|
||||
exit 1
|
||||
fi
|
||||
if [ "$RELEASE_CHANNEL" = "preview" ] && [ "$sha" != "$(git rev-parse origin/main-v2)" ]; then
|
||||
echo "::error::CLI Preview must tag current main-v2; $RELEASE_TAG points to $sha"
|
||||
exit 1
|
||||
fi
|
||||
echo "sha=$sha" >> "$GITHUB_OUTPUT"
|
||||
- name: Verify existing protected tag
|
||||
env:
|
||||
RELEASE_TAG: ${{ steps.release.outputs.tag }}
|
||||
APPROVED_SHA: ${{ steps.candidate.outputs.sha }}
|
||||
VERIFY_RELEASE_CHECKOUT: false
|
||||
run: bash scripts/verify-release-tag.sh
|
||||
|
||||
orchestration-guard:
|
||||
name: verify approved orchestrator
|
||||
needs: resolve
|
||||
if: ${{ inputs.orchestrated }}
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
@@ -68,23 +140,25 @@ jobs:
|
||||
|
||||
release-gate:
|
||||
name: approve standalone CLI release
|
||||
needs: resolve
|
||||
if: ${{ !inputs.orchestrated }}
|
||||
runs-on: ubuntu-latest
|
||||
environment: release
|
||||
environment: ${{ needs.resolve.outputs.channel == 'preview' && 'canary' || 'release' }}
|
||||
steps:
|
||||
- env:
|
||||
RELEASE_TAG: ${{ inputs.tag || github.ref_name }}
|
||||
run: echo "Approved standalone CLI release $RELEASE_TAG"
|
||||
RELEASE_TAG: ${{ needs.resolve.outputs.tag }}
|
||||
RELEASE_CHANNEL: ${{ needs.resolve.outputs.channel }}
|
||||
run: echo "Approved standalone CLI $RELEASE_CHANNEL release $RELEASE_TAG"
|
||||
|
||||
cache-guard:
|
||||
name: cache hit guard
|
||||
needs: [orchestration-guard, release-gate]
|
||||
if: ${{ always() && !cancelled() && ((inputs.orchestrated && needs.orchestration-guard.result == 'success') || (!inputs.orchestrated && needs.release-gate.result == 'success')) }}
|
||||
needs: [resolve, orchestration-guard, release-gate]
|
||||
if: ${{ always() && !cancelled() && needs.resolve.result == 'success' && ((inputs.orchestrated && needs.orchestration-guard.result == 'success') || (!inputs.orchestrated && needs.release-gate.result == 'success')) }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
ref: ${{ inputs.approved_sha || inputs.tag || github.ref }}
|
||||
ref: ${{ needs.resolve.outputs.sha }}
|
||||
- uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
@@ -93,7 +167,7 @@ jobs:
|
||||
|
||||
goreleaser:
|
||||
name: archives + checksums + homebrew tap
|
||||
needs: cache-guard
|
||||
needs: [resolve, cache-guard]
|
||||
if: ${{ always() && !cancelled() && needs.cache-guard.result == 'success' }}
|
||||
runs-on: ubuntu-latest
|
||||
# The stable caller has already passed the single GitHub release approval.
|
||||
@@ -103,7 +177,7 @@ jobs:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
ref: ${{ inputs.approved_sha || inputs.tag || github.ref }}
|
||||
ref: ${{ needs.resolve.outputs.sha }}
|
||||
- uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
@@ -111,15 +185,26 @@ jobs:
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: "22"
|
||||
- name: Download orchestrator-reviewed release notes
|
||||
if: ${{ inputs.orchestrated }}
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: stable-reviewed-release-notes
|
||||
path: /tmp/stable-reviewed-release-notes
|
||||
- name: Use orchestrator-reviewed release notes
|
||||
if: ${{ inputs.orchestrated }}
|
||||
run: |
|
||||
test -s /tmp/stable-reviewed-release-notes/release-notes.md
|
||||
cp /tmp/stable-reviewed-release-notes/release-notes.md /tmp/release-notes.md
|
||||
- name: Render reviewed release notes
|
||||
if: ${{ !inputs.orchestrated }}
|
||||
env:
|
||||
RELEASE_TAG: ${{ inputs.tag || github.ref_name }}
|
||||
RELEASE_TAG: ${{ needs.resolve.outputs.notes_version }}
|
||||
run: node scripts/release-notes.mjs render --version "$RELEASE_TAG" --output /tmp/release-notes.md
|
||||
- name: Revalidate approved release ref
|
||||
if: ${{ inputs.orchestrated }}
|
||||
env:
|
||||
RELEASE_TAG: ${{ inputs.tag }}
|
||||
APPROVED_SHA: ${{ inputs.approved_sha }}
|
||||
RELEASE_TAG: ${{ needs.resolve.outputs.tag }}
|
||||
APPROVED_SHA: ${{ needs.resolve.outputs.sha }}
|
||||
run: bash scripts/verify-release-tag.sh
|
||||
- uses: goreleaser/goreleaser-action@v7
|
||||
with:
|
||||
@@ -127,22 +212,165 @@ jobs:
|
||||
args: release --clean
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
HOMEBREW_TAP_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }}
|
||||
HOMEBREW_TAP_TOKEN: ${{ needs.resolve.outputs.channel == 'stable' && 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.
|
||||
GORELEASER_CURRENT_TAG: ${{ inputs.tag || github.ref_name }}
|
||||
GORELEASER_CURRENT_TAG: ${{ needs.resolve.outputs.tag }}
|
||||
|
||||
- name: Publish product release notes
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
TAG: ${{ inputs.tag || github.ref_name }}
|
||||
TAG: ${{ needs.resolve.outputs.tag }}
|
||||
run: gh release edit "$TAG" --notes-file /tmp/release-notes.md
|
||||
|
||||
- name: Publish CLI release metadata to R2
|
||||
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 != '' }}
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
|
||||
AWS_DEFAULT_REGION: auto
|
||||
R2_ACCOUNT_ID: ${{ secrets.R2_ACCOUNT_ID }}
|
||||
R2_BUCKET: ${{ secrets.R2_BUCKET }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ "$HAS_R2" != "true" ]; then
|
||||
echo "R2 secrets not configured; skipping CLI release metadata"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
channel=""
|
||||
if [[ "$TAG" =~ ^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]]; then
|
||||
channel="stable"
|
||||
elif [[ "$TAG" =~ ^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)-preview\.(0|[1-9][0-9]*)$ ]]; then
|
||||
channel="preview"
|
||||
else
|
||||
echo "internal CLI release $TAG; publishing only immutable metadata"
|
||||
fi
|
||||
|
||||
required_assets='[
|
||||
"reasonix-darwin-amd64.tar.gz",
|
||||
"reasonix-darwin-arm64.tar.gz",
|
||||
"reasonix-linux-amd64.tar.gz",
|
||||
"reasonix-linux-arm64.tar.gz",
|
||||
"reasonix-windows-amd64.zip",
|
||||
"reasonix-windows-arm64.zip",
|
||||
"SHA256SUMS"
|
||||
]'
|
||||
gh api "repos/${{ github.repository }}/releases/tags/$TAG" > /tmp/cli-release.raw.json
|
||||
jq --arg tag "$TAG" --argjson required "$required_assets" '
|
||||
if .tag_name != $tag then error("release tag mismatch") else . end |
|
||||
if .draft then error("draft release cannot be published") else . end |
|
||||
. as $release |
|
||||
($release.assets | map({key: .name, value: .}) | from_entries) as $assets |
|
||||
if ($required | all(. as $name | $assets[$name] != null))
|
||||
then {
|
||||
tag_name: $release.tag_name,
|
||||
prerelease: $release.prerelease,
|
||||
html_url: $release.html_url,
|
||||
assets: [
|
||||
$required[] as $name |
|
||||
$assets[$name] |
|
||||
{
|
||||
name: .name,
|
||||
browser_download_url: .browser_download_url,
|
||||
size: .size
|
||||
}
|
||||
]
|
||||
}
|
||||
else error("release is missing one or more required CLI assets")
|
||||
end
|
||||
' /tmp/cli-release.raw.json > /tmp/cli-release.json
|
||||
if [ -n "$channel" ]; then
|
||||
bash scripts/validate-cli-release-manifest.sh \
|
||||
"$channel" "$TAG" "${{ github.repository }}" /tmp/cli-release.json
|
||||
else
|
||||
bash scripts/validate-cli-release-manifest.sh \
|
||||
any "$TAG" "${{ github.repository }}" /tmp/cli-release.json
|
||||
fi
|
||||
|
||||
endpoint="https://${R2_ACCOUNT_ID}.r2.cloudflarestorage.com"
|
||||
validation_channel="${channel:-any}"
|
||||
immutable_key="cli/releases/${TAG}/latest.json"
|
||||
immutable_error="$(mktemp)"
|
||||
if aws s3 cp "s3://${R2_BUCKET}/${immutable_key}" /tmp/cli-release.immutable.json \
|
||||
--endpoint-url "$endpoint" 2>"$immutable_error"; then
|
||||
bash scripts/validate-cli-release-manifest.sh \
|
||||
"$validation_channel" "$TAG" "${{ github.repository }}" \
|
||||
/tmp/cli-release.immutable.json
|
||||
if ! cmp -s /tmp/cli-release.json /tmp/cli-release.immutable.json; then
|
||||
echo "::error::immutable CLI release metadata for $TAG already exists with different content"
|
||||
exit 1
|
||||
fi
|
||||
echo "immutable CLI release metadata for $TAG already exists; preserving it"
|
||||
elif grep -Eiq '404|NoSuchKey|Not Found' "$immutable_error"; then
|
||||
aws s3 cp /tmp/cli-release.json "s3://${R2_BUCKET}/${immutable_key}" \
|
||||
--endpoint-url "$endpoint" \
|
||||
--content-type "application/json; charset=utf-8" \
|
||||
--cache-control "public, max-age=31536000, immutable"
|
||||
else
|
||||
cat "$immutable_error" >&2
|
||||
exit 1
|
||||
fi
|
||||
rm -f "$immutable_error"
|
||||
|
||||
aws s3 cp "s3://${R2_BUCKET}/${immutable_key}" /tmp/cli-release.immutable.json \
|
||||
--endpoint-url "$endpoint"
|
||||
bash scripts/validate-cli-release-manifest.sh \
|
||||
"$validation_channel" "$TAG" "${{ github.repository }}" \
|
||||
/tmp/cli-release.immutable.json
|
||||
cmp -s /tmp/cli-release.json /tmp/cli-release.immutable.json
|
||||
|
||||
if [ -z "$channel" ]; then
|
||||
echo "internal CLI release $TAG; Stable and Preview pointers remain unchanged"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
current_tag=""
|
||||
pointer_error="$(mktemp)"
|
||||
if aws s3 cp "s3://${R2_BUCKET}/cli/${channel}/latest.json" /tmp/cli-release.pointer.json \
|
||||
--endpoint-url "$endpoint" 2>"$pointer_error"; then
|
||||
current_tag="$(jq -er '.tag_name | strings' /tmp/cli-release.pointer.json)"
|
||||
bash scripts/validate-cli-release-manifest.sh \
|
||||
"$channel" "$current_tag" "${{ github.repository }}" /tmp/cli-release.pointer.json
|
||||
elif grep -Eiq '404|NoSuchKey|Not Found' "$pointer_error"; then
|
||||
echo "CLI $channel pointer does not exist yet"
|
||||
else
|
||||
cat "$pointer_error" >&2
|
||||
exit 1
|
||||
fi
|
||||
rm -f "$pointer_error"
|
||||
|
||||
pointer_manifest=-
|
||||
if [ -n "$current_tag" ]; then
|
||||
pointer_manifest=/tmp/cli-release.pointer.json
|
||||
fi
|
||||
pointer_decision="$(
|
||||
bash scripts/decide-cli-pointer-update.sh \
|
||||
"$channel" /tmp/cli-release.json "$pointer_manifest"
|
||||
)"
|
||||
if [ "$pointer_decision" = "skip" ]; then
|
||||
echo "CLI $channel pointer remains ${current_tag:-unset}; candidate $TAG is not newer and needs no repair"
|
||||
exit 0
|
||||
fi
|
||||
aws s3 cp /tmp/cli-release.json "s3://${R2_BUCKET}/cli/${channel}/latest.json" \
|
||||
--endpoint-url "$endpoint" \
|
||||
--content-type "application/json; charset=utf-8" \
|
||||
--cache-control "public, max-age=300, stale-if-error=86400"
|
||||
|
||||
aws s3 cp "s3://${R2_BUCKET}/cli/${channel}/latest.json" /tmp/cli-release.pointer.json \
|
||||
--endpoint-url "$endpoint"
|
||||
bash scripts/validate-cli-release-manifest.sh \
|
||||
"$channel" "$TAG" "${{ github.repository }}" /tmp/cli-release.pointer.json
|
||||
cmp -s /tmp/cli-release.json /tmp/cli-release.pointer.json
|
||||
echo "CLI $channel pointer -> $TAG"
|
||||
|
||||
- name: Attach desktop manifest compatibility asset
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
TAG: ${{ inputs.tag || github.ref_name }}
|
||||
TAG: ${{ needs.resolve.outputs.tag }}
|
||||
HAS_R2: ${{ secrets.R2_ACCESS_KEY_ID != '' && secrets.R2_SECRET_ACCESS_KEY != '' && secrets.R2_ACCOUNT_ID != '' && secrets.R2_BUCKET != '' }}
|
||||
R2_ACCOUNT_ID: ${{ secrets.R2_ACCOUNT_ID }}
|
||||
R2_BUCKET: ${{ secrets.R2_BUCKET }}
|
||||
@@ -184,7 +412,7 @@ jobs:
|
||||
# cover the release CDN propagating the freshly uploaded asset.
|
||||
- name: Smoke public compatibility manifest
|
||||
env:
|
||||
TAG: ${{ inputs.tag || github.ref_name }}
|
||||
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: |
|
||||
set -euo pipefail
|
||||
@@ -231,7 +459,7 @@ jobs:
|
||||
# owns asserting the dist-tag lands.
|
||||
- name: Check npm latest dist-tag freshness
|
||||
env:
|
||||
TAG: ${{ inputs.tag || github.ref_name }}
|
||||
TAG: ${{ needs.resolve.outputs.tag }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
case "$TAG" in
|
||||
|
||||
@@ -71,3 +71,4 @@ temp/
|
||||
# Official theme art intermediate outputs / caches
|
||||
scripts/official-theme-art/out/
|
||||
scripts/official-theme-art/__pycache__/
|
||||
agent-complexity-report.txt
|
||||
@@ -0,0 +1,40 @@
|
||||
# Report-only complexity monitor for internal/agent production code.
|
||||
# Uses funlen + cyclop with generous thresholds so existing code does not
|
||||
# generate a flood of noise. issues-exit-code is forced to 0 by the CI step so
|
||||
# this never blocks merges while we establish a baseline and track trend.
|
||||
version: "2"
|
||||
|
||||
run:
|
||||
tests: false
|
||||
|
||||
linters:
|
||||
default: none
|
||||
enable:
|
||||
- funlen
|
||||
- cyclop
|
||||
settings:
|
||||
funlen:
|
||||
# Deliberately above a hard 50-line rule: length alone is not the
|
||||
# acceptance gate. This surfaces only the remaining large hotspots after
|
||||
# the Run/executeOne extraction so trend can be tracked.
|
||||
lines: 120
|
||||
statements: 80
|
||||
ignore-comments: true
|
||||
cyclop:
|
||||
max-complexity: 30
|
||||
package-average: 0
|
||||
skip-tests: true
|
||||
|
||||
issues:
|
||||
max-issues-per-linter: 0
|
||||
max-same-issues: 0
|
||||
# Report mode: never fail the job. Baseline/trend is the gate, not a hard cut.
|
||||
# The CI step also passes --issues-exit-code=0 for older golangci-lint builds.
|
||||
uniq-by-line: false
|
||||
|
||||
output:
|
||||
formats:
|
||||
text:
|
||||
path: stdout
|
||||
print-linter-name: true
|
||||
print-issued-lines: true
|
||||
+3
-2
@@ -34,8 +34,9 @@ checksum:
|
||||
homebrew_casks:
|
||||
- name: reasonix
|
||||
ids: [reasonix]
|
||||
# Prereleases (v*-rc*) must not update the stable tap; brew has no separate
|
||||
# prerelease channel, so a pushed rc cask becomes the default `brew install`.
|
||||
# Prereleases (including v*-preview.* and v*-rc*) must not update the stable
|
||||
# tap; brew has no separate prerelease channel, so uploading either cask
|
||||
# would make it the default `brew install`.
|
||||
# npm is a separate line (release-npm.yml, `npm-v*`), so an npm prerelease no
|
||||
# longer drags brew along — a stable `v*` cask can ship while npm stays on rc.
|
||||
skip_upload: "auto"
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Canary-only configuration. SignPath test certificates intentionally do not
|
||||
chain to a Windows trusted root, so authenticode-verify cannot be used for
|
||||
the already test-signed payload. The workflow verifies that every payload
|
||||
executable is signed and hash-identical to the rebuilt portable package.
|
||||
Stable and RC releases use windows-installer-v2 instead. -->
|
||||
<artifact-configuration xmlns="http://signpath.io/artifact-configuration/v1">
|
||||
<zip-file>
|
||||
<pe-file path="*installer*.exe">
|
||||
<authenticode-sign />
|
||||
</pe-file>
|
||||
</zip-file>
|
||||
</artifact-configuration>
|
||||
@@ -0,0 +1,30 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Sign the finished NSIS container only when the exact payload used to build it
|
||||
is present and already Authenticode-signed. GitHub Actions wraps this flat
|
||||
bundle in a zip before SignPath receives it. Keep this versioned separately
|
||||
so provisioning it cannot break releases still using windows-installer. -->
|
||||
<artifact-configuration xmlns="http://signpath.io/artifact-configuration/v1">
|
||||
<zip-file>
|
||||
<pe-file path="*installer*.exe">
|
||||
<authenticode-sign />
|
||||
</pe-file>
|
||||
<pe-file path="reasonix-desktop.exe">
|
||||
<authenticode-verify />
|
||||
</pe-file>
|
||||
<pe-file path="reasonix-guard.exe">
|
||||
<authenticode-verify />
|
||||
</pe-file>
|
||||
<pe-file path="reasonix-launcher.exe">
|
||||
<authenticode-verify />
|
||||
</pe-file>
|
||||
<pe-file path="reasonix-update-helper.exe">
|
||||
<authenticode-verify />
|
||||
</pe-file>
|
||||
<pe-file path="reasonix-cli.exe">
|
||||
<authenticode-verify />
|
||||
</pe-file>
|
||||
<pe-file path="reasonix-uninstall.exe">
|
||||
<authenticode-verify />
|
||||
</pe-file>
|
||||
</zip-file>
|
||||
</artifact-configuration>
|
||||
@@ -0,0 +1,25 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Sign every executable that the NSIS installer writes to disk. GitHub Actions
|
||||
wraps the flat payload directory in this zip before SignPath receives it. -->
|
||||
<artifact-configuration xmlns="http://signpath.io/artifact-configuration/v1">
|
||||
<zip-file>
|
||||
<pe-file path="reasonix-desktop.exe">
|
||||
<authenticode-sign />
|
||||
</pe-file>
|
||||
<pe-file path="reasonix-guard.exe">
|
||||
<authenticode-sign />
|
||||
</pe-file>
|
||||
<pe-file path="reasonix-launcher.exe">
|
||||
<authenticode-sign />
|
||||
</pe-file>
|
||||
<pe-file path="reasonix-update-helper.exe">
|
||||
<authenticode-sign />
|
||||
</pe-file>
|
||||
<pe-file path="reasonix-cli.exe">
|
||||
<authenticode-sign />
|
||||
</pe-file>
|
||||
<pe-file path="reasonix-uninstall.exe">
|
||||
<authenticode-sign />
|
||||
</pe-file>
|
||||
</zip-file>
|
||||
</artifact-configuration>
|
||||
@@ -0,0 +1,20 @@
|
||||
version: 1
|
||||
project_slug: DeepSeek-Reasonix
|
||||
signing_policy_slug: release-signing
|
||||
repository_url: https://github.com/esengine/DeepSeek-Reasonix.git
|
||||
allowed_branch_names:
|
||||
- main-v2
|
||||
allowed_build_definitions:
|
||||
- .github/workflows/release-stable.yml
|
||||
- .github/workflows/release-desktop.yml
|
||||
fingerprint_files:
|
||||
- .github/workflows/release-stable.yml
|
||||
- .github/workflows/release-stable-trigger.yml
|
||||
- .github/workflows/release-desktop.yml
|
||||
- .github/workflows/release-desktop-trigger.yml
|
||||
- .signpath/artifact-configurations/windows-payload.xml
|
||||
- .signpath/artifact-configurations/windows-installer-v2.xml
|
||||
- scripts/complete-signpath-request.ps1
|
||||
- scripts/package-windows-desktop.sh
|
||||
- scripts/verify-windows-authenticode.ps1
|
||||
- cmd/signpath-contract/main.go
|
||||
@@ -69,6 +69,28 @@ branch.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Hardened Bash permission reuse for dynamic and indirect execution. Parameter/arithmetic expansions,
|
||||
assignments, redirects, heredocs, and globs can only be remembered as exact
|
||||
`Bash=<literal>` rules, while still using Auto's normal fallback. Nested or
|
||||
indirect execution now requires a human in interactive Ask/Auto and fails
|
||||
closed in headless Ask/Auto/DontAsk. Broad Bash rules, Guardian/hook allows,
|
||||
and the approved-plan window can no longer silently authorize that stricter
|
||||
class; YOLO remains the explicit full-access bypass and sandbox enforcement
|
||||
is unchanged.
|
||||
- Fixed Desktop sessions incorrectly locking themselves during Goal + Delivery
|
||||
mode changes, controller rebuilds, duplicate-tab restore, and background
|
||||
reattachment. Desktop now keeps one process-local runtime owner per canonical
|
||||
session, fences stale controller events by runtime epoch, blocks sends until
|
||||
that runtime is ready, and scopes single-instance ownership to
|
||||
`REASONIX_HOME` instead of the executable path. Switching saved sessions is
|
||||
now transactional: a target build, restore, or lease failure leaves the
|
||||
current controller, lease, path, mode profile, and runtime epoch untouched.
|
||||
- Stabilized the desktop rich composer caret after skill and plugin invocation
|
||||
tags. DOM→model and model→DOM selection mapping now treat invocation chips as
|
||||
zero-length atoms while still counting user text that lands inside the NBSP
|
||||
caret anchor (common on Windows WebView2), restore both selection ends, and
|
||||
recover the insertion point from a `beforeinput` snapshot when the browser
|
||||
temporarily loses selection — so mid-text edits no longer jump to the end.
|
||||
- Isolated the Windows desktop WebView2 shell from stale system proxies, so an
|
||||
exited proxy client cannot leave the embedded UI hidden during startup. If
|
||||
WebView2 still does not reach DOM-ready within 15 seconds, Reasonix now shows
|
||||
|
||||
@@ -139,6 +139,7 @@ For advanced CLI usage and configuration, see the **[CLI reference](./docs/CLI.m
|
||||
- **Getting started:** [Guide](./docs/GUIDE.md) · [CLI reference](./docs/CLI.md) ·
|
||||
[Configuration paths](./docs/CONFIG_PATHS.md) · [ACP editor integration](./docs/ACP.md)
|
||||
- **Features & troubleshooting:** [Subagent profiles](./docs/SUBAGENT_PROFILES.md) ·
|
||||
[Context Engine v2](./docs/SESSION_MEMORY_RETRIEVAL.md) ·
|
||||
[Capability diagnostics](./docs/CAPABILITY_DIAGNOSTICS.md) ·
|
||||
[Recovery and Safe Mode](./docs/RECOVERY.md) · [Bot guide](./docs/BOT_GUIDE.md) ·
|
||||
[Checkpoints & rewind](./docs/CHECKPOINTS.md)
|
||||
|
||||
@@ -131,6 +131,7 @@ CLI 进阶用法和详细配置见 **[CLI 命令参考](./docs/CLI.zh-CN.md)**
|
||||
[CLI 命令参考](./docs/CLI.zh-CN.md) · [配置路径](./docs/CONFIG_PATHS.zh-CN.md) ·
|
||||
[ACP 编辑器接入](./docs/ACP.zh-CN.md)
|
||||
- **功能与排障:** [子智能体 Profile](./docs/SUBAGENT_PROFILES.zh-CN.md) ·
|
||||
[Context Engine v2](./docs/SESSION_MEMORY_RETRIEVAL.zh-CN.md) ·
|
||||
[能力诊断](./docs/CAPABILITY_DIAGNOSTICS.zh-CN.md) ·
|
||||
[恢复与安全模式](./docs/RECOVERY.zh-CN.md) ·
|
||||
[机器人使用指南](./docs/BOT_GUIDE.zh-CN.md) ·
|
||||
|
||||
+12
-6
@@ -17,13 +17,19 @@ agent. It is the Reasonix analog of Claude Code's CLAUDE.md.
|
||||
|
||||
## Memory
|
||||
|
||||
- Hierarchical docs: `REASONIX.md` (this file, committed/shared), `REASONIX.local.md`
|
||||
(personal, git-ignored), user-global `~/.config/reasonix/REASONIX.md`, and any
|
||||
`REASONIX.md` in an ancestor dir. `AGENTS.md` is accepted as a fallback name.
|
||||
- Standing instructions are hierarchical: committed/shared `REASONIX.md`,
|
||||
`AGENTS.md`, and `CLAUDE.md`; personal `*.local.md` variants; matching files in
|
||||
ancestor directories; and user-global files under the memory state root
|
||||
(`REASONIX_STATE_HOME`, otherwise `REASONIX_HOME`, otherwise `~/.reasonix` on
|
||||
macOS/Linux or `%APPDATA%\reasonix` on Windows). All distinct supported files
|
||||
in a directory load; `AGENTS.md` is not merely a fallback.
|
||||
- `@path` on its own line imports another file's contents.
|
||||
- `#<note>` in chat quick-adds a line here. The `remember` tool saves durable
|
||||
facts to the per-project auto-memory store (frontmatter files + `MEMORY.md`
|
||||
index), which loads into the prefix on the next session.
|
||||
- `#<note>` in chat quick-adds an always-on instruction. The `remember` tool
|
||||
instead saves a fallible background fact (frontmatter file + `MEMORY.md`
|
||||
index). Fact `type` classifies content; independent `scope` controls whether it
|
||||
is project-only (the default) or explicitly global. The index loads into the
|
||||
stable prefix on the next session; global user/feedback bodies also load as
|
||||
lower-priority compatibility guidance. The current turn receives a tail note.
|
||||
|
||||
## Notes
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"time"
|
||||
|
||||
"reasonix/internal/config"
|
||||
"reasonix/internal/netclient"
|
||||
"reasonix/internal/provider"
|
||||
_ "reasonix/internal/provider/anthropic"
|
||||
_ "reasonix/internal/provider/openai"
|
||||
@@ -49,9 +50,11 @@ func runAssist(args []string) int {
|
||||
}
|
||||
if *jsonOut {
|
||||
if code := printJSON(struct {
|
||||
Plan repair.RepairPlan `json:"plan"`
|
||||
Preview []repair.RepairPlanPreview `json:"preview"`
|
||||
}{plan, preview}); code != 0 {
|
||||
Plan repair.RepairPlan `json:"plan"`
|
||||
PlanID string `json:"planId"`
|
||||
Preview []repair.RepairPlanPreview `json:"preview"`
|
||||
PreviewID string `json:"previewId"`
|
||||
}{plan, repair.RepairPlanID(plan), preview, repair.RepairPlanPreviewID(plan, preview)}); code != 0 {
|
||||
return code
|
||||
}
|
||||
} else {
|
||||
@@ -64,6 +67,7 @@ func runAssist(args []string) int {
|
||||
fmt.Println("repair plan not applied")
|
||||
return 0
|
||||
}
|
||||
opts.ExpectedPreviewID = repair.RepairPlanPreviewID(plan, preview)
|
||||
result, err := repair.ApplyRepairPlan(plan, opts)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "error:", err)
|
||||
@@ -84,6 +88,7 @@ func runApplyPlan(args []string) int {
|
||||
file := fs.String("file", "", "RepairPlan JSON file")
|
||||
yes := fs.Bool("yes", false, "confirm plan application non-interactively")
|
||||
allowProject := fs.Bool("allow-project", false, "allow project reasonix.toml repair")
|
||||
previewID := fs.String("preview-id", "", "expected preview ID from a prior dry-run")
|
||||
jsonOut := fs.Bool("json", false, "print JSON")
|
||||
if err := fs.Parse(args); err != nil || fs.NArg() != 0 || strings.TrimSpace(*file) == "" {
|
||||
return 2
|
||||
@@ -107,10 +112,21 @@ func runApplyPlan(args []string) int {
|
||||
if !*jsonOut {
|
||||
printPlanPreview(plan, preview)
|
||||
}
|
||||
actualPreviewID := repair.RepairPlanPreviewID(plan, preview)
|
||||
expectedPreviewID := strings.TrimSpace(*previewID)
|
||||
if *yes && expectedPreviewID == "" {
|
||||
fmt.Fprintln(os.Stderr, "error: --preview-id is required with --yes; preview the plan and confirm its current previewId first")
|
||||
return 2
|
||||
}
|
||||
if expectedPreviewID != "" && expectedPreviewID != actualPreviewID {
|
||||
fmt.Fprintf(os.Stderr, "error: repair plan preview changed since confirmation; re-preview and re-confirm (expected %s, got %s)\n", expectedPreviewID, actualPreviewID)
|
||||
return 1
|
||||
}
|
||||
if !*yes && !confirmPlan() {
|
||||
fmt.Println("repair plan not applied")
|
||||
return 0
|
||||
}
|
||||
opts.ExpectedPreviewID = actualPreviewID
|
||||
result, err := repair.ApplyRepairPlan(plan, opts)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "error:", err)
|
||||
@@ -140,15 +156,7 @@ func requestRepairPlan(ctx context.Context, root, modelRef string, report repair
|
||||
if entry.RequiresAPIKey() && entry.APIKey() == "" {
|
||||
return repair.RepairPlan{}, fmt.Errorf("AI assistance provider %q has no configured API key", entry.Name)
|
||||
}
|
||||
p, err := provider.New(entry.Kind, provider.Config{
|
||||
Name: entry.Name, BaseURL: entry.BaseURL, Model: entry.Model, APIKey: entry.APIKey(),
|
||||
Extra: map[string]any{
|
||||
"api_key_env": entry.APIKeyEnv, "api_key_source": entry.APIKeySourceLabel(),
|
||||
"thinking": entry.Thinking, "effort": entry.Effort, "reasoning_protocol": entry.ReasoningProtocol,
|
||||
"chat_url": entry.ChatURL, "headers": entry.Headers, "extra_body": entry.ExtraBody,
|
||||
"auth_header": entry.AuthHeader, "proxy_spec": cfg.NetworkProxySpec(),
|
||||
},
|
||||
})
|
||||
p, err := provider.New(entry.Kind, repairPlanProviderConfig(entry, cfg.NetworkProxySpec()))
|
||||
if err != nil {
|
||||
return repair.RepairPlan{}, err
|
||||
}
|
||||
@@ -186,6 +194,20 @@ func requestRepairPlan(ctx context.Context, root, modelRef string, report repair
|
||||
return resolveProviderSnapshotAliases(plan, snapshotAliases)
|
||||
}
|
||||
|
||||
func repairPlanProviderConfig(entry *config.ProviderEntry, proxy netclient.ProxySpec) provider.Config {
|
||||
return provider.Config{
|
||||
Name: entry.Name, BaseURL: entry.BaseURL, Model: entry.Model, APIKey: entry.APIKey(),
|
||||
Extra: map[string]any{
|
||||
"api_key_env": entry.APIKeyEnv, "api_key_source": entry.APIKeySourceLabel(),
|
||||
"thinking": entry.Thinking, "effort": config.EffectiveEffort(entry), "supported_efforts": entry.SupportedEfforts,
|
||||
"reasoning_protocol": config.ReasoningProtocolForEntry(entry),
|
||||
"chat_url": entry.ChatURL, "headers": entry.Headers, "extra_body": entry.ExtraBody,
|
||||
"auth_header": entry.AuthHeader, "proxy_spec": proxy,
|
||||
"vision": config.EffectiveVision(entry), "vision_detail": entry.VisionDetail,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func loadAIProviderConfig(root string) (*config.Config, error) {
|
||||
// Recovery tooling must never rewrite user configuration, so skip the
|
||||
// on-disk legacy MCP tier migration that LoadForRoot performs.
|
||||
|
||||
@@ -2,12 +2,39 @@ package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/config"
|
||||
"reasonix/internal/netclient"
|
||||
"reasonix/internal/repair"
|
||||
)
|
||||
|
||||
func TestRepairPlanProviderConfigPreservesKimiK3Efforts(t *testing.T) {
|
||||
entry := &config.ProviderEntry{
|
||||
Name: "opencode-go",
|
||||
Kind: "openai",
|
||||
BaseURL: "https://opencode.ai/zen/go/v1",
|
||||
Model: "kimi-k3",
|
||||
ReasoningProtocol: config.ReasoningProtocolOpenAI,
|
||||
SupportedEfforts: []string{"high", "max"},
|
||||
DefaultEffort: "max",
|
||||
}
|
||||
got := repairPlanProviderConfig(entry, netclient.ProxySpec{})
|
||||
if effort := got.Extra["effort"]; effort != "max" {
|
||||
t.Fatalf("effort = %#v, want max", effort)
|
||||
}
|
||||
if efforts, ok := got.Extra["supported_efforts"].([]string); !ok || !reflect.DeepEqual(efforts, []string{"high", "max"}) {
|
||||
t.Fatalf("supported_efforts = %#v, want [high max]", got.Extra["supported_efforts"])
|
||||
}
|
||||
if protocol := got.Extra["reasoning_protocol"]; protocol != config.ReasoningProtocolOpenAI {
|
||||
t.Fatalf("reasoning_protocol = %#v, want openai", protocol)
|
||||
}
|
||||
}
|
||||
|
||||
// TestProviderSafeReportDropsUserControlledContent pins the outbound-privacy
|
||||
// contract: the payload sent to the AI provider must carry only allowlisted
|
||||
// codes, severities, and generic scopes — never diagnostic prose, which can
|
||||
@@ -122,6 +149,81 @@ func TestResolveProviderSnapshotAliases(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func writeDerivedStatePlan(t *testing.T) (string, repair.RepairPlan) {
|
||||
t.Helper()
|
||||
file := filepath.Join(t.TempDir(), "plan.json")
|
||||
raw := []byte(`{"schemaVersion":1,"summary":"rebuild tabs","actions":[{"type":"rebuild_derived_state","target":"tabs","reason":"malformed"}]}`)
|
||||
if err := os.WriteFile(file, raw, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
plan, err := repair.DecodeRepairPlan(raw)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return file, plan
|
||||
}
|
||||
|
||||
func TestApplyPlanYesRequiresConfirmedPreviewID(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
t.Setenv("REASONIX_HOME", home)
|
||||
tabs := filepath.Join(home, "desktop-tabs.json")
|
||||
if err := os.WriteFile(tabs, []byte("bad-tabs"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
file, _ := writeDerivedStatePlan(t)
|
||||
if got := runApplyPlan([]string{"--file", file, "--yes"}); got != 2 {
|
||||
t.Fatalf("exit code = %d, want missing preview ID usage refusal", got)
|
||||
}
|
||||
if got, err := os.ReadFile(tabs); err != nil || string(got) != "bad-tabs" {
|
||||
t.Fatalf("unbound --yes touched derived state: %q, %v", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyPlanRejectsStateChangedAfterPreview(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
t.Setenv("REASONIX_HOME", home)
|
||||
tabs := filepath.Join(home, "desktop-tabs.json")
|
||||
if err := os.WriteFile(tabs, []byte("first-state"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
file, plan := writeDerivedStatePlan(t)
|
||||
preview, err := repair.PreviewRepairPlan(plan, repair.ApplyPlanOptions{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
previewID := repair.RepairPlanPreviewID(plan, preview)
|
||||
if err := os.WriteFile(tabs, []byte("changed-after-preview"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := runApplyPlan([]string{"--file", file, "--preview-id", previewID, "--yes"}); got != 1 {
|
||||
t.Fatalf("exit code = %d, want stale preview refusal", got)
|
||||
}
|
||||
if got, err := os.ReadFile(tabs); err != nil || string(got) != "changed-after-preview" {
|
||||
t.Fatalf("stale preview touched derived state: %q, %v", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyPlanAcceptsCurrentConfirmedPreviewID(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
t.Setenv("REASONIX_HOME", home)
|
||||
tabs := filepath.Join(home, "desktop-tabs.json")
|
||||
if err := os.WriteFile(tabs, []byte("bad-tabs"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
file, plan := writeDerivedStatePlan(t)
|
||||
preview, err := repair.PreviewRepairPlan(plan, repair.ApplyPlanOptions{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
previewID := repair.RepairPlanPreviewID(plan, preview)
|
||||
if got := runApplyPlan([]string{"--file", file, "--preview-id", previewID, "--yes"}); got != 0 {
|
||||
t.Fatalf("exit code = %d, want confirmed preview application", got)
|
||||
}
|
||||
if _, err := os.Stat(tabs); !os.IsNotExist(err) {
|
||||
t.Fatalf("confirmed repair did not quarantine derived state: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProviderSafeScopeClosedVocabulary(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"": "",
|
||||
|
||||
+16
-1
@@ -3,8 +3,11 @@ package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"runtime/debug"
|
||||
|
||||
"reasonix/internal/cli"
|
||||
"reasonix/internal/config"
|
||||
"reasonix/internal/crashreport"
|
||||
|
||||
// Blank imports wire compile-time built-ins into their registries.
|
||||
_ "reasonix/internal/provider/anthropic"
|
||||
@@ -15,6 +18,18 @@ import (
|
||||
// version is injected at build time via -ldflags "-X main.version=...".
|
||||
var version = "dev"
|
||||
|
||||
var runCLI = cli.Run
|
||||
|
||||
func main() {
|
||||
os.Exit(cli.Run(os.Args[1:], version))
|
||||
os.Exit(runWithCrashCapture(os.Args[1:], version))
|
||||
}
|
||||
|
||||
func runWithCrashCapture(args []string, buildVersion string) (exitCode int) {
|
||||
defer func() {
|
||||
if recovered := recover(); recovered != nil {
|
||||
_ = crashreport.CapturePanic(config.ReasonixHomeDir(), buildVersion, recovered, debug.Stack())
|
||||
panic(recovered)
|
||||
}
|
||||
}()
|
||||
return runCLI(args, buildVersion)
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/config"
|
||||
"reasonix/internal/crashreport"
|
||||
)
|
||||
|
||||
func TestRunWithCrashCaptureRecordsAndReraises(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
t.Setenv("REASONIX_HOME", home)
|
||||
previous := runCLI
|
||||
t.Cleanup(func() { runCLI = previous })
|
||||
secret := "private prompt from panic"
|
||||
runCLI = func([]string, string) int { panic(secret) }
|
||||
|
||||
func() {
|
||||
defer func() {
|
||||
if recovered := recover(); recovered != secret {
|
||||
t.Fatalf("reraised panic = %#v", recovered)
|
||||
}
|
||||
}()
|
||||
runWithCrashCapture([]string{"run"}, "v1.20.0")
|
||||
}()
|
||||
|
||||
reports, err := crashreport.List(config.ReasonixHomeDir())
|
||||
if err != nil || len(reports) != 1 {
|
||||
t.Fatalf("captured reports=%d err=%v", len(reports), err)
|
||||
}
|
||||
if got := reports[0].Report.TopFrame; !strings.Contains(got, "main_test.go:") {
|
||||
t.Fatalf("top frame = %q, want panic call site in main_test.go", got)
|
||||
}
|
||||
preview, err := crashreport.Preview(reports[0].Report)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(string(preview), secret) {
|
||||
t.Fatalf("panic value leaked into report: %s", preview)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunWithCrashCapturePassesThroughExitCode(t *testing.T) {
|
||||
previous := runCLI
|
||||
t.Cleanup(func() { runCLI = previous })
|
||||
runCLI = func(args []string, version string) int {
|
||||
if len(args) != 1 || args[0] != "version" || version != "v1.20.0" {
|
||||
t.Fatalf("runCLI args=%v version=%q", args, version)
|
||||
}
|
||||
return 17
|
||||
}
|
||||
if got := runWithCrashCapture([]string{"version"}, "v1.20.0"); got != 17 {
|
||||
t.Fatalf("exit code=%d", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,389 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
const (
|
||||
contractPath = ".signpath/contracts/release-signing.yml"
|
||||
projectSlug = "DeepSeek-Reasonix"
|
||||
policySlug = "release-signing"
|
||||
repository = "https://github.com/esengine/DeepSeek-Reasonix.git"
|
||||
)
|
||||
|
||||
var expectedBranches = []string{"main-v2"}
|
||||
|
||||
type releaseSigningContract struct {
|
||||
Version int `yaml:"version"`
|
||||
ProjectSlug string `yaml:"project_slug"`
|
||||
SigningPolicySlug string `yaml:"signing_policy_slug"`
|
||||
RepositoryURL string `yaml:"repository_url"`
|
||||
AllowedBranchNames []string `yaml:"allowed_branch_names"`
|
||||
AllowedBuildDefinitions []string `yaml:"allowed_build_definitions"`
|
||||
FingerprintFiles []string `yaml:"fingerprint_files"`
|
||||
}
|
||||
|
||||
type workflowInfo struct {
|
||||
externallyTriggered bool
|
||||
directSigning bool
|
||||
calls []string
|
||||
}
|
||||
|
||||
func main() {
|
||||
if len(os.Args) < 2 || len(os.Args) > 3 {
|
||||
fatalf("usage: signpath-contract <validate|fingerprint> [repository-root]")
|
||||
}
|
||||
root := "."
|
||||
if len(os.Args) == 3 {
|
||||
root = os.Args[2]
|
||||
}
|
||||
contract, err := loadAndValidate(root)
|
||||
if err != nil {
|
||||
fatalf("%v", err)
|
||||
}
|
||||
|
||||
switch os.Args[1] {
|
||||
case "validate":
|
||||
fmt.Println("SignPath release signing contract is valid")
|
||||
case "fingerprint":
|
||||
fingerprint, err := contractFingerprint(root, contract)
|
||||
if err != nil {
|
||||
fatalf("fingerprint contract: %v", err)
|
||||
}
|
||||
fmt.Printf("v1:%x\n", fingerprint)
|
||||
default:
|
||||
fatalf("unknown command %q", os.Args[1])
|
||||
}
|
||||
}
|
||||
|
||||
func fatalf(format string, args ...any) {
|
||||
fmt.Fprintf(os.Stderr, "signpath-contract: "+format+"\n", args...)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
func loadAndValidate(root string) (releaseSigningContract, error) {
|
||||
data, err := os.ReadFile(filepath.Join(root, filepath.FromSlash(contractPath)))
|
||||
if err != nil {
|
||||
return releaseSigningContract{}, fmt.Errorf("read %s: %w", contractPath, err)
|
||||
}
|
||||
var contract releaseSigningContract
|
||||
if err := yaml.Unmarshal(data, &contract); err != nil {
|
||||
return releaseSigningContract{}, fmt.Errorf("parse %s: %w", contractPath, err)
|
||||
}
|
||||
if err := validateContract(root, contract); err != nil {
|
||||
return releaseSigningContract{}, err
|
||||
}
|
||||
return contract, nil
|
||||
}
|
||||
|
||||
func validateContract(root string, contract releaseSigningContract) error {
|
||||
if contract.Version != 1 {
|
||||
return fmt.Errorf("%s version is %d, want 1", contractPath, contract.Version)
|
||||
}
|
||||
if contract.ProjectSlug != projectSlug {
|
||||
return fmt.Errorf("project_slug is %q, want %q", contract.ProjectSlug, projectSlug)
|
||||
}
|
||||
if contract.SigningPolicySlug != policySlug {
|
||||
return fmt.Errorf("signing_policy_slug is %q, want %q", contract.SigningPolicySlug, policySlug)
|
||||
}
|
||||
if contract.RepositoryURL != repository {
|
||||
return fmt.Errorf("repository_url is %q, want %q", contract.RepositoryURL, repository)
|
||||
}
|
||||
if err := requireExactSet("allowed_branch_names", contract.AllowedBranchNames, expectedBranches); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(contract.AllowedBuildDefinitions) == 0 {
|
||||
return errors.New("allowed_build_definitions must not be empty")
|
||||
}
|
||||
for _, value := range append(append([]string{}, contract.AllowedBranchNames...), contract.AllowedBuildDefinitions...) {
|
||||
if strings.ContainsAny(value, "*?[") {
|
||||
return fmt.Errorf("SignPath allowlists must not contain wildcard %q", value)
|
||||
}
|
||||
}
|
||||
for _, name := range contract.AllowedBuildDefinitions {
|
||||
if err := validateRepositoryPath(name); err != nil {
|
||||
return fmt.Errorf("invalid allowed build definition %q: %w", name, err)
|
||||
}
|
||||
if !strings.HasPrefix(name, ".github/workflows/") {
|
||||
return fmt.Errorf("allowed build definition %q is outside .github/workflows", name)
|
||||
}
|
||||
}
|
||||
if err := requireUnique("allowed_build_definitions", contract.AllowedBuildDefinitions); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(contract.FingerprintFiles) == 0 {
|
||||
return errors.New("fingerprint_files must not be empty")
|
||||
}
|
||||
seen := make(map[string]bool, len(contract.FingerprintFiles))
|
||||
for _, name := range contract.FingerprintFiles {
|
||||
if err := validateRepositoryPath(name); err != nil {
|
||||
return fmt.Errorf("invalid fingerprint file %q: %w", name, err)
|
||||
}
|
||||
if seen[name] {
|
||||
return fmt.Errorf("duplicate fingerprint file %q", name)
|
||||
}
|
||||
seen[name] = true
|
||||
info, err := os.Stat(filepath.Join(root, filepath.FromSlash(name)))
|
||||
if err != nil {
|
||||
return fmt.Errorf("fingerprint file %q: %w", name, err)
|
||||
}
|
||||
if !info.Mode().IsRegular() {
|
||||
return fmt.Errorf("fingerprint file %q is not a regular file", name)
|
||||
}
|
||||
}
|
||||
|
||||
reachable, err := discoverTopLevelSigningWorkflows(root)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := requireExactSet("top-level workflows that reach SignPath", reachable, contract.AllowedBuildDefinitions); err != nil {
|
||||
return fmt.Errorf("SignPath build-definition drift: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func requireExactSet(name string, got, want []string) error {
|
||||
gotCopy := append([]string(nil), got...)
|
||||
wantCopy := append([]string(nil), want...)
|
||||
sort.Strings(gotCopy)
|
||||
sort.Strings(wantCopy)
|
||||
if len(gotCopy) != len(wantCopy) {
|
||||
return fmt.Errorf("%s is %v, want %v", name, got, want)
|
||||
}
|
||||
for i := range gotCopy {
|
||||
if gotCopy[i] != wantCopy[i] {
|
||||
return fmt.Errorf("%s is %v, want %v", name, got, want)
|
||||
}
|
||||
if i > 0 && gotCopy[i] == gotCopy[i-1] {
|
||||
return fmt.Errorf("%s contains duplicate %q", name, gotCopy[i])
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func requireUnique(name string, values []string) error {
|
||||
seen := make(map[string]bool, len(values))
|
||||
for _, value := range values {
|
||||
if seen[value] {
|
||||
return fmt.Errorf("%s contains duplicate %q", name, value)
|
||||
}
|
||||
seen[value] = true
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateRepositoryPath(name string) error {
|
||||
if name == "" || filepath.IsAbs(filepath.FromSlash(name)) {
|
||||
return errors.New("path must be non-empty and repository-relative")
|
||||
}
|
||||
clean := filepath.Clean(filepath.FromSlash(name))
|
||||
if clean == "." || clean == ".." || strings.HasPrefix(clean, ".."+string(filepath.Separator)) {
|
||||
return errors.New("path escapes the repository")
|
||||
}
|
||||
if filepath.ToSlash(clean) != name {
|
||||
return errors.New("path must be normalized with forward slashes")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func discoverTopLevelSigningWorkflows(root string) ([]string, error) {
|
||||
paths, err := filepath.Glob(filepath.Join(root, ".github", "workflows", "*.y*ml"))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list workflows: %w", err)
|
||||
}
|
||||
workflows := make(map[string]workflowInfo, len(paths))
|
||||
for _, path := range paths {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read workflow %s: %w", path, err)
|
||||
}
|
||||
name, err := filepath.Rel(root, path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
name = filepath.ToSlash(name)
|
||||
info, err := parseWorkflow(data)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse workflow %s: %w", name, err)
|
||||
}
|
||||
workflows[name] = info
|
||||
}
|
||||
|
||||
state := make(map[string]uint8, len(workflows))
|
||||
memo := make(map[string]bool, len(workflows))
|
||||
var reachesSigning func(string) (bool, error)
|
||||
reachesSigning = func(name string) (bool, error) {
|
||||
if state[name] == 2 {
|
||||
return memo[name], nil
|
||||
}
|
||||
if state[name] == 1 {
|
||||
return false, fmt.Errorf("reusable workflow cycle includes %s", name)
|
||||
}
|
||||
info, ok := workflows[name]
|
||||
if !ok {
|
||||
return false, fmt.Errorf("workflow calls missing local workflow %s", name)
|
||||
}
|
||||
state[name] = 1
|
||||
reachable := info.directSigning
|
||||
for _, called := range info.calls {
|
||||
calledReachable, err := reachesSigning(called)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
reachable = reachable || calledReachable
|
||||
}
|
||||
state[name] = 2
|
||||
memo[name] = reachable
|
||||
return reachable, nil
|
||||
}
|
||||
|
||||
var result []string
|
||||
for name, info := range workflows {
|
||||
if !info.externallyTriggered {
|
||||
continue
|
||||
}
|
||||
reachable, err := reachesSigning(name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if reachable {
|
||||
result = append(result, name)
|
||||
}
|
||||
}
|
||||
sort.Strings(result)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func parseWorkflow(data []byte) (workflowInfo, error) {
|
||||
var document yaml.Node
|
||||
if err := yaml.Unmarshal(data, &document); err != nil {
|
||||
return workflowInfo{}, err
|
||||
}
|
||||
if len(document.Content) != 1 || document.Content[0].Kind != yaml.MappingNode {
|
||||
return workflowInfo{}, errors.New("workflow root must be a mapping")
|
||||
}
|
||||
root := document.Content[0]
|
||||
on := mappingValue(root, "on")
|
||||
jobs := mappingValue(root, "jobs")
|
||||
if on == nil || jobs == nil || jobs.Kind != yaml.MappingNode {
|
||||
return workflowInfo{}, errors.New("workflow must contain on and jobs mappings")
|
||||
}
|
||||
|
||||
info := workflowInfo{externallyTriggered: hasExternalTrigger(on)}
|
||||
for i := 1; i < len(jobs.Content); i += 2 {
|
||||
job := jobs.Content[i]
|
||||
if job.Kind != yaml.MappingNode {
|
||||
continue
|
||||
}
|
||||
if uses := mappingScalar(job, "uses"); strings.HasPrefix(uses, "./.github/workflows/") {
|
||||
info.calls = append(info.calls, strings.TrimPrefix(uses, "./"))
|
||||
}
|
||||
if nodeContains(job, "secrets.SIGNPATH_API_TOKEN") {
|
||||
info.directSigning = true
|
||||
}
|
||||
steps := mappingValue(job, "steps")
|
||||
if steps == nil || steps.Kind != yaml.SequenceNode {
|
||||
continue
|
||||
}
|
||||
for _, step := range steps.Content {
|
||||
if step.Kind != yaml.MappingNode {
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(mappingScalar(step, "uses"), "signpath/github-action-submit-signing-request@") {
|
||||
info.directSigning = true
|
||||
}
|
||||
}
|
||||
}
|
||||
sort.Strings(info.calls)
|
||||
return info, nil
|
||||
}
|
||||
|
||||
func nodeContains(node *yaml.Node, text string) bool {
|
||||
if node == nil {
|
||||
return false
|
||||
}
|
||||
if node.Kind == yaml.ScalarNode && strings.Contains(node.Value, text) {
|
||||
return true
|
||||
}
|
||||
for _, child := range node.Content {
|
||||
if nodeContains(child, text) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func hasExternalTrigger(node *yaml.Node) bool {
|
||||
switch node.Kind {
|
||||
case yaml.ScalarNode:
|
||||
return node.Value != "workflow_call"
|
||||
case yaml.SequenceNode:
|
||||
for _, child := range node.Content {
|
||||
if child.Value != "workflow_call" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
case yaml.MappingNode:
|
||||
for i := 0; i < len(node.Content); i += 2 {
|
||||
if node.Content[i].Value != "workflow_call" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func mappingValue(node *yaml.Node, key string) *yaml.Node {
|
||||
if node == nil || node.Kind != yaml.MappingNode {
|
||||
return nil
|
||||
}
|
||||
for i := 0; i < len(node.Content); i += 2 {
|
||||
if node.Content[i].Value == key {
|
||||
return node.Content[i+1]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func mappingScalar(node *yaml.Node, key string) string {
|
||||
value := mappingValue(node, key)
|
||||
if value == nil || value.Kind != yaml.ScalarNode {
|
||||
return ""
|
||||
}
|
||||
return value.Value
|
||||
}
|
||||
|
||||
func contractFingerprint(root string, contract releaseSigningContract) ([sha256.Size]byte, error) {
|
||||
names := append([]string{contractPath}, contract.FingerprintFiles...)
|
||||
sort.Strings(names)
|
||||
hash := sha256.New()
|
||||
_, _ = io.WriteString(hash, "reasonix-signpath-release-contract-v1\x00")
|
||||
for _, name := range names {
|
||||
data, err := os.ReadFile(filepath.Join(root, filepath.FromSlash(name)))
|
||||
if err != nil {
|
||||
return [sha256.Size]byte{}, fmt.Errorf("read %s: %w", name, err)
|
||||
}
|
||||
writeLengthPrefixed(hash, []byte(name))
|
||||
writeLengthPrefixed(hash, data)
|
||||
}
|
||||
var result [sha256.Size]byte
|
||||
copy(result[:], hash.Sum(nil))
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func writeLengthPrefixed(writer io.Writer, data []byte) {
|
||||
var length [8]byte
|
||||
binary.BigEndian.PutUint64(length[:], uint64(len(data)))
|
||||
_, _ = writer.Write(length[:])
|
||||
_, _ = writer.Write(data)
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRepositoryReleaseSigningContract(t *testing.T) {
|
||||
root := "../.."
|
||||
contract, err := loadAndValidate(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fingerprint, err := contractFingerprint(root, contract)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := fmt.Sprintf("%x", fingerprint); len(got) != 64 {
|
||||
t.Fatalf("fingerprint length = %d, want 64", len(got))
|
||||
}
|
||||
}
|
||||
|
||||
func TestTopLevelSignPathWorkflowCallGraph(t *testing.T) {
|
||||
got, err := discoverTopLevelSigningWorkflows("../..")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := []string{
|
||||
".github/workflows/release-desktop.yml",
|
||||
".github/workflows/release-stable.yml",
|
||||
}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("top-level workflows that reach SignPath = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReleaseSigningContractRejectsWildcards(t *testing.T) {
|
||||
contract, err := loadAndValidate("../..")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
contract.AllowedBuildDefinitions = []string{".github/workflows/release-*.yml"}
|
||||
if err := validateContract("../..", contract); err == nil {
|
||||
t.Fatal("wildcard build definition unexpectedly passed validation")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorkflowUsingSignPathTokenIsSigningEntryPoint(t *testing.T) {
|
||||
workflow := []byte(`
|
||||
on: workflow_dispatch
|
||||
jobs:
|
||||
sign:
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- shell: pwsh
|
||||
env:
|
||||
SIGNPATH_API_TOKEN: ${{ secrets.SIGNPATH_API_TOKEN }}
|
||||
run: ./submit-signing-request.ps1
|
||||
`)
|
||||
info, err := parseWorkflow(workflow)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !info.externallyTriggered || !info.directSigning {
|
||||
t.Fatalf("token-backed workflow was not classified as a signing entry point: %+v", info)
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,7 @@
|
||||
/build/linux/*
|
||||
!/build/linux/nfpm.yaml
|
||||
!/build/linux/reasonix.desktop
|
||||
!/build/linux/io.reasonix.desktop.update.policy
|
||||
!/build/linux/icons
|
||||
!/build/linux/icons/**
|
||||
|
||||
|
||||
+23
-9
@@ -137,9 +137,19 @@ The gateway resolves only the desktop `desktop-v*` release line and never uses
|
||||
GitHub's repository-wide `/releases/latest` shortcut, so updater behavior does
|
||||
not depend on homepage badge semantics. Self-update behavior by platform:
|
||||
|
||||
- **Linux / Windows** — download, verify the minisign signature, then update in
|
||||
place: Linux replaces the binary and relaunches; Windows runs the per-user NSIS
|
||||
installer (no admin rights needed).
|
||||
- **Linux portable (`.tar.gz`)** — download, verify the minisign signature, replace
|
||||
the binaries in the install directory, and relaunch through Guard. No elevation.
|
||||
- **Linux Debian/Ubuntu (`.deb`)** — download the signed `.deb`, request administrator
|
||||
authorization via Polkit (`pkexec`), re-verify and install with `apt-get
|
||||
--only-upgrade`, then relaunch through Guard. The first build that ships the
|
||||
update helper and Polkit policy is a one-time bootstrap: existing `.deb` users
|
||||
should overwrite-install once with
|
||||
`sudo apt install ./Reasonix-linux-amd64.deb` (no uninstall required). After
|
||||
that, in-app authorized updates work. If Polkit/`pkexec` is unavailable, use
|
||||
the same manual command. Failed installs leave the running app intact so you
|
||||
can retry; successful installs are managed by apt/dpkg and are not auto-downgraded.
|
||||
- **Windows** — download, verify the minisign signature, then run the per-user
|
||||
NSIS installer (no admin rights needed).
|
||||
- **macOS** — *not* self-updating yet. The build is unsigned/un-notarized, so an
|
||||
in-place swap would be blocked by Gatekeeper; the banner links to the download
|
||||
page for a manual update instead.
|
||||
@@ -272,16 +282,20 @@ desktop/
|
||||
|
||||
The desktop app sends one anonymous ping per launch to `crash.reasonix.io`:
|
||||
a random install id (generated locally, tied to nothing), app version, OS,
|
||||
arch, and OS version. It exists solely to count active installs. It never
|
||||
includes conversations, API keys, file contents, or paths.
|
||||
arch, and OS version. When the previous process ended abnormally, the next
|
||||
normal launch may also send a bounded native diagnostic (lifecycle phase,
|
||||
symbolized stack, WebView2/window failure kind, and coarse device facts).
|
||||
Panic values are removed and paths/secrets are scrubbed before the report is
|
||||
queued. It never includes conversations, API keys, or file contents.
|
||||
|
||||
Opt out any time: Settings > Updates > "Anonymous usage ping", or set
|
||||
`telemetry = false` under `[desktop]` in the global config. Dev builds
|
||||
never ping. Crash and performance-pressure reports are separate and only
|
||||
ever sent when the user clicks "Send report" on the diagnostic UI.
|
||||
never ping or upload queued native diagnostics. Frontend crash and
|
||||
performance-pressure reports remain separate and are sent only when the user
|
||||
clicks "Send report" on the diagnostic UI.
|
||||
|
||||
Aggregate quality metrics are also enabled by default and can be disabled from
|
||||
Settings > Updates > "Share aggregate quality metrics", or by setting
|
||||
`metrics = false` under `[desktop]`. These metrics are anonymous signal/bucket
|
||||
counts and preference buckets; they never include conversations, prompts, keys,
|
||||
paths, base URLs, or file contents.
|
||||
counts, lifecycle/window failure buckets, and preference buckets; they never
|
||||
include conversations, prompts, keys, paths, base URLs, or file contents.
|
||||
+1161
-299
File diff suppressed because it is too large.
Load diff
+323
-47
@@ -28,6 +28,7 @@ import (
|
||||
"reasonix/internal/control"
|
||||
"reasonix/internal/event"
|
||||
"reasonix/internal/evidence"
|
||||
"reasonix/internal/instruction"
|
||||
"reasonix/internal/jobs"
|
||||
"reasonix/internal/mcplaunch"
|
||||
"reasonix/internal/memory"
|
||||
@@ -473,7 +474,9 @@ func TestMetaForTabIncludesWorkspaceContext(t *testing.T) {
|
||||
if strings.Contains(string(raw), "sandboxPath") || strings.Contains(string(raw), configuredSandboxRoot) {
|
||||
t.Fatalf("meta should not expose configured sandbox root as sandboxPath: %s", raw)
|
||||
}
|
||||
deadline := time.Now().Add(time.Second)
|
||||
// The first git process launch can be noticeably slower on Windows runners
|
||||
// while MetaForTab intentionally keeps the caller path non-blocking.
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for {
|
||||
if got = app.MetaForTab("tab-1"); got.GitBranch == "feature/meta" {
|
||||
break
|
||||
@@ -596,18 +599,78 @@ func TestMemoryViewReturnsNonNilArraysBeforeStartup(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
|
||||
view := NewApp().Memory()
|
||||
if view.Docs == nil || view.Facts == nil || view.Archives == nil || view.Scopes == nil {
|
||||
if view.Docs == nil || view.Facts == nil || view.Archives == nil || view.Scopes == nil || view.InstructionDiagnostics == nil || view.Conflicts == nil || view.LastRecall.Hits == nil {
|
||||
t.Fatalf("Memory() arrays must be non-nil before startup: %+v", view)
|
||||
}
|
||||
raw, err := json.Marshal(view)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal Memory(): %v", err)
|
||||
}
|
||||
for _, bad := range []string{`"docs":null`, `"facts":null`, `"archives":null`, `"scopes":null`} {
|
||||
for _, bad := range []string{`"docs":null`, `"facts":null`, `"archives":null`, `"scopes":null`, `"instructionDiagnostics":null`, `"conflicts":null`, `"hits":null`} {
|
||||
if strings.Contains(string(raw), bad) {
|
||||
t.Fatalf("Memory() JSON contains %s; frontend expects []: %s", bad, raw)
|
||||
}
|
||||
}
|
||||
if revisions := NewApp().MemoryRevisions("missing"); revisions == nil {
|
||||
t.Fatal("MemoryRevisions must return [] before startup, not nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMemoryViewIncludesRecallFreshnessAndOverrides(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
root := t.TempDir()
|
||||
store := memory.Store{Dir: filepath.Join(root, "project"), GlobalDir: filepath.Join(root, "global")}
|
||||
if _, err := (memory.Store{Dir: store.GlobalDir}).Save(memory.Memory{
|
||||
Name: "deploy-target", Title: "Deploy target", Description: "legacy deployment target", Scope: memory.FactScopeGlobal, Type: memory.TypeProject, Body: "Deploy payments to the legacy cluster.",
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := (memory.Store{Dir: store.Dir}).Save(memory.Memory{
|
||||
Name: "deploy-target", Title: "Deploy target", Description: "current deployment target", Scope: memory.FactScopeProject, Type: memory.TypeProject, Body: "Deploy payments to the green cluster.",
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ctrl := control.New(control.Options{Memory: &memory.Set{Store: store}})
|
||||
ctrl.Compose("deploy payments target cluster")
|
||||
app := NewApp()
|
||||
app.setTestCtrl(ctrl, "test-model")
|
||||
|
||||
view := app.Memory()
|
||||
if len(view.Facts) != 2 || view.Facts[0].Freshness == "" || view.Facts[1].Freshness == "" {
|
||||
t.Fatalf("facts with freshness = %+v", view.Facts)
|
||||
}
|
||||
if len(view.Conflicts) != 1 || view.Conflicts[0].Resolution != "project_over_global" {
|
||||
t.Fatalf("conflicts = %+v", view.Conflicts)
|
||||
}
|
||||
if view.LastRecall.Query != "deploy payments target cluster" || len(view.LastRecall.Hits) != 1 || view.LastRecall.Hits[0].Scope != "project" {
|
||||
t.Fatalf("last recall = %+v", view.LastRecall)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMemoryRevisionAPIRestoresSelectedRevision(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
store := memory.Store{Dir: t.TempDir()}
|
||||
first, err := store.SaveWithOptions(memory.Memory{Name: "fact", Description: "one", Body: "v1"}, memory.SaveOptions{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := store.SaveWithOptions(memory.Memory{ID: first.Memory.ID, Name: "fact", Description: "two", Body: "v2"}, memory.SaveOptions{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
app := NewApp()
|
||||
app.setTestCtrl(control.New(control.Options{Memory: &memory.Set{Store: store}}), "test-model")
|
||||
|
||||
revisions := app.MemoryRevisions(first.Memory.ID)
|
||||
if len(revisions) != 1 || revisions[0].Revision != 1 {
|
||||
t.Fatalf("revisions = %+v", revisions)
|
||||
}
|
||||
restored, err := app.RestoreMemoryRevision(first.Memory.ID, 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if restored.Revision != 3 || restored.Body != "v1" {
|
||||
t.Fatalf("restored = %+v", restored)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMemoryViewIncludesActiveAndArchivedFacts(t *testing.T) {
|
||||
@@ -638,10 +701,12 @@ func TestMemoryViewIncludesActiveAndArchivedFacts(t *testing.T) {
|
||||
|
||||
app := NewApp()
|
||||
app.setTestCtrl(control.New(control.Options{Memory: &memory.Set{
|
||||
Docs: []memory.Source{{Path: filepath.Join(cwd, "AGENTS.md"), Scope: memory.ScopeProject, Body: "Project instructions"}},
|
||||
Store: store,
|
||||
CWD: cwd,
|
||||
UserDir: userDir,
|
||||
Docs: []memory.Source{{
|
||||
Path: filepath.Join(cwd, "AGENTS.md"), Scope: memory.ScopeProject, Directory: cwd,
|
||||
Body: "Project instructions", Imports: []instruction.Import{{Path: filepath.Join(cwd, "shared.md"), SourcePath: filepath.Join(cwd, "AGENTS.md")}},
|
||||
}},
|
||||
InstructionDiagnostics: []instruction.Diagnostic{{Code: "import_cycle", Path: "shared.md", SourcePath: filepath.Join(cwd, "AGENTS.md"), Line: 3, Message: "cycle"}},
|
||||
Store: store, CWD: cwd, UserDir: userDir,
|
||||
}}), "test-model")
|
||||
|
||||
view := app.Memory()
|
||||
@@ -651,18 +716,57 @@ func TestMemoryViewIncludesActiveAndArchivedFacts(t *testing.T) {
|
||||
if len(view.Docs) != 1 || view.Docs[0].Scope != "project" || !strings.Contains(view.Docs[0].Body, "Project instructions") {
|
||||
t.Fatalf("Memory() docs = %+v", view.Docs)
|
||||
}
|
||||
if len(view.Facts) != 1 || view.Facts[0].Name != "active-fact" || view.Facts[0].Type != "project" {
|
||||
if view.Docs[0].Directory != cwd || len(view.Docs[0].Imports) != 1 || len(view.InstructionDiagnostics) != 1 || view.InstructionDiagnostics[0].Code != "import_cycle" {
|
||||
t.Fatalf("Memory() instruction provenance = docs %+v diagnostics %+v", view.Docs, view.InstructionDiagnostics)
|
||||
}
|
||||
if len(view.Facts) != 1 || view.Facts[0].Name != "active-fact" || view.Facts[0].Type != "project" || view.Facts[0].Scope != "project" {
|
||||
t.Fatalf("Memory() active facts = %+v", view.Facts)
|
||||
}
|
||||
if len(view.Archives) != 1 || view.Archives[0].Name != "archived-fact" || view.Archives[0].Type != "feedback" ||
|
||||
if view.Facts[0].ID == "" || view.Facts[0].Revision != 1 || view.Facts[0].CreatedAt == "" || view.Facts[0].UpdatedAt == "" {
|
||||
t.Fatalf("Memory() active fact metadata = %+v", view.Facts[0])
|
||||
}
|
||||
if len(view.Archives) != 1 || view.Archives[0].Name != "archived-fact" || view.Archives[0].Type != "feedback" || view.Archives[0].Scope != "project" ||
|
||||
view.Archives[0].Path == "" || view.Archives[0].ArchivedAt == "" {
|
||||
t.Fatalf("Memory() archived facts = %+v", view.Archives)
|
||||
}
|
||||
if view.Archives[0].ID == "" || view.Archives[0].Revision != 1 || view.Archives[0].CreatedAt == "" || view.Archives[0].UpdatedAt == "" {
|
||||
t.Fatalf("Memory() archived fact metadata = %+v", view.Archives[0])
|
||||
}
|
||||
if len(view.Scopes) != 3 {
|
||||
t.Fatalf("Memory() scopes = %+v, want user/project/local", view.Scopes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRestoreArchivedMemoryRecoversFactForCurrentSession(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
userDir := t.TempDir()
|
||||
cwd := t.TempDir()
|
||||
store := memory.StoreFor(userDir, cwd)
|
||||
first, err := store.SaveWithOptions(memory.Memory{
|
||||
Name: "restorable-fact", Description: "recover me", Body: "Recovered guidance.",
|
||||
}, memory.SaveOptions{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
archivePath, err := store.Archive(first.Memory.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
app := NewApp()
|
||||
app.setTestCtrl(control.New(control.Options{Memory: &memory.Set{Store: store, CWD: cwd, UserDir: userDir}}), "test-model")
|
||||
if _, err := app.RestoreArchivedMemory(archivePath); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
view := app.Memory()
|
||||
if len(view.Facts) != 1 || view.Facts[0].ID != first.Memory.ID || view.Facts[0].Revision != 2 {
|
||||
t.Fatalf("restored memory view = %+v", view)
|
||||
}
|
||||
if len(view.Archives) != 0 {
|
||||
t.Fatalf("restored archive remained visible: %+v", view.Archives)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBeforeCloseAllowsSystemQuitWhenBackgroundCloseEnabled(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
consumeSystemQuitRequested()
|
||||
@@ -942,6 +1046,9 @@ func TestDesktopStartupSettingsUsesUserDesktopPreferencesWithoutFullSettingsPayl
|
||||
if err := userCfg.SetDesktopCheckUpdates(false); err != nil {
|
||||
t.Fatalf("set desktop check updates: %v", err)
|
||||
}
|
||||
if err := userCfg.SetDesktopUpdateChannel("preview"); err != nil {
|
||||
t.Fatalf("set desktop update channel: %v", err)
|
||||
}
|
||||
userCfg.Bot.Enabled = true
|
||||
userCfg.Bot.Allowlist.Enabled = true
|
||||
userCfg.Bot.Allowlist.QQUsers = []string{"alice"}
|
||||
@@ -950,7 +1057,7 @@ func TestDesktopStartupSettingsUsesUserDesktopPreferencesWithoutFullSettingsPayl
|
||||
}
|
||||
|
||||
got := NewApp().DesktopStartupSettings()
|
||||
if got.DesktopLanguage != "en" || got.DesktopLayoutStyle != "classic" || got.DesktopTheme != "dark" || got.DesktopThemeStyle != "graphite" || got.DisplayMode != "standard" || got.StatusBarStyle != "icon" || got.CheckUpdates {
|
||||
if got.DesktopLanguage != "en" || got.DesktopLayoutStyle != "classic" || got.DesktopTheme != "dark" || got.DesktopThemeStyle != "graphite" || got.DisplayMode != "standard" || got.StatusBarStyle != "icon" || got.CheckUpdates || got.UpdateChannel != "preview" {
|
||||
t.Fatalf("DesktopStartupSettings desktop prefs = %+v, want user-level startup prefs", got)
|
||||
}
|
||||
if want := []string{"workspace", "git_branch", "model"}; !reflect.DeepEqual(got.StatusBarItems, want) {
|
||||
@@ -2844,6 +2951,21 @@ func TestModelsForTabOnlyListsProviderAccessWhenConfigured(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestModelsForTabListsNothingWhenProviderAccessExplicitlyEmpty(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
setDesktopTestCredential(t, "DEEPSEEK_API_KEY", "sk-test")
|
||||
|
||||
cfg := config.Default()
|
||||
cfg.Desktop.ProviderAccess = []string{}
|
||||
if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
|
||||
t.Fatalf("save config: %v", err)
|
||||
}
|
||||
|
||||
if models := NewApp().Models(); len(models) != 0 {
|
||||
t.Fatalf("Models() = %+v, want no models when provider access is explicitly empty", models)
|
||||
}
|
||||
}
|
||||
|
||||
func TestModelsForTabListsCustomMultiModelProviderWithoutMetadata(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
setDesktopTestCredential(t, "LOCAL_API_KEY", "sk-test")
|
||||
@@ -3045,7 +3167,7 @@ func TestSetModelForTabRejectsProviderOutsideAccess(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetModelForTabRefreshesCarriedSystemPrompt(t *testing.T) {
|
||||
func TestSetModelForTabRefreshesCarriedSystemPromptWithoutChangingDefaults(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
setDesktopTestCredential(t, "OLD_MODEL_KEY", "sk-test")
|
||||
setDesktopTestCredential(t, "NEW_MODEL_KEY", "sk-test")
|
||||
@@ -3089,9 +3211,18 @@ func TestSetModelForTabRefreshesCarriedSystemPrompt(t *testing.T) {
|
||||
sink: &tabEventSink{tabID: "tab_a", app: app},
|
||||
disabledMCP: map[string]ServerView{},
|
||||
}
|
||||
app.tabs = map[string]*WorkspaceTab{tab.ID: tab}
|
||||
app.tabOrder = []string{tab.ID}
|
||||
sibling := &WorkspaceTab{
|
||||
ID: "tab_b",
|
||||
Scope: "global",
|
||||
Ready: true,
|
||||
model: "old/old-model",
|
||||
disabledMCP: map[string]ServerView{},
|
||||
}
|
||||
app.tabs = map[string]*WorkspaceTab{tab.ID: tab, sibling.ID: sibling}
|
||||
app.tabOrder = []string{tab.ID, sibling.ID}
|
||||
app.activeTabID = tab.ID
|
||||
var switchTiming modelSwitchTiming
|
||||
app.modelSwitchTimingHook = func(timing modelSwitchTiming) { switchTiming = timing }
|
||||
t.Cleanup(func() {
|
||||
if tab.Ctrl != nil {
|
||||
tab.Ctrl.Close()
|
||||
@@ -3114,6 +3245,18 @@ func TestSetModelForTabRefreshesCarriedSystemPrompt(t *testing.T) {
|
||||
if history[1].Role != provider.RoleUser || history[1].Content != "hello" {
|
||||
t.Fatalf("carried user message changed: %+v", history[1])
|
||||
}
|
||||
if got := config.LoadForEdit(config.UserConfigPath()).DefaultModel; got != "old/old-model" {
|
||||
t.Fatalf("default model after session switch = %q, want old/old-model", got)
|
||||
}
|
||||
if sibling.model != "old/old-model" {
|
||||
t.Fatalf("sibling tab model after session switch = %q, want old/old-model", sibling.model)
|
||||
}
|
||||
if switchTiming.Outcome != "ok" || switchTiming.Total <= 0 {
|
||||
t.Fatalf("model switch timing = %+v, want successful non-zero observation", switchTiming)
|
||||
}
|
||||
if switchTiming.Build <= 0 || switchTiming.LeaseAndResume <= 0 || switchTiming.SwapAndPersist <= 0 {
|
||||
t.Fatalf("model switch stage timing incomplete: %+v", switchTiming)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSetModelForTabRestoresSessionAuthorizations pins the fix for a model
|
||||
@@ -5261,6 +5404,10 @@ func TestSetTokenModeLeaseHeldKeepsCurrentController(t *testing.T) {
|
||||
if got := currentTabTokenMode(tab); got != "full" {
|
||||
t.Fatalf("token mode = %q, want full", got)
|
||||
}
|
||||
meta := app.MetaForTab(tab.ID)
|
||||
if !meta.Ready || meta.Runtime.Phase != sessionRuntimeReady {
|
||||
t.Fatalf("failed switch disabled current runtime: ready=%v phase=%q", meta.Ready, meta.Runtime.Phase)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetTokenModeMigratesStaleOfficialDeepSeekTabModel(t *testing.T) {
|
||||
@@ -7186,7 +7333,7 @@ func TestCapabilitiesIncludesInstalledPlugins(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDesktopSharedHostProjectMCPWaitsForLaunchApproval(t *testing.T) {
|
||||
func TestDesktopSharedHostProjectMCPConnectsWithoutLaunchApproval(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping background MCP boot integration test in short mode")
|
||||
}
|
||||
@@ -7222,15 +7369,16 @@ url = %q
|
||||
defer ctrl.Close()
|
||||
|
||||
deadline := time.Now().Add(3 * time.Second)
|
||||
for len(sharedHost.Failures()) == 0 && time.Now().Before(deadline) {
|
||||
for !sharedHost.HasClient("h") && time.Now().Before(deadline) {
|
||||
time.Sleep(25 * time.Millisecond)
|
||||
}
|
||||
if sharedHost.HasClient("h") {
|
||||
t.Fatal("project MCP connected before launch approval")
|
||||
if !sharedHost.HasClient("h") {
|
||||
t.Fatalf("project MCP did not connect automatically; failures=%+v", sharedHost.Failures())
|
||||
}
|
||||
failures := sharedHost.Failures()
|
||||
if len(failures) != 1 || !failures[0].RequiresLaunchApproval || !strings.Contains(failures[0].Error, "until the user authorizes") {
|
||||
t.Fatalf("project MCP failure = %+v", failures)
|
||||
for _, failure := range sharedHost.Failures() {
|
||||
if failure.Name == "h" && failure.RequiresLaunchApproval {
|
||||
t.Fatalf("project MCP unexpectedly requested launch approval: %+v", failure)
|
||||
}
|
||||
}
|
||||
|
||||
app := NewApp()
|
||||
@@ -7248,12 +7396,12 @@ url = %q
|
||||
app.activeTabID = "test"
|
||||
|
||||
view := app.MCPServers()
|
||||
if len(view) != 1 || view[0].Name != "h" || view[0].Status != "failed" || view[0].RuntimeState != "issue" || !view[0].RequiresLaunchApproval {
|
||||
t.Fatalf("MCPServers() = %+v, want project h awaiting launch approval", view)
|
||||
if len(view) != 1 || view[0].Name != "h" || view[0].Status != "connected" || view[0].RuntimeState != "ready" || view[0].RequiresLaunchApproval {
|
||||
t.Fatalf("MCPServers() = %+v, want trusted connected project h", view)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectMCPLaunchApprovalViewOnlyShowsWhileBlocked(t *testing.T) {
|
||||
func TestProjectMCPViewIsTrustedAndKeepsProjectSource(t *testing.T) {
|
||||
entry := config.PluginEntry{Name: "project", Source: config.MCPSourceProjectConfig}
|
||||
connected := withPluginConfig(ServerView{Name: entry.Name, Status: "connected"}, entry)
|
||||
if connected.RequiresLaunchApproval {
|
||||
@@ -7262,8 +7410,11 @@ func TestProjectMCPLaunchApprovalViewOnlyShowsWhileBlocked(t *testing.T) {
|
||||
blocked := withPluginConfig(ServerView{
|
||||
Name: entry.Name, Status: "failed", RequiresLaunchApproval: true,
|
||||
}, entry)
|
||||
if !blocked.RequiresLaunchApproval {
|
||||
t.Fatalf("blocked project MCP lost launch approval action: %+v", blocked)
|
||||
if blocked.RequiresLaunchApproval {
|
||||
t.Fatalf("project MCP exposed obsolete launch approval action: %+v", blocked)
|
||||
}
|
||||
if blocked.Source != "project" || blocked.ConfigSource != "reasonix.toml" {
|
||||
t.Fatalf("blocked project MCP source = %q/%q, want project/reasonix.toml", blocked.Source, blocked.ConfigSource)
|
||||
}
|
||||
|
||||
user := withPluginConfig(ServerView{Name: "user", Status: "connected"},
|
||||
@@ -7271,6 +7422,9 @@ func TestProjectMCPLaunchApprovalViewOnlyShowsWhileBlocked(t *testing.T) {
|
||||
if user.RequiresLaunchApproval {
|
||||
t.Fatalf("user-config MCP must not be launch-gate governed: %+v", user)
|
||||
}
|
||||
if user.Source != "user" || user.ConfigSource != "config.toml" {
|
||||
t.Fatalf("user MCP source = %q/%q, want user/config.toml", user.Source, user.ConfigSource)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMCPServersMatchesCapabilitiesServerProjection(t *testing.T) {
|
||||
@@ -8561,6 +8715,71 @@ args = ["serve"]
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoveProjectMCPRevealsAndRegistersGlobalFallback(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
dir := robustTempDir(t)
|
||||
t.Chdir(dir)
|
||||
userCfg := config.LoadForEdit(config.UserConfigPath())
|
||||
userCfg.Plugins = []config.PluginEntry{{Name: "docs", Command: "global-docs"}}
|
||||
if err := userCfg.SaveTo(config.UserConfigPath()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
projectPath := filepath.Join(dir, "reasonix.toml")
|
||||
if err := os.WriteFile(projectPath, []byte(`
|
||||
[[plugins]]
|
||||
name = "docs"
|
||||
command = "project-docs"
|
||||
`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
reg := tool.NewRegistry()
|
||||
var configured []plugin.Spec
|
||||
ctrl := control.New(control.Options{
|
||||
Host: plugin.NewHost(),
|
||||
Registry: reg,
|
||||
WorkspaceRoot: dir,
|
||||
MCPConfigureSpec: func(spec *plugin.Spec) {
|
||||
configured = append(configured, *spec)
|
||||
},
|
||||
})
|
||||
defer ctrl.Close()
|
||||
projectEntry, found, err := desktopEffectiveMCPServer(dir, "docs")
|
||||
if err != nil || !found {
|
||||
t.Fatalf("load project docs: entry=%+v found=%v err=%v", projectEntry, found, err)
|
||||
}
|
||||
if _, err := ctrl.RegisterMCPServerOnDemand(projectEntry); err != nil {
|
||||
t.Fatalf("register project docs: %v", err)
|
||||
}
|
||||
|
||||
app := NewApp()
|
||||
app.setTestCtrl(ctrl, "")
|
||||
app.activeTab().WorkspaceRoot = dir
|
||||
if err := app.RemoveMCPServer("docs"); err != nil {
|
||||
t.Fatalf("RemoveMCPServer(docs): %v", err)
|
||||
}
|
||||
|
||||
projectCfg := config.LoadForEdit(projectPath)
|
||||
if _, found := findPluginEntry(projectCfg.Plugins, "docs"); found {
|
||||
t.Fatalf("project docs still configured after removal: %+v", projectCfg.Plugins)
|
||||
}
|
||||
globalCfg := config.LoadForEdit(config.UserConfigPath())
|
||||
globalEntry, found := findPluginEntry(globalCfg.Plugins, "docs")
|
||||
if !found || globalEntry.Command != "global-docs" {
|
||||
t.Fatalf("global docs fallback = %+v, found=%v", globalEntry, found)
|
||||
}
|
||||
effective, found, err := desktopEffectiveMCPServer(dir, "docs")
|
||||
if err != nil || !found || effective.Source != config.MCPSourceUserConfig || effective.Command != "global-docs" {
|
||||
t.Fatalf("effective docs fallback = %+v, found=%v err=%v", effective, found, err)
|
||||
}
|
||||
if len(configured) < 2 || configured[len(configured)-1].Command != "global-docs" {
|
||||
t.Fatalf("registered specs = %+v, want global fallback registered last", configured)
|
||||
}
|
||||
if _, found := reg.Get("mcp__docs__connect"); !found {
|
||||
t.Fatalf("global fallback connect surface missing; names=%v", reg.Names())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoveMCPServerClearsRecordedStartupFailure(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
dir := robustTempDir(t)
|
||||
@@ -8786,6 +9005,59 @@ func TestUpdateMCPServerEditsProjectMCPJSONEntry(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateMCPServerPreservesProjectTOMLSourceAndGlobalShadow(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
dir := robustTempDir(t)
|
||||
t.Chdir(dir)
|
||||
userCfg := config.LoadForEdit(config.UserConfigPath())
|
||||
userCfg.Plugins = []config.PluginEntry{{Name: "docs", Command: "global-docs"}}
|
||||
if err := userCfg.SaveTo(config.UserConfigPath()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
projectPath := filepath.Join(dir, "reasonix.toml")
|
||||
if err := os.WriteFile(projectPath, []byte(`
|
||||
[[plugins]]
|
||||
name = "docs"
|
||||
command = "project-docs"
|
||||
`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
app := NewApp()
|
||||
app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost(), WorkspaceRoot: dir}), "")
|
||||
defer app.activeCtrl().Close()
|
||||
app.activeTab().WorkspaceRoot = dir
|
||||
entry, ok, err := desktopEffectiveMCPServer(dir, "docs")
|
||||
if err != nil || !ok || entry.Source != config.MCPSourceProjectConfig {
|
||||
t.Fatalf("load project docs entry: entry=%+v found=%v err=%v", entry, ok, err)
|
||||
}
|
||||
if err := config.DefaultMCPActivationStore().SetServerEnabled(entry, dir, false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
app.activeTab().disabledMCP["docs"] = ServerView{}
|
||||
|
||||
if err := app.UpdateMCPServer("docs", MCPServerInput{
|
||||
Name: "docs", Transport: "stdio", Command: "project-docs-updated",
|
||||
}); err != nil {
|
||||
t.Fatalf("UpdateMCPServer(project reasonix.toml docs): %v", err)
|
||||
}
|
||||
|
||||
projectCfg := config.LoadForEdit(projectPath)
|
||||
projectEntry, found := findPluginEntry(projectCfg.Plugins, "docs")
|
||||
if !found || projectEntry.Command != "project-docs-updated" {
|
||||
t.Fatalf("project docs entry = %+v, found=%v", projectEntry, found)
|
||||
}
|
||||
globalCfg := config.LoadForEdit(config.UserConfigPath())
|
||||
globalEntry, found := findPluginEntry(globalCfg.Plugins, "docs")
|
||||
if !found || globalEntry.Command != "global-docs" {
|
||||
t.Fatalf("global shadow changed while editing project entry: %+v, found=%v", globalEntry, found)
|
||||
}
|
||||
effective, found, err := desktopEffectiveMCPServer(dir, "docs")
|
||||
if err != nil || !found || effective.Source != config.MCPSourceProjectConfig || effective.Command != "project-docs-updated" {
|
||||
t.Fatalf("effective docs after edit = %+v, found=%v err=%v", effective, found, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddMCPServerPersistsRemoteHeaders(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
dir := robustTempDir(t)
|
||||
@@ -9186,7 +9458,7 @@ tier = "lazy"
|
||||
t.Fatalf("figma MCP missing from Capabilities: %+v", view.Servers)
|
||||
}
|
||||
|
||||
func TestUpdateMCPServerMigratesLegacyTierToBackground(t *testing.T) {
|
||||
func TestUpdateMCPServerMigratesLegacyTierInProjectSource(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
dir := robustTempDir(t)
|
||||
t.Chdir(dir)
|
||||
@@ -9236,19 +9508,19 @@ tier = "lazy"
|
||||
t.Fatalf("env TOKEN = %q, want preserved env", got)
|
||||
}
|
||||
userCfg := config.LoadForEdit(config.UserConfigPath())
|
||||
userPlugin, ok := findPluginEntry(userCfg.Plugins, "playwright")
|
||||
if !ok {
|
||||
t.Fatalf("playwright should be migrated to user config: %+v", userCfg.Plugins)
|
||||
}
|
||||
if userPlugin.Command != "node" || userPlugin.Env["TOKEN"] != "${PLAYWRIGHT_TOKEN}" {
|
||||
t.Fatalf("user plugin after migration = %+v", userPlugin)
|
||||
}
|
||||
if userPlugin.Tier != "" {
|
||||
t.Fatalf("user plugin tier = %q, want migrated empty", userPlugin.Tier)
|
||||
if _, ok := findPluginEntry(userCfg.Plugins, "playwright"); ok {
|
||||
t.Fatalf("project plugin should not be copied to user config: %+v", userCfg.Plugins)
|
||||
}
|
||||
projectCfg := config.LoadForEdit(filepath.Join(dir, "reasonix.toml"))
|
||||
if _, ok := findPluginEntry(projectCfg.Plugins, "playwright"); ok {
|
||||
t.Fatalf("project plugin should be removed after desktop migration: %+v", projectCfg.Plugins)
|
||||
projectPlugin, ok := findPluginEntry(projectCfg.Plugins, "playwright")
|
||||
if !ok {
|
||||
t.Fatalf("playwright should remain in project config: %+v", projectCfg.Plugins)
|
||||
}
|
||||
if projectPlugin.Command != "node" || projectPlugin.Env["TOKEN"] != "${PLAYWRIGHT_TOKEN}" {
|
||||
t.Fatalf("project plugin after update = %+v", projectPlugin)
|
||||
}
|
||||
if projectPlugin.Tier != "" {
|
||||
t.Fatalf("project plugin tier = %q, want migrated empty", projectPlugin.Tier)
|
||||
}
|
||||
view := app.Capabilities()
|
||||
for _, s := range view.Servers {
|
||||
@@ -9411,7 +9683,7 @@ name = "codegraph"
|
||||
t.Fatalf("codegraph missing after reconnect: %+v", view.Servers)
|
||||
}
|
||||
|
||||
func TestSetMCPServerTierRecordsConnectFailure(t *testing.T) {
|
||||
func TestSetMCPServerTierPreservesProjectSourceAndRecordsConnectFailure(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
dir := robustTempDir(t)
|
||||
t.Chdir(dir)
|
||||
@@ -9443,16 +9715,16 @@ tier = "lazy"
|
||||
t.Fatalf("saved tier = %q, want migrated empty", got)
|
||||
}
|
||||
userCfg := config.LoadForEdit(config.UserConfigPath())
|
||||
userPlugin, ok := findPluginEntry(userCfg.Plugins, "broken")
|
||||
if !ok {
|
||||
t.Fatalf("broken should be migrated to user config: %+v", userCfg.Plugins)
|
||||
}
|
||||
if userPlugin.Tier != "" {
|
||||
t.Fatalf("user plugin tier = %q, want migrated empty", userPlugin.Tier)
|
||||
if _, ok := findPluginEntry(userCfg.Plugins, "broken"); ok {
|
||||
t.Fatalf("project plugin should not be copied to user config: %+v", userCfg.Plugins)
|
||||
}
|
||||
projectCfg := config.LoadForEdit(filepath.Join(dir, "reasonix.toml"))
|
||||
if _, ok := findPluginEntry(projectCfg.Plugins, "broken"); ok {
|
||||
t.Fatalf("project plugin should be removed after desktop migration: %+v", projectCfg.Plugins)
|
||||
projectPlugin, ok := findPluginEntry(projectCfg.Plugins, "broken")
|
||||
if !ok {
|
||||
t.Fatalf("broken should remain in project config: %+v", projectCfg.Plugins)
|
||||
}
|
||||
if projectPlugin.Tier != "" {
|
||||
t.Fatalf("project plugin tier = %q, want migrated empty", projectPlugin.Tier)
|
||||
}
|
||||
if !mcpFailed(app.activeCtrl(), "broken") {
|
||||
t.Fatalf("Host.Failures() = %+v, want broken failure recorded", app.activeCtrl().Host().Failures())
|
||||
@@ -9759,7 +10031,11 @@ func startNonCooperativeSessionJob(t *testing.T, jm *jobs.Manager, sessionPath s
|
||||
|
||||
func waitNotRunning(t *testing.T, ctrl control.SessionAPI) {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(time.Second)
|
||||
// Windows release runners can take more than one second to schedule the
|
||||
// controller's asynchronous completion while the full desktop suite is
|
||||
// active. Keep a bounded responsiveness check without treating scheduler
|
||||
// delay as a leaked controller.
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for ctrl.Running() {
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatal("controller still running")
|
||||
|
||||
+10
-4
@@ -185,7 +185,7 @@ func (h *botBridgeHub) observe(tabID string, e event.Event) {
|
||||
// 桌面端主动停止的任务不推送,避免正常操作变成噪音。
|
||||
return
|
||||
}
|
||||
h.enqueue(h.turnDoneNotification(tabID, e.Err))
|
||||
h.enqueue(h.turnDoneNotification(tabID, e))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -359,15 +359,21 @@ func (h *botBridgeHub) askNotification(tabID string, ask event.Ask) desktopBridg
|
||||
return desktopBridgeNotification{text: textFor, card: card}
|
||||
}
|
||||
|
||||
func (h *botBridgeHub) turnDoneNotification(tabID string, err error) desktopBridgeNotification {
|
||||
func (h *botBridgeHub) turnDoneNotification(tabID string, e event.Event) desktopBridgeNotification {
|
||||
label := h.tabLabel(tabID)
|
||||
if err != nil {
|
||||
if e.Outcome == event.TurnOutcomeRecoveryPaused {
|
||||
return desktopBridgeNotification{text: constText(fmt.Sprintf(
|
||||
"⏸️ 桌面会话「%s」已暂停自动重试。已完成的工作会保留;发送“继续”即可开始新一轮,也可以补充要求调整方向。",
|
||||
label,
|
||||
))}
|
||||
}
|
||||
if e.Err != nil {
|
||||
// Error text can contain paths/tokens; only detail it in a private chat.
|
||||
return desktopBridgeNotification{text: func(route bot.DesktopWatchRoute) string {
|
||||
if isSharedChat(route.ChatType) {
|
||||
return fmt.Sprintf("❌ 桌面会话「%s」任务出错(详情见桌面端或私聊)。", label)
|
||||
}
|
||||
return fmt.Sprintf("❌ 桌面会话「%s」任务出错: %s", label, truncateForBridge(err.Error(), botBridgeErrTextLimit))
|
||||
return fmt.Sprintf("❌ 桌面会话「%s」任务出错: %s", label, truncateForBridge(e.Err.Error(), botBridgeErrTextLimit))
|
||||
}}
|
||||
}
|
||||
return desktopBridgeNotification{text: constText(fmt.Sprintf("✅ 桌面会话「%s」任务完成。", label))}
|
||||
|
||||
@@ -357,6 +357,21 @@ func TestBridgeSuppressesCanceledTurnAndErrorsNotify(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBridgeRecoveryPauseNotifiesAsControlledPause(t *testing.T) {
|
||||
env := newBridgeTestEnv([]TabMeta{{ID: "tab-1", Label: "会话一"}})
|
||||
env.hub.SetWatch(testWatchRoute(), true)
|
||||
|
||||
env.hub.observe("tab-1", event.Event{
|
||||
Kind: event.TurnDone,
|
||||
Err: errors.New("automatic recovery paused"),
|
||||
Outcome: event.TurnOutcomeRecoveryPaused,
|
||||
})
|
||||
call := env.waitNotification(t)
|
||||
if strings.Contains(call.msg.Text, "❌") || !strings.Contains(call.msg.Text, "已暂停自动重试") || !strings.Contains(call.msg.Text, "继续") {
|
||||
t.Fatalf("recovery pause text = %q, want a neutral actionable pause notice", call.msg.Text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBridgeAskAnswerRoundTrip(t *testing.T) {
|
||||
env := newBridgeTestEnv([]TabMeta{{ID: "tab-2", Label: "问答会话"}})
|
||||
env.hub.SetWatch(testWatchRoute(), true)
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE policyconfig PUBLIC
|
||||
"-//freedesktop//DTD PolicyKit Policy Configuration 1.0//EN"
|
||||
"http://www.freedesktop.org/standards/PolicyKit/1/policyconfig.dtd">
|
||||
<!--
|
||||
Polkit policy for Reasonix desktop in-app .deb upgrades.
|
||||
The action only authorizes the fixed root-owned helper with argv1=install.
|
||||
Active sessions require auth_admin every time; authorization is not retained.
|
||||
-->
|
||||
<policyconfig>
|
||||
<vendor>Reasonix</vendor>
|
||||
<vendor_url>https://reasonix.io</vendor_url>
|
||||
|
||||
<action id="io.reasonix.desktop.update">
|
||||
<description gettext-domain="reasonix-desktop">Install a Reasonix desktop update</description>
|
||||
<description xml:lang="zh_CN" gettext-domain="reasonix-desktop">安装 Reasonix 桌面端更新</description>
|
||||
<description xml:lang="zh_TW" gettext-domain="reasonix-desktop">安裝 Reasonix 桌面端更新</description>
|
||||
<message gettext-domain="reasonix-desktop">Authentication is required to install a Reasonix update</message>
|
||||
<message xml:lang="zh_CN" gettext-domain="reasonix-desktop">安装 Reasonix 更新需要管理员授权</message>
|
||||
<message xml:lang="zh_TW" gettext-domain="reasonix-desktop">安裝 Reasonix 更新需要管理員授權</message>
|
||||
<defaults>
|
||||
<allow_any>no</allow_any>
|
||||
<allow_inactive>no</allow_inactive>
|
||||
<allow_active>auth_admin</allow_active>
|
||||
</defaults>
|
||||
<annotate key="org.freedesktop.policykit.exec.path">/usr/lib/reasonix/reasonix-update-helper</annotate>
|
||||
<annotate key="org.freedesktop.policykit.exec.argv1">install</annotate>
|
||||
</action>
|
||||
</policyconfig>
|
||||
@@ -1,12 +1,13 @@
|
||||
# nfpm config for the Linux .deb package. scripts/desktop-build.sh's linux branch
|
||||
# runs `nfpm package` after `wails build`, from the desktop/ dir (so the src paths
|
||||
# below are relative to desktop/). The .deb is a human-download artifact only — like
|
||||
# the macOS .dmg — so the Linux updater channel stays the .tar.gz and cmd/sign's
|
||||
# manifest skips .deb files.
|
||||
# below are relative to desktop/). Portable clients keep using the .tar.gz under
|
||||
# platforms[]; this .deb is the native package channel (native_packages) and also
|
||||
# remains a human-download artifact.
|
||||
#
|
||||
# $DEB_VERSION and $DEB_ARCH are exported by desktop-build.sh. DEB_VERSION is the tag
|
||||
# minus a leading "v" and any prerelease suffix (a strict X.Y.Z that dpkg accepts);
|
||||
# DEB_ARCH is the Go arch name, which already matches Debian's (amd64, arm64).
|
||||
# $DEB_VERSION and $DEB_ARCH are exported by desktop-build.sh. DEB_VERSION follows
|
||||
# Debian version ordering: stable X.Y.Z, prereleases as X.Y.Z~rc.N / X.Y.Z~canary.N
|
||||
# so they sort below the matching stable package. DEB_ARCH is the Go arch name,
|
||||
# which already matches Debian's (amd64, arm64).
|
||||
name: "reasonix-desktop"
|
||||
arch: "${DEB_ARCH}"
|
||||
platform: "linux"
|
||||
@@ -21,9 +22,11 @@ homepage: "https://github.com/esengine/DeepSeek-Reasonix"
|
||||
license: "MIT"
|
||||
# Runtime libraries the WebKitGTK 4.1 build links against (-tags webkit2_41); apt
|
||||
# pulls them in on install. These package names ship from Ubuntu 22.04 / Debian 12 on.
|
||||
# pkexec is required for the in-app Polkit authorization update path.
|
||||
depends:
|
||||
- libgtk-3-0
|
||||
- libwebkit2gtk-4.1-0
|
||||
- pkexec
|
||||
contents:
|
||||
- src: ./build/bin/reasonix-desktop
|
||||
dst: /usr/bin/reasonix-desktop
|
||||
@@ -37,6 +40,14 @@ contents:
|
||||
dst: /usr/bin/reasonix
|
||||
file_info:
|
||||
mode: 0755
|
||||
- src: ./build/bin/reasonix-update-helper
|
||||
dst: /usr/lib/reasonix/reasonix-update-helper
|
||||
file_info:
|
||||
mode: 0755
|
||||
- src: ./build/linux/io.reasonix.desktop.update.policy
|
||||
dst: /usr/share/polkit-1/actions/io.reasonix.desktop.update.policy
|
||||
file_info:
|
||||
mode: 0644
|
||||
- src: ./build/linux/reasonix.desktop
|
||||
dst: /usr/share/applications/reasonix.desktop
|
||||
file_info:
|
||||
|
||||
@@ -83,8 +83,13 @@ LangString reasonixUpdateSubtitle ${LANG_ENGLISH} "Installing the verified updat
|
||||
LangString reasonixUpdateSubtitle ${LANG_SIMPCHINESE} "正在安装已验证的更新,完成后 Reasonix 将自动重启。"
|
||||
LangString reasonixUpdateSubtitle ${LANG_TRADCHINESE} "正在安裝已驗證的更新,完成後 Reasonix 將自動重新啟動。"
|
||||
|
||||
## The following two statements can be used to sign the installer and the uninstaller. The path to the binaries are provided in %1
|
||||
#!uninstfinalize 'signtool --file "%1"'
|
||||
## Preserve the first-pass generated uninstaller so the release workflow can
|
||||
## Authenticode-sign it together with the other installed payload files.
|
||||
## The second pass provides ARG_REASONIX_SIGNED_UNINSTALLER and embeds that
|
||||
## signed binary instead of generating another unsigned uninstaller.
|
||||
!ifndef ARG_REASONIX_SIGNED_UNINSTALLER
|
||||
!uninstfinalize 'cmd.exe /C copy /Y "%1" "reasonix-uninstall.exe" >NUL'
|
||||
!endif
|
||||
#!finalize 'signtool --file "%1"'
|
||||
|
||||
Name "${INFO_PRODUCTNAME}"
|
||||
@@ -95,8 +100,11 @@ OutFile "..\..\bin\${INFO_PROJECTNAME}-${ARCH}-installer.exe" # Name of the inst
|
||||
!define REASONIX_LAUNCHER "reasonix-launcher.exe"
|
||||
!define REASONIX_CLI "reasonix-cli.exe"
|
||||
!define REASONIX_PORTABLE_ENTRY "Reasonix.exe"
|
||||
!define REASONIX_PAYLOAD_MANIFEST "reasonix-payload.json"
|
||||
!define REASONIX_PAYLOAD_SIGNATURE "reasonix-payload.json.minisig"
|
||||
!define REASONIX_UNLOCK_RETRIES 60
|
||||
Var ReasonixUpdateMode
|
||||
Var ReasonixStageMode
|
||||
InstallDirRegKey HKCU "${UNINST_KEY}" "InstallLocation" # Reuse the previous install path on update; .onInit falls back to the default on first install.
|
||||
InstallDir "${REASONIX_DEFAULT_INSTALLDIR}" # Per-user install location (no admin rights required).
|
||||
ShowInstDetails show # This will always show the installation details.
|
||||
@@ -106,7 +114,11 @@ ShowInstDetails show # This will always show the installation details.
|
||||
## wails.deleteUninstaller, which write HKLM and would fail without admin rights.
|
||||
####
|
||||
!macro reasonix.writeUninstaller
|
||||
!ifdef ARG_REASONIX_SIGNED_UNINSTALLER
|
||||
File "/oname=uninstall.exe" "${ARG_REASONIX_SIGNED_UNINSTALLER}"
|
||||
!else
|
||||
WriteUninstaller "$INSTDIR\uninstall.exe"
|
||||
!endif
|
||||
|
||||
WriteRegStr HKCU "${UNINST_KEY}" "Publisher" "${INFO_COMPANYNAME}"
|
||||
WriteRegStr HKCU "${UNINST_KEY}" "DisplayName" "${INFO_PRODUCTNAME}"
|
||||
@@ -140,6 +152,7 @@ Function .onInit
|
||||
; destination, then closes automatically after the file copy so the helper
|
||||
; can relaunch Reasonix. A normal manual installer keeps the full wizard.
|
||||
StrCpy $ReasonixUpdateMode "0"
|
||||
StrCpy $ReasonixStageMode "0"
|
||||
${GetParameters} $R0
|
||||
ClearErrors
|
||||
${GetOptions} $R0 "/REASONIXUPDATE=" $R1
|
||||
@@ -148,6 +161,13 @@ Function .onInit
|
||||
StrCpy $ReasonixUpdateMode "1"
|
||||
|
||||
reasonix_update_mode_done:
|
||||
ClearErrors
|
||||
${GetOptions} $R0 "/REASONIXSTAGE=" $R2
|
||||
IfErrors reasonix_stage_mode_done
|
||||
StrCmp $R2 "1" 0 reasonix_stage_mode_done
|
||||
StrCpy $ReasonixStageMode "1"
|
||||
|
||||
reasonix_stage_mode_done:
|
||||
|
||||
; InstallDirRegKey leaves $INSTDIR empty when the InstallLocation value is
|
||||
; missing. Older installers still wrote DisplayIcon, so use its parent folder
|
||||
@@ -253,12 +273,30 @@ FunctionEnd
|
||||
Section
|
||||
!insertmacro wails.setShellContext
|
||||
|
||||
; Current auto-update helpers extract the signed installer payload into a
|
||||
; unique temporary directory, then compare-and-publish the exact claimed
|
||||
; release unit. Legacy helpers omit /REASONIXSTAGE and retain the historical
|
||||
; in-place update behavior required to upgrade into the hardened release.
|
||||
StrCmp $ReasonixStageMode "1" reasonix_copy_payload
|
||||
!insertmacro wails.webview2runtime
|
||||
|
||||
Call reasonix.waitForExecutableUnlock
|
||||
|
||||
reasonix_copy_payload:
|
||||
SetOutPath $INSTDIR
|
||||
|
||||
; The release workflow embeds a minisign-authenticated manifest after
|
||||
; SignPath has signed the five executable members. Only the extraction
|
||||
; handoff needs these files; a normal installation does not persist them.
|
||||
StrCmp $ReasonixStageMode "1" 0 reasonix_payload_metadata_done
|
||||
!if /FileExists "${REASONIX_PAYLOAD_MANIFEST}"
|
||||
File "/oname=${REASONIX_PAYLOAD_MANIFEST}" "${REASONIX_PAYLOAD_MANIFEST}"
|
||||
!endif
|
||||
!if /FileExists "${REASONIX_PAYLOAD_SIGNATURE}"
|
||||
File "/oname=${REASONIX_PAYLOAD_SIGNATURE}" "${REASONIX_PAYLOAD_SIGNATURE}"
|
||||
!endif
|
||||
reasonix_payload_metadata_done:
|
||||
|
||||
!insertmacro wails.files
|
||||
!if /FileExists "${REASONIX_UPDATE_HELPER}"
|
||||
File "/oname=${REASONIX_UPDATE_HELPER}" "${REASONIX_UPDATE_HELPER}"
|
||||
@@ -270,6 +308,7 @@ Section
|
||||
!endif
|
||||
!if /FileExists "${REASONIX_LAUNCHER}"
|
||||
File "/oname=${REASONIX_LAUNCHER}" "${REASONIX_LAUNCHER}"
|
||||
StrCmp $ReasonixStageMode "1" reasonix_launcher_payload_done
|
||||
; Portable archives expose Reasonix.exe as a second copy of the Guard
|
||||
; launcher. Preserve that layout only when upgrading an existing portable
|
||||
; directory, and keep the alias in lockstep with the canonical launcher.
|
||||
@@ -282,9 +321,12 @@ reasonix_no_portable_entry:
|
||||
; in-place upgrade from a launcher build that had no Windows resources.
|
||||
CreateShortcut "$SMPROGRAMS\${INFO_PRODUCTNAME}.lnk" "$INSTDIR\${REASONIX_LAUNCHER}" "launch --detach" "$INSTDIR\${PRODUCT_EXECUTABLE}" 0
|
||||
CreateShortCut "$DESKTOP\${INFO_PRODUCTNAME}.lnk" "$INSTDIR\${REASONIX_LAUNCHER}" "launch --detach" "$INSTDIR\${PRODUCT_EXECUTABLE}" 0
|
||||
reasonix_launcher_payload_done:
|
||||
!else
|
||||
StrCmp $ReasonixStageMode "1" reasonix_launcher_payload_done_no_launcher
|
||||
CreateShortcut "$SMPROGRAMS\${INFO_PRODUCTNAME}.lnk" "$INSTDIR\${PRODUCT_EXECUTABLE}"
|
||||
CreateShortCut "$DESKTOP\${INFO_PRODUCTNAME}.lnk" "$INSTDIR\${PRODUCT_EXECUTABLE}"
|
||||
reasonix_launcher_payload_done_no_launcher:
|
||||
!endif
|
||||
!if /FileExists "${REASONIX_CLI}"
|
||||
File "/oname=${REASONIX_CLI}" "${REASONIX_CLI}"
|
||||
@@ -292,10 +334,12 @@ reasonix_no_portable_entry:
|
||||
!warning "${REASONIX_CLI} was not found; remote upload installation will be unavailable."
|
||||
!endif
|
||||
|
||||
StrCmp $ReasonixStageMode "1" reasonix_section_done
|
||||
!insertmacro wails.associateFiles
|
||||
!insertmacro wails.associateCustomProtocols
|
||||
|
||||
!insertmacro reasonix.writeUninstaller
|
||||
reasonix_section_done:
|
||||
SectionEnd
|
||||
|
||||
Section "uninstall"
|
||||
|
||||
+90
-16
@@ -14,6 +14,9 @@
|
||||
// size + sha256, and write <dir>/latest.json with GitHub
|
||||
// release download URLs. The R2 mirror step rewrites those
|
||||
// URLs to the CDN afterwards (url + sig fields together).
|
||||
//
|
||||
// windows-payload <dir> <ver> Write a deterministic manifest of the exact
|
||||
// executables embedded in the Windows installer.
|
||||
package main
|
||||
|
||||
import (
|
||||
@@ -37,6 +40,11 @@ import (
|
||||
// generator and the updater agree on update.PlatformKey output.
|
||||
var platforms = []string{"darwin-arm64", "darwin-amd64", "windows-amd64", "windows-arm64", "linux-amd64"}
|
||||
|
||||
var websiteDownloads = map[string]struct{}{
|
||||
"Reasonix-darwin-universal.dmg": {},
|
||||
"Reasonix-windows-amd64.zip": {},
|
||||
}
|
||||
|
||||
func main() {
|
||||
if len(os.Args) < 2 {
|
||||
usage()
|
||||
@@ -50,6 +58,11 @@ func main() {
|
||||
usage()
|
||||
}
|
||||
err = genManifest(os.Args[2], os.Args[3], os.Args[4])
|
||||
case "windows-payload":
|
||||
if len(os.Args) != 4 {
|
||||
usage()
|
||||
}
|
||||
err = genWindowsPayloadManifest(os.Args[2], os.Args[3])
|
||||
case "genkey":
|
||||
if len(os.Args) != 3 {
|
||||
usage()
|
||||
@@ -70,10 +83,34 @@ func main() {
|
||||
}
|
||||
|
||||
func usage() {
|
||||
fmt.Fprintln(os.Stderr, "usage:\n sign <file>...\n manifest <dir> <version> <tag>\n genkey <dir>\n verify <file>")
|
||||
fmt.Fprintln(os.Stderr, "usage:\n sign <file>...\n manifest <dir> <version> <tag>\n windows-payload <dir> <version>\n genkey <dir>\n verify <file>")
|
||||
os.Exit(2)
|
||||
}
|
||||
|
||||
func genWindowsPayloadManifest(dir, version string) error {
|
||||
hashes := make(map[string]string)
|
||||
for _, name := range update.WindowsPayloadFileNames() {
|
||||
path := filepath.Join(dir, name)
|
||||
info, err := os.Lstat(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("Windows payload %s: %w", name, err)
|
||||
}
|
||||
if !info.Mode().IsRegular() {
|
||||
return fmt.Errorf("Windows payload %s is not a regular file", name)
|
||||
}
|
||||
_, sum, err := hashFile(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
hashes[name] = sum
|
||||
}
|
||||
b, err := update.EncodeWindowsPayloadManifest(version, hashes)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(filepath.Join(dir, update.WindowsPayloadManifestName), b, 0o644)
|
||||
}
|
||||
|
||||
// verifyFile checks <file> against <file>.minisig using the embedded public key —
|
||||
// the same check the updater runs before applying. A self-test that the signing
|
||||
// key matches what's compiled in. Returns an error (nonzero exit) on mismatch.
|
||||
@@ -164,15 +201,21 @@ func signFiles(files []string) error {
|
||||
// genManifest scans dir for the per-platform artifacts and writes dir/latest.json.
|
||||
// version is the semver compared by the updater (e.g. "v1.1.0"); tag is the GitHub
|
||||
// release tag used in download URLs (e.g. "desktop-v1.1.0").
|
||||
//
|
||||
// Portable updater channels land in platforms (tarballs/installers). Debian/Ubuntu
|
||||
// .deb packages land only in native_packages so older clients keep resolving the
|
||||
// tarball under platforms["linux-amd64"].
|
||||
func genManifest(dir, version, tag string) error {
|
||||
repo := os.Getenv("GITHUB_REPOSITORY")
|
||||
if repo == "" || repo == "esengine/reasonix" {
|
||||
repo = "esengine/DeepSeek-Reasonix"
|
||||
}
|
||||
m := update.Manifest{
|
||||
Version: version,
|
||||
DownloadPage: "https://reasonix.io/?download=desktop#start",
|
||||
Platforms: map[string]update.Asset{},
|
||||
Version: version,
|
||||
DownloadPage: "https://reasonix.io/?download=desktop#start",
|
||||
Platforms: map[string]update.Asset{},
|
||||
NativePackages: map[string]update.Asset{},
|
||||
Downloads: map[string]update.Asset{},
|
||||
}
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
@@ -183,8 +226,9 @@ func genManifest(dir, version, tag string) error {
|
||||
if e.IsDir() || strings.HasSuffix(name, ".minisig") || name == "latest.json" {
|
||||
continue
|
||||
}
|
||||
key := matchPlatform(name)
|
||||
if key == "" {
|
||||
key, kind := matchArtifact(name)
|
||||
_, websiteDownload := websiteDownloads[name]
|
||||
if key == "" && !websiteDownload {
|
||||
continue
|
||||
}
|
||||
size, sum, err := hashFile(filepath.Join(dir, name))
|
||||
@@ -192,12 +236,31 @@ func genManifest(dir, version, tag string) error {
|
||||
return err
|
||||
}
|
||||
url := fmt.Sprintf("https://github.com/%s/releases/download/%s/%s", repo, tag, name)
|
||||
m.Platforms[key] = update.Asset{URL: url, Sig: url + ".minisig", Size: size, SHA256: sum}
|
||||
fmt.Printf("manifest: %s -> %s (%d bytes)\n", key, name, size)
|
||||
asset := update.Asset{URL: url, Sig: url + ".minisig", Size: size, SHA256: sum}
|
||||
if websiteDownload {
|
||||
m.Downloads[name] = asset
|
||||
fmt.Printf("manifest download: %s (%d bytes)\n", name, size)
|
||||
}
|
||||
if key != "" {
|
||||
switch kind {
|
||||
case artifactNative:
|
||||
m.NativePackages[key] = asset
|
||||
fmt.Printf("manifest native: %s -> %s (%d bytes)\n", key, name, size)
|
||||
default:
|
||||
m.Platforms[key] = asset
|
||||
fmt.Printf("manifest: %s -> %s (%d bytes)\n", key, name, size)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(m.Platforms) == 0 {
|
||||
return fmt.Errorf("manifest: no platform artifacts found in %s", dir)
|
||||
}
|
||||
if len(m.NativePackages) == 0 {
|
||||
m.NativePackages = nil // omit empty map so older tooling sees a clean document
|
||||
}
|
||||
if len(m.Downloads) == 0 {
|
||||
m.Downloads = nil
|
||||
}
|
||||
b, err := json.MarshalIndent(m, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -205,24 +268,35 @@ func genManifest(dir, version, tag string) error {
|
||||
return os.WriteFile(filepath.Join(dir, "latest.json"), append(b, '\n'), 0o644)
|
||||
}
|
||||
|
||||
// matchPlatform returns the platform key embedded in a file name, or "" if none.
|
||||
func matchPlatform(name string) string {
|
||||
// The .deb is a human-download package (like the macOS .dmg); the Linux updater
|
||||
// channel is the .tar.gz. Skip it so it doesn't shadow the tarball's linux-amd64 key.
|
||||
const (
|
||||
artifactPortable = "portable"
|
||||
artifactNative = "native"
|
||||
)
|
||||
|
||||
// matchArtifact returns the platform key and channel kind embedded in a file name,
|
||||
// or ("", "") if the file is not a publishable updater/download artifact.
|
||||
func matchArtifact(name string) (key, kind string) {
|
||||
// .deb is the Linux native package channel. Keep it out of platforms so the
|
||||
// tarball remains the portable linux-amd64 key for older clients.
|
||||
if strings.HasSuffix(name, ".deb") {
|
||||
return ""
|
||||
for _, p := range platforms {
|
||||
if strings.Contains(name, p) {
|
||||
return p, artifactNative
|
||||
}
|
||||
}
|
||||
return "", ""
|
||||
}
|
||||
// The Windows updater channel is the per-arch -installer.exe; the portable .zip
|
||||
// is a human download, so skip it or it would shadow the installer's key.
|
||||
if strings.Contains(name, "windows-") && !strings.HasSuffix(name, "-installer.exe") {
|
||||
return ""
|
||||
return "", ""
|
||||
}
|
||||
for _, p := range platforms {
|
||||
if strings.Contains(name, p) {
|
||||
return p
|
||||
return p, artifactPortable
|
||||
}
|
||||
}
|
||||
return ""
|
||||
return "", ""
|
||||
}
|
||||
|
||||
// hashFile returns the size and lowercase-hex SHA-256 of a file, streaming it so
|
||||
|
||||
@@ -55,6 +55,7 @@ func TestGenManifest(t *testing.T) {
|
||||
names := []string{
|
||||
"Reasonix-darwin-arm64.zip",
|
||||
"Reasonix-darwin-amd64.zip",
|
||||
"Reasonix-darwin-universal.dmg",
|
||||
"Reasonix-windows-amd64-installer.exe",
|
||||
"Reasonix-windows-amd64.zip", // portable download, not the updater channel
|
||||
"Reasonix-windows-arm64-installer.exe",
|
||||
@@ -114,8 +115,8 @@ func TestGenManifest(t *testing.T) {
|
||||
if !strings.HasSuffix(arm.URL, "/Reasonix-windows-arm64-installer.exe") {
|
||||
t.Fatalf("windows-arm64 url = %q, want the installer, not the portable zip", arm.URL)
|
||||
}
|
||||
// The Linux updater channel must stay the .tar.gz; the co-located .deb is a
|
||||
// human download and must not shadow the linux-amd64 key.
|
||||
// The Linux portable channel stays the .tar.gz; the co-located .deb lands
|
||||
// only in native_packages so older clients keep resolving platforms["linux-amd64"].
|
||||
lin, ok := m.Platforms["linux-amd64"]
|
||||
if !ok {
|
||||
t.Fatal("linux-amd64 missing")
|
||||
@@ -123,4 +124,87 @@ func TestGenManifest(t *testing.T) {
|
||||
if !strings.HasSuffix(lin.URL, "/Reasonix-linux-amd64.tar.gz") {
|
||||
t.Fatalf("linux-amd64 url = %q, want the .tar.gz, not the .deb", lin.URL)
|
||||
}
|
||||
if lin.Sig == "" || lin.SHA256 == "" || lin.Size == 0 {
|
||||
t.Fatalf("linux portable asset incomplete: %+v", lin)
|
||||
}
|
||||
deb, ok := m.NativePackages["linux-amd64"]
|
||||
if !ok {
|
||||
t.Fatal("native_packages linux-amd64 missing")
|
||||
}
|
||||
if !strings.HasSuffix(deb.URL, "/Reasonix-linux-amd64.deb") {
|
||||
t.Fatalf("native linux-amd64 url = %q, want the .deb", deb.URL)
|
||||
}
|
||||
if deb.Sig != deb.URL+".minisig" || deb.SHA256 == "" || deb.Size == 0 {
|
||||
t.Fatalf("native linux asset incomplete: %+v", deb)
|
||||
}
|
||||
if len(m.Downloads) != 2 {
|
||||
t.Fatalf("want 2 website downloads, got %d: %+v", len(m.Downloads), m.Downloads)
|
||||
}
|
||||
for _, name := range []string{"Reasonix-darwin-universal.dmg", "Reasonix-windows-amd64.zip"} {
|
||||
asset, ok := m.Downloads[name]
|
||||
if !ok {
|
||||
t.Fatalf("website download %q missing", name)
|
||||
}
|
||||
if !strings.HasSuffix(asset.URL, "/"+name) ||
|
||||
asset.Sig != asset.URL+".minisig" ||
|
||||
asset.SHA256 == "" ||
|
||||
asset.Size == 0 {
|
||||
t.Fatalf("website download %q incomplete: %+v", name, asset)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestGenManifestIgnoresUnknownNativePackages ensures a .deb without a known
|
||||
// platform key is skipped rather than inventing a native_packages entry.
|
||||
func TestGenManifestIgnoresUnknownNativePackages(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
for _, n := range []string{
|
||||
"Reasonix-linux-amd64.tar.gz",
|
||||
"Reasonix-mystery.deb",
|
||||
} {
|
||||
if err := os.WriteFile(filepath.Join(dir, n), []byte(n), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
t.Setenv("GITHUB_REPOSITORY", "esengine/DeepSeek-Reasonix")
|
||||
if err := genManifest(dir, "v1.2.0", "desktop-v1.2.0"); err != nil {
|
||||
t.Fatalf("genManifest: %v", err)
|
||||
}
|
||||
raw, err := os.ReadFile(filepath.Join(dir, "latest.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var m update.Manifest
|
||||
if err := json.Unmarshal(raw, &m); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(m.NativePackages) != 0 {
|
||||
t.Fatalf("unexpected native_packages: %+v", m.NativePackages)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenWindowsPayloadManifestHashesExactReleaseUnit(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
for _, name := range update.WindowsPayloadFileNames() {
|
||||
if err := os.WriteFile(filepath.Join(dir, name), []byte("payload:"+name), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := genWindowsPayloadManifest(dir, "v2.3.4"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b, err := os.ReadFile(filepath.Join(dir, update.WindowsPayloadManifestName))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
hashes, err := update.DecodeWindowsPayloadManifest(b, "v2.3.4")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, name := range update.WindowsPayloadFileNames() {
|
||||
want := update.WindowsPayloadSHA256([]byte("payload:" + name))
|
||||
if hashes[name] != want {
|
||||
t.Fatalf("manifest hash for %s = %q, want %q", name, hashes[name], want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,10 +4,10 @@ import "fmt"
|
||||
|
||||
func installerCommandLine(installer, dir string) string {
|
||||
// Auto-updates use a dedicated visible progress mode instead of NSIS /S.
|
||||
// The installer skips its welcome and directory pages in this mode, keeps
|
||||
// the current install directory fixed, and closes itself after the file copy
|
||||
// completes so this helper can relaunch Reasonix.
|
||||
line := fmt.Sprintf(`"%s" /REASONIXUPDATE=1`, installer)
|
||||
// The current helper also requires staging-only extraction: NSIS never writes
|
||||
// the live install, and the helper compare-and-publishes the claimed release
|
||||
// unit after validating every staged member.
|
||||
line := fmt.Sprintf(`"%s" /REASONIXUPDATE=1 /REASONIXSTAGE=1`, installer)
|
||||
if dir != "" {
|
||||
line += " /D=" + dir
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
|
||||
func TestInstallerCommandLineUsesVisibleUpdateModeAndLeavesDFlagLast(t *testing.T) {
|
||||
got := installerCommandLine(`C:\Temp\Reasonix Installer.exe`, `D:\Tools\Reasonix App`)
|
||||
want := `"C:\Temp\Reasonix Installer.exe" /REASONIXUPDATE=1 /D=D:\Tools\Reasonix App`
|
||||
want := `"C:\Temp\Reasonix Installer.exe" /REASONIXUPDATE=1 /REASONIXSTAGE=1 /D=D:\Tools\Reasonix App`
|
||||
if got != want {
|
||||
t.Fatalf("installerCommandLine = %q, want %q", got, want)
|
||||
}
|
||||
@@ -17,4 +17,7 @@ func TestInstallerCommandLineUsesVisibleUpdateModeAndLeavesDFlagLast(t *testing.
|
||||
if !strings.HasSuffix(got, `/D=D:\Tools\Reasonix App`) {
|
||||
t.Fatalf("/D= must be the final unquoted NSIS token, got %q", got)
|
||||
}
|
||||
if !strings.Contains(got, " /REASONIXSTAGE=1") {
|
||||
t.Fatalf("auto-update must extract away from the live install, got %q", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
//go:build !windows
|
||||
|
||||
package main
|
||||
|
||||
import "os"
|
||||
|
||||
// Windows verifies each executable with the OS Authenticode policy while the
|
||||
// same read handle is held. Non-Windows builds keep the release-unit loader
|
||||
// testable without importing platform-specific trust APIs.
|
||||
var readVerifiedWindowsStagedPayloadFn = os.ReadFile
|
||||
@@ -0,0 +1,97 @@
|
||||
//go:build windows
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"unsafe"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
var readVerifiedWindowsStagedPayloadFn = readVerifiedWindowsStagedPayload
|
||||
|
||||
// readVerifiedWindowsStagedPayload verifies the Authenticode signature and
|
||||
// reads the payload through one handle that disallows concurrent writes,
|
||||
// deletes, and renames. The bytes handed to the publisher therefore cannot
|
||||
// change between signature verification and publication.
|
||||
func readVerifiedWindowsStagedPayload(path string) ([]byte, error) {
|
||||
info, err := os.Lstat(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !info.Mode().IsRegular() {
|
||||
return nil, fmt.Errorf("staged payload is not a regular file")
|
||||
}
|
||||
path16, err := windows.UTF16PtrFromString(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
handle, err := windows.CreateFile(
|
||||
path16,
|
||||
windows.GENERIC_READ,
|
||||
windows.FILE_SHARE_READ,
|
||||
nil,
|
||||
windows.OPEN_EXISTING,
|
||||
windows.FILE_ATTRIBUTE_NORMAL|windows.FILE_FLAG_SEQUENTIAL_SCAN,
|
||||
0,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
file := os.NewFile(uintptr(handle), path)
|
||||
if file == nil {
|
||||
_ = windows.CloseHandle(handle)
|
||||
return nil, fmt.Errorf("open staged payload handle")
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
fileInfo, err := file.Stat()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !fileInfo.Mode().IsRegular() {
|
||||
return nil, fmt.Errorf("staged payload is not a regular file")
|
||||
}
|
||||
|
||||
trustFile := &windows.WinTrustFileInfo{
|
||||
Size: uint32(unsafe.Sizeof(windows.WinTrustFileInfo{})),
|
||||
FilePath: path16,
|
||||
File: handle,
|
||||
}
|
||||
trustData := &windows.WinTrustData{
|
||||
Size: uint32(unsafe.Sizeof(windows.WinTrustData{})),
|
||||
UIChoice: windows.WTD_UI_NONE,
|
||||
RevocationChecks: windows.WTD_REVOKE_NONE,
|
||||
UnionChoice: windows.WTD_CHOICE_FILE,
|
||||
FileOrCatalogOrBlobOrSgnrOrCert: unsafe.Pointer(trustFile),
|
||||
StateAction: windows.WTD_STATEACTION_VERIFY,
|
||||
UIContext: windows.WTD_UICONTEXT_EXECUTE,
|
||||
}
|
||||
verifyErr := windows.WinVerifyTrustEx(
|
||||
windows.InvalidHWND,
|
||||
&windows.WINTRUST_ACTION_GENERIC_VERIFY_V2,
|
||||
trustData,
|
||||
)
|
||||
trustData.StateAction = windows.WTD_STATEACTION_CLOSE
|
||||
closeErr := windows.WinVerifyTrustEx(
|
||||
windows.InvalidHWND,
|
||||
&windows.WINTRUST_ACTION_GENERIC_VERIFY_V2,
|
||||
trustData,
|
||||
)
|
||||
if verifyErr != nil {
|
||||
if closeErr != nil {
|
||||
return nil, fmt.Errorf("Authenticode verification failed: %v (release trust state: %w)", verifyErr, closeErr)
|
||||
}
|
||||
return nil, fmt.Errorf("Authenticode verification failed: %w", verifyErr)
|
||||
}
|
||||
if closeErr != nil {
|
||||
return nil, fmt.Errorf("release Authenticode verification state: %w", closeErr)
|
||||
}
|
||||
if _, err := file.Seek(0, io.SeekStart); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return io.ReadAll(file)
|
||||
}
|
||||
@@ -0,0 +1,489 @@
|
||||
//go:build linux
|
||||
|
||||
// Command reasonix-update-helper (Linux) installs a verified .deb under Polkit.
|
||||
// It is invoked only via pkexec with fixed argv and re-validates every input as
|
||||
// root before calling apt-get. The unprivileged desktop process never runs apt.
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
|
||||
"reasonix/desktop/internal/update"
|
||||
)
|
||||
|
||||
// Stable exit codes observed by the desktop installer.
|
||||
const (
|
||||
exitOK = 0
|
||||
exitUsage = 2
|
||||
exitNotRoot = 10
|
||||
exitBadInput = 11
|
||||
exitVerifyFailed = 12
|
||||
exitPackageRejected = 13
|
||||
exitBusy = 14
|
||||
exitInstallFailed = 15
|
||||
exitPostVerify = 16
|
||||
)
|
||||
|
||||
const (
|
||||
packageName = "reasonix-desktop"
|
||||
dpkgDebPath = "/usr/bin/dpkg-deb"
|
||||
dpkgQueryPath = "/usr/bin/dpkg-query"
|
||||
dpkgPath = "/usr/bin/dpkg"
|
||||
aptGetPath = "/usr/bin/apt-get"
|
||||
|
||||
// maxInputBytes bounds untrusted package/signature files before they are
|
||||
// copied into the root temp directory (desktop .deb + minisig).
|
||||
maxInputBytes = 512 << 20 // 512 MiB
|
||||
|
||||
// phasePrefix is a single-line protocol the desktop parses from stderr so
|
||||
// the UI can leave "authorizing" once Polkit has launched this helper and
|
||||
// validation finished, before apt-get starts.
|
||||
phasePrefix = "REASONIX_UPDATE_PHASE="
|
||||
)
|
||||
|
||||
type helperResult struct {
|
||||
OK bool `json:"ok"`
|
||||
Version string `json:"version,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Code string `json:"code,omitempty"`
|
||||
}
|
||||
|
||||
// installDeps holds the privileged install seams. Production uses realDeps();
|
||||
// tests inject fakes so every branch is deterministic without root/apt.
|
||||
type installDeps struct {
|
||||
geteuid func() int
|
||||
getenv func(string) string
|
||||
mkTempDir func() (string, error)
|
||||
removeAll func(string) error
|
||||
copyOwnedRegular func(src, dst string, mode os.FileMode, ownerUID int, maxBytes int64) error
|
||||
readFile func(string) ([]byte, error)
|
||||
verify func(data, sig []byte) error
|
||||
inspectDeb func(path string) (debIdentity, error)
|
||||
installedVersion func() (string, error)
|
||||
compareVersions func(a, b string) (int, error)
|
||||
aptInstall func(pkgPath string, allowDowngrade bool) error
|
||||
verifyInstalled func(want string) error
|
||||
writePhase func(phase string)
|
||||
writeResult func(helperResult)
|
||||
goArch string
|
||||
maxInputBytes int64
|
||||
}
|
||||
|
||||
func realDeps() installDeps {
|
||||
return installDeps{
|
||||
geteuid: os.Geteuid,
|
||||
getenv: os.Getenv,
|
||||
mkTempDir: func() (string, error) { return os.MkdirTemp("", "reasonix-update-*") },
|
||||
removeAll: os.RemoveAll,
|
||||
copyOwnedRegular: copyOwnedRegularFile,
|
||||
readFile: os.ReadFile,
|
||||
verify: update.Verify,
|
||||
inspectDeb: inspectDeb,
|
||||
installedVersion: installedPackageVersion,
|
||||
compareVersions: compareDebVersions,
|
||||
aptInstall: aptInstallOnlyUpgrade,
|
||||
verifyInstalled: verifyInstalled,
|
||||
writePhase: writePhaseLine,
|
||||
writeResult: writeResultJSON,
|
||||
goArch: runtime.GOARCH,
|
||||
maxInputBytes: maxInputBytes,
|
||||
}
|
||||
}
|
||||
|
||||
func main() {
|
||||
os.Exit(run(os.Args[1:]))
|
||||
}
|
||||
|
||||
func run(args []string) int {
|
||||
return runWith(realDeps(), args)
|
||||
}
|
||||
|
||||
func runWith(d installDeps, args []string) int {
|
||||
if len(args) == 0 {
|
||||
d.writeResult(helperResult{OK: false, Error: "missing command", Code: "usage"})
|
||||
return exitUsage
|
||||
}
|
||||
switch args[0] {
|
||||
case "install":
|
||||
return runInstall(d, args[1:])
|
||||
default:
|
||||
d.writeResult(helperResult{OK: false, Error: "unknown command", Code: "usage"})
|
||||
return exitUsage
|
||||
}
|
||||
}
|
||||
|
||||
func runInstall(d installDeps, args []string) int {
|
||||
fs := flag.NewFlagSet("install", flag.ContinueOnError)
|
||||
fs.SetOutput(io.Discard)
|
||||
var packagePath, signaturePath string
|
||||
fs.StringVar(&packagePath, "package", "", "path to the verified .deb")
|
||||
fs.StringVar(&signaturePath, "signature", "", "path to the detached .minisig")
|
||||
if err := fs.Parse(args); err != nil {
|
||||
d.writeResult(helperResult{OK: false, Error: "invalid arguments", Code: "usage"})
|
||||
return exitUsage
|
||||
}
|
||||
if packagePath == "" || signaturePath == "" || fs.NArg() != 0 {
|
||||
d.writeResult(helperResult{OK: false, Error: "install requires --package and --signature", Code: "usage"})
|
||||
return exitUsage
|
||||
}
|
||||
|
||||
if d.geteuid() != 0 {
|
||||
d.writeResult(helperResult{OK: false, Error: "helper must run as root", Code: "not_root"})
|
||||
return exitNotRoot
|
||||
}
|
||||
pkUID, err := strconv.Atoi(strings.TrimSpace(d.getenv("PKEXEC_UID")))
|
||||
if err != nil || pkUID < 0 {
|
||||
d.writeResult(helperResult{OK: false, Error: "missing or invalid PKEXEC_UID", Code: "not_root"})
|
||||
return exitNotRoot
|
||||
}
|
||||
|
||||
tmpDir, err := d.mkTempDir()
|
||||
if err != nil {
|
||||
d.writeResult(helperResult{OK: false, Error: "create temp dir failed", Code: "install_failed"})
|
||||
return exitInstallFailed
|
||||
}
|
||||
// Ensure root-only access before any untrusted bytes land here.
|
||||
if err := os.Chmod(tmpDir, 0o700); err != nil {
|
||||
_ = d.removeAll(tmpDir)
|
||||
d.writeResult(helperResult{OK: false, Error: "secure temp dir failed", Code: "install_failed"})
|
||||
return exitInstallFailed
|
||||
}
|
||||
defer func() { _ = d.removeAll(tmpDir) }()
|
||||
|
||||
maxBytes := d.maxInputBytes
|
||||
if maxBytes <= 0 {
|
||||
maxBytes = maxInputBytes
|
||||
}
|
||||
pkgCopy := filepath.Join(tmpDir, "package.deb")
|
||||
sigCopy := filepath.Join(tmpDir, "package.deb.minisig")
|
||||
if err := d.copyOwnedRegular(packagePath, pkgCopy, 0o600, pkUID, maxBytes); err != nil {
|
||||
d.writeResult(helperResult{OK: false, Error: "invalid package input", Code: "bad_input"})
|
||||
return exitBadInput
|
||||
}
|
||||
if err := d.copyOwnedRegular(signaturePath, sigCopy, 0o600, pkUID, maxBytes); err != nil {
|
||||
d.writeResult(helperResult{OK: false, Error: "invalid signature input", Code: "bad_input"})
|
||||
return exitBadInput
|
||||
}
|
||||
|
||||
pkgData, err := d.readFile(pkgCopy)
|
||||
if err != nil {
|
||||
d.writeResult(helperResult{OK: false, Error: "read package failed", Code: "bad_input"})
|
||||
return exitBadInput
|
||||
}
|
||||
sigData, err := d.readFile(sigCopy)
|
||||
if err != nil {
|
||||
d.writeResult(helperResult{OK: false, Error: "read signature failed", Code: "bad_input"})
|
||||
return exitBadInput
|
||||
}
|
||||
// Re-verify as root; never trust the unprivileged process's prior check.
|
||||
if err := d.verify(pkgData, sigData); err != nil {
|
||||
d.writeResult(helperResult{OK: false, Error: "signature verification failed", Code: "verify_failed"})
|
||||
return exitVerifyFailed
|
||||
}
|
||||
|
||||
candidate, err := d.inspectDeb(pkgCopy)
|
||||
if err != nil {
|
||||
d.writeResult(helperResult{OK: false, Error: err.Error(), Code: "package_rejected"})
|
||||
return exitPackageRejected
|
||||
}
|
||||
if err := acceptDebIdentity(candidate, d.goArch); err != nil {
|
||||
d.writeResult(helperResult{OK: false, Error: err.Error(), Code: "package_rejected"})
|
||||
return exitPackageRejected
|
||||
}
|
||||
|
||||
installed, err := d.installedVersion()
|
||||
if err != nil {
|
||||
d.writeResult(helperResult{OK: false, Error: err.Error(), Code: "package_rejected"})
|
||||
return exitPackageRejected
|
||||
}
|
||||
cmp, err := d.compareVersions(candidate.Version, installed)
|
||||
if err != nil {
|
||||
d.writeResult(helperResult{OK: false, Error: "version compare failed", Code: "package_rejected"})
|
||||
return exitPackageRejected
|
||||
}
|
||||
allowDowngrade, err := acceptVersionTransition(cmp, candidate.Version, installed)
|
||||
if err != nil {
|
||||
d.writeResult(helperResult{OK: false, Error: err.Error(), Code: "package_rejected"})
|
||||
return exitPackageRejected
|
||||
}
|
||||
|
||||
// Polkit already authorized this process; validation is complete. Tell the
|
||||
// desktop to leave "authorizing" before the long apt-get call.
|
||||
d.writePhase("installing")
|
||||
|
||||
if err := d.aptInstall(pkgCopy, allowDowngrade); err != nil {
|
||||
code := "install_failed"
|
||||
exit := exitInstallFailed
|
||||
if isPackageManagerBusy(err) {
|
||||
code = "package_manager_busy"
|
||||
exit = exitBusy
|
||||
}
|
||||
d.writeResult(helperResult{OK: false, Error: sanitizeHelperError(err), Code: code})
|
||||
return exit
|
||||
}
|
||||
|
||||
if err := d.verifyInstalled(candidate.Version); err != nil {
|
||||
d.writeResult(helperResult{OK: false, Error: err.Error(), Code: "package_verify_failed"})
|
||||
return exitPostVerify
|
||||
}
|
||||
|
||||
d.writeResult(helperResult{OK: true, Version: candidate.Version})
|
||||
return exitOK
|
||||
}
|
||||
|
||||
type debIdentity struct {
|
||||
Package string
|
||||
Version string
|
||||
Arch string
|
||||
}
|
||||
|
||||
// acceptDebIdentity enforces package name and architecture. Pure for tests.
|
||||
func acceptDebIdentity(id debIdentity, goArch string) error {
|
||||
if id.Package != packageName {
|
||||
return errors.New("package name rejected")
|
||||
}
|
||||
wantArch := goArch
|
||||
if wantArch == "386" {
|
||||
wantArch = "i386"
|
||||
}
|
||||
if id.Arch != wantArch && id.Arch != "all" {
|
||||
return errors.New("package architecture rejected")
|
||||
}
|
||||
if id.Version == "" {
|
||||
return errors.New("package version missing")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var (
|
||||
stableDebVersionRE = regexp.MustCompile(`^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$`)
|
||||
previewDebVersionRE = regexp.MustCompile(`^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)~preview\.(0|[1-9][0-9]*)$`)
|
||||
)
|
||||
|
||||
// acceptVersionTransition permits ordinary upgrades and the one intentional
|
||||
// downgrade: replacing a public Preview package with a public Stable package
|
||||
// after the user switches channels.
|
||||
func acceptVersionTransition(cmp int, candidate, installed string) (bool, error) {
|
||||
switch {
|
||||
case cmp > 0:
|
||||
return false, nil
|
||||
case cmp == 0:
|
||||
return false, errors.New("candidate version is not strictly newer")
|
||||
case stableDebVersionRE.MatchString(candidate) && previewDebVersionRE.MatchString(installed):
|
||||
return true, nil
|
||||
default:
|
||||
return false, errors.New("candidate downgrade is not an allowed preview-to-stable transition")
|
||||
}
|
||||
}
|
||||
|
||||
// aptInstallArgv is the fixed absolute apt-get argv (never shell). Pure for tests.
|
||||
func aptInstallArgv(pkgPath string, allowDowngrade bool) []string {
|
||||
argv := []string{
|
||||
aptGetPath,
|
||||
"install",
|
||||
"--assume-yes",
|
||||
"--only-upgrade",
|
||||
"--no-remove",
|
||||
}
|
||||
if allowDowngrade {
|
||||
argv = append(argv, "--allow-downgrades")
|
||||
}
|
||||
return append(argv, pkgPath)
|
||||
}
|
||||
|
||||
func inspectDeb(path string) (debIdentity, error) {
|
||||
pkg, err := dpkgDebField(path, "Package")
|
||||
if err != nil {
|
||||
return debIdentity{}, errors.New("dpkg-deb inspection failed")
|
||||
}
|
||||
ver, err := dpkgDebField(path, "Version")
|
||||
if err != nil {
|
||||
return debIdentity{}, errors.New("dpkg-deb inspection failed")
|
||||
}
|
||||
arch, err := dpkgDebField(path, "Architecture")
|
||||
if err != nil {
|
||||
return debIdentity{}, errors.New("dpkg-deb inspection failed")
|
||||
}
|
||||
return debIdentity{Package: pkg, Version: ver, Arch: arch}, nil
|
||||
}
|
||||
|
||||
func dpkgDebField(path, field string) (string, error) {
|
||||
out, err := exec.Command(dpkgDebPath, "-f", path, field).Output()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return strings.TrimSpace(string(out)), nil
|
||||
}
|
||||
|
||||
func installedPackageVersion() (string, error) {
|
||||
out, err := exec.Command(dpkgQueryPath, "-W", "-f=${Version}", packageName).Output()
|
||||
if err != nil {
|
||||
return "", errors.New("installed package not found")
|
||||
}
|
||||
v := strings.TrimSpace(string(out))
|
||||
if v == "" {
|
||||
return "", errors.New("installed package version empty")
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
|
||||
// compareDebVersions returns >0 when a > b using dpkg --compare-versions.
|
||||
func compareDebVersions(a, b string) (int, error) {
|
||||
if err := exec.Command(dpkgPath, "--compare-versions", a, "gt", b).Run(); err == nil {
|
||||
return 1, nil
|
||||
}
|
||||
if err := exec.Command(dpkgPath, "--compare-versions", a, "eq", b).Run(); err == nil {
|
||||
return 0, nil
|
||||
}
|
||||
if err := exec.Command(dpkgPath, "--compare-versions", a, "lt", b).Run(); err == nil {
|
||||
return -1, nil
|
||||
}
|
||||
return 0, errors.New("compare-versions failed")
|
||||
}
|
||||
|
||||
func aptInstallOnlyUpgrade(pkgPath string, allowDowngrade bool) error {
|
||||
argv := aptInstallArgv(pkgPath, allowDowngrade)
|
||||
cmd := exec.Command(argv[0], argv[1:]...)
|
||||
// Fixed absolute argv only — never shell.
|
||||
cmd.Env = append(os.Environ(), "DEBIAN_FRONTEND=noninteractive")
|
||||
var stderr bytes.Buffer
|
||||
cmd.Stdout = io.Discard
|
||||
cmd.Stderr = &stderr
|
||||
if err := cmd.Run(); err != nil {
|
||||
return fmt.Errorf("%w: %s", err, strings.TrimSpace(stderr.String()))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func verifyInstalled(wantVersion string) error {
|
||||
out, err := exec.Command(dpkgQueryPath, "-W", "-f=${Status}\n${Version}", packageName).Output()
|
||||
if err != nil {
|
||||
return errors.New("post-install dpkg-query failed")
|
||||
}
|
||||
lines := strings.Split(strings.TrimSpace(string(out)), "\n")
|
||||
if len(lines) < 2 {
|
||||
return errors.New("post-install package state incomplete")
|
||||
}
|
||||
if strings.TrimSpace(lines[0]) != "install ok installed" {
|
||||
return errors.New("package not in install ok installed state")
|
||||
}
|
||||
if strings.TrimSpace(lines[1]) != wantVersion {
|
||||
return errors.New("installed version mismatch")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// copyOwnedRegularFile opens src with O_NOFOLLOW only (fail closed — no Lstat/Open
|
||||
// fallback), requires a regular file owned by ownerUID, enforces maxBytes, and
|
||||
// writes a root-owned copy at dst. Ownership is checked on the opened fd.
|
||||
func copyOwnedRegularFile(src, dst string, mode os.FileMode, ownerUID int, maxBytes int64) error {
|
||||
f, err := os.OpenFile(src, os.O_RDONLY|syscall.O_NOFOLLOW, 0)
|
||||
if err != nil {
|
||||
// Fail closed: never fall back to a followable open (TOCTOU).
|
||||
return fmt.Errorf("open without following links: %w", err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
info, err := f.Stat()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !info.Mode().IsRegular() {
|
||||
return errors.New("not a regular file")
|
||||
}
|
||||
if maxBytes > 0 && info.Size() > maxBytes {
|
||||
return errors.New("input exceeds size bound")
|
||||
}
|
||||
st, ok := info.Sys().(*syscall.Stat_t)
|
||||
if !ok {
|
||||
return errors.New("stat owner unavailable")
|
||||
}
|
||||
if int(st.Uid) != ownerUID {
|
||||
return errors.New("input owner does not match PKEXEC_UID")
|
||||
}
|
||||
|
||||
out, err := os.OpenFile(dst, os.O_CREATE|os.O_WRONLY|os.O_EXCL, mode)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer out.Close()
|
||||
// Cap the copy even if size grew after Stat (regular files can still be
|
||||
// truncated/appended by the owner on some filesystems; bound the bytes we take).
|
||||
limited := io.LimitReader(f, maxBytes+1)
|
||||
n, err := io.Copy(out, limited)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if maxBytes > 0 && n > maxBytes {
|
||||
return errors.New("input exceeds size bound")
|
||||
}
|
||||
return out.Close()
|
||||
}
|
||||
|
||||
func isPackageManagerBusy(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
low := strings.ToLower(err.Error())
|
||||
return strings.Contains(low, "could not get lock") ||
|
||||
strings.Contains(low, "unable to acquire the dpkg frontend lock") ||
|
||||
strings.Contains(low, "is another process using it") ||
|
||||
strings.Contains(low, "dpkg frontend lock")
|
||||
}
|
||||
|
||||
// sanitizeHelperError strips absolute paths from helper diagnostics so the
|
||||
// desktop UI never surfaces user home directories.
|
||||
func sanitizeHelperError(err error) string {
|
||||
if err == nil {
|
||||
return "install failed"
|
||||
}
|
||||
msg := err.Error()
|
||||
// Drop anything that looks like an absolute path segment.
|
||||
fields := strings.Fields(msg)
|
||||
for i, f := range fields {
|
||||
if strings.HasPrefix(f, "/") {
|
||||
fields[i] = "<path>"
|
||||
}
|
||||
}
|
||||
out := strings.Join(fields, " ")
|
||||
if out == "" {
|
||||
return "install failed"
|
||||
}
|
||||
if len(out) > 240 {
|
||||
out = out[:240]
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func writePhaseLine(phase string) {
|
||||
// Single line, no user paths — desktop parses this while the helper runs.
|
||||
fmt.Fprintf(os.Stderr, "%s%s\n", phasePrefix, phase)
|
||||
}
|
||||
|
||||
func writeResultJSON(r helperResult) {
|
||||
enc := json.NewEncoder(os.Stdout)
|
||||
_ = enc.Encode(r)
|
||||
}
|
||||
|
||||
// parsePhaseLine extracts a progress phase from a helper stderr line.
|
||||
func parsePhaseLine(line string) (phase string, ok bool) {
|
||||
line = strings.TrimSpace(line)
|
||||
if !strings.HasPrefix(line, phasePrefix) {
|
||||
return "", false
|
||||
}
|
||||
phase = strings.TrimSpace(strings.TrimPrefix(line, phasePrefix))
|
||||
return phase, phase != ""
|
||||
}
|
||||
@@ -0,0 +1,596 @@
|
||||
//go:build linux
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"aead.dev/minisign"
|
||||
)
|
||||
|
||||
func TestSanitizeHelperErrorStripsPaths(t *testing.T) {
|
||||
err := errors.New("/home/user/cache/foo.deb locked by /var/lib/dpkg/lock")
|
||||
got := sanitizeHelperError(err)
|
||||
if strings.Contains(got, "/home/") || strings.Contains(got, "/var/") {
|
||||
t.Fatalf("path leaked: %q", got)
|
||||
}
|
||||
if !strings.Contains(got, "<path>") {
|
||||
t.Fatalf("expected path redaction, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsPackageManagerBusy(t *testing.T) {
|
||||
if !isPackageManagerBusy(errors.New("Could not get lock /var/lib/dpkg/lock-frontend")) {
|
||||
t.Fatal("expected busy detection")
|
||||
}
|
||||
if isPackageManagerBusy(errors.New("dependency problems prevent configuration")) {
|
||||
t.Fatal("dependency failure is not busy")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAcceptDebIdentity(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
id debIdentity
|
||||
goArch string
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "ok amd64",
|
||||
id: debIdentity{Package: packageName, Version: "1.2.3", Arch: "amd64"},
|
||||
goArch: "amd64",
|
||||
},
|
||||
{
|
||||
name: "wrong package",
|
||||
id: debIdentity{Package: "evil", Version: "1.2.3", Arch: "amd64"},
|
||||
goArch: "amd64",
|
||||
wantErr: "package name rejected",
|
||||
},
|
||||
{
|
||||
name: "wrong arch",
|
||||
id: debIdentity{Package: packageName, Version: "1.2.3", Arch: "arm64"},
|
||||
goArch: "amd64",
|
||||
wantErr: "package architecture rejected",
|
||||
},
|
||||
{
|
||||
name: "arch all allowed",
|
||||
id: debIdentity{Package: packageName, Version: "1.2.3", Arch: "all"},
|
||||
goArch: "amd64",
|
||||
},
|
||||
{
|
||||
name: "missing version",
|
||||
id: debIdentity{Package: packageName, Version: "", Arch: "amd64"},
|
||||
goArch: "amd64",
|
||||
wantErr: "package version missing",
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
err := acceptDebIdentity(tc.id, tc.goArch)
|
||||
if tc.wantErr == "" {
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected err: %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err == nil || err.Error() != tc.wantErr {
|
||||
t.Fatalf("err = %v, want %q", err, tc.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAcceptVersionTransition(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
cmp int
|
||||
candidate string
|
||||
installed string
|
||||
wantDowngrade bool
|
||||
wantErr bool
|
||||
}{
|
||||
{name: "ordinary upgrade", cmp: 1, candidate: "1.3.0", installed: "1.2.0"},
|
||||
{name: "equal rejected", cmp: 0, candidate: "1.2.0", installed: "1.2.0", wantErr: true},
|
||||
{name: "preview to older stable", cmp: -1, candidate: "1.2.0", installed: "1.3.0~preview.1", wantDowngrade: true},
|
||||
{name: "stable to older stable rejected", cmp: -1, candidate: "1.2.0", installed: "1.3.0", wantErr: true},
|
||||
{name: "preview to older preview rejected", cmp: -1, candidate: "1.2.0~preview.1", installed: "1.3.0~preview.1", wantErr: true},
|
||||
{name: "rc to stable rejected", cmp: -1, candidate: "1.2.0", installed: "1.3.0~rc.1", wantErr: true},
|
||||
{name: "internal preview rejected", cmp: -1, candidate: "1.2.0", installed: "1.3.0~preview.1+internal", wantErr: true},
|
||||
{name: "malformed stable rejected", cmp: -1, candidate: "01.2.0", installed: "1.3.0~preview.1", wantErr: true},
|
||||
{name: "malformed preview rejected", cmp: -1, candidate: "1.2.0", installed: "1.3.0~preview.01", wantErr: true},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
allowDowngrade, err := acceptVersionTransition(tt.cmp, tt.candidate, tt.installed)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Fatalf("error = %v, wantErr %v", err, tt.wantErr)
|
||||
}
|
||||
if allowDowngrade != tt.wantDowngrade {
|
||||
t.Fatalf("allowDowngrade = %v, want %v", allowDowngrade, tt.wantDowngrade)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAptInstallArgvFixed(t *testing.T) {
|
||||
argv := aptInstallArgv("/tmp/pkg.deb", false)
|
||||
want := []string{
|
||||
"/usr/bin/apt-get",
|
||||
"install",
|
||||
"--assume-yes",
|
||||
"--only-upgrade",
|
||||
"--no-remove",
|
||||
"/tmp/pkg.deb",
|
||||
}
|
||||
if len(argv) != len(want) {
|
||||
t.Fatalf("argv = %v", argv)
|
||||
}
|
||||
for i := range want {
|
||||
if argv[i] != want[i] {
|
||||
t.Fatalf("argv[%d] = %q, want %q", i, argv[i], want[i])
|
||||
}
|
||||
}
|
||||
joined := strings.Join(argv, " ")
|
||||
for _, bad := range []string{"--allow-unauthenticated", "--allow-downgrades", "--force-yes", "sh -c"} {
|
||||
if strings.Contains(joined, bad) {
|
||||
t.Fatalf("forbidden apt option present: %s in %v", bad, argv)
|
||||
}
|
||||
}
|
||||
|
||||
downgradeArgv := aptInstallArgv("/tmp/pkg.deb", true)
|
||||
if strings.Count(strings.Join(downgradeArgv, " "), "--allow-downgrades") != 1 {
|
||||
t.Fatalf("constrained downgrade argv = %v", downgradeArgv)
|
||||
}
|
||||
if downgradeArgv[len(downgradeArgv)-1] != "/tmp/pkg.deb" {
|
||||
t.Fatalf("package path must remain the final fixed argument: %v", downgradeArgv)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParsePhaseLine(t *testing.T) {
|
||||
phase, ok := parsePhaseLine(phasePrefix + "installing")
|
||||
if !ok || phase != "installing" {
|
||||
t.Fatalf("got %q %v", phase, ok)
|
||||
}
|
||||
if _, ok := parsePhaseLine("noise"); ok {
|
||||
t.Fatal("noise should not parse")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCopyOwnedRegularFileRejectsSymlinkFailClosed(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
target := filepath.Join(dir, "real.deb")
|
||||
if err := os.WriteFile(target, []byte("deb"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
link := filepath.Join(dir, "link.deb")
|
||||
if err := os.Symlink(target, link); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
dst := filepath.Join(dir, "out.deb")
|
||||
// Any UID — O_NOFOLLOW must fail before ownership is checked.
|
||||
if err := copyOwnedRegularFile(link, dst, 0o600, os.Getuid(), maxInputBytes); err == nil {
|
||||
t.Fatal("symlink must be rejected without fallback open")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCopyOwnedRegularFileRejectsWrongOwner(t *testing.T) {
|
||||
if os.Geteuid() == 0 {
|
||||
t.Skip("running as root; cannot construct a non-self-owned file cheaply")
|
||||
}
|
||||
dir := t.TempDir()
|
||||
src := filepath.Join(dir, "in.deb")
|
||||
if err := os.WriteFile(src, []byte("package-bytes"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
dst := filepath.Join(dir, "out.deb")
|
||||
// Claim the file must be owned by root while the test user owns it.
|
||||
if err := copyOwnedRegularFile(src, dst, 0o600, 0, maxInputBytes); err == nil {
|
||||
t.Fatal("wrong owner must be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCopyOwnedRegularFileRejectsOversize(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
src := filepath.Join(dir, "in.deb")
|
||||
if err := os.WriteFile(src, []byte("0123456789"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
dst := filepath.Join(dir, "out.deb")
|
||||
if err := copyOwnedRegularFile(src, dst, 0o600, os.Getuid(), 4); err == nil {
|
||||
t.Fatal("oversize input must be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCopyOwnedRegularFileCopiesMatchingOwner(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
src := filepath.Join(dir, "in.deb")
|
||||
payload := []byte("package-bytes")
|
||||
if err := os.WriteFile(src, payload, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
dst := filepath.Join(dir, "out.deb")
|
||||
if err := copyOwnedRegularFile(src, dst, 0o600, os.Getuid(), maxInputBytes); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := os.ReadFile(dst)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(got) != string(payload) {
|
||||
t.Fatalf("copy mismatch: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// --- runInstall table-driven coverage via injectable deps ---
|
||||
|
||||
type installProbe struct {
|
||||
phases []string
|
||||
results []helperResult
|
||||
aptCalls []string
|
||||
}
|
||||
|
||||
func baseFakeDeps(p *installProbe) installDeps {
|
||||
return installDeps{
|
||||
geteuid: func() int { return 0 },
|
||||
getenv: func(k string) string {
|
||||
if k == "PKEXEC_UID" {
|
||||
return "1000"
|
||||
}
|
||||
return ""
|
||||
},
|
||||
mkTempDir: func() (string, error) {
|
||||
return os.MkdirTemp("", "reasonix-helper-test-*")
|
||||
},
|
||||
removeAll: os.RemoveAll,
|
||||
copyOwnedRegular: func(src, dst string, mode os.FileMode, ownerUID int, maxBytes int64) error {
|
||||
data, err := os.ReadFile(src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(dst, data, mode)
|
||||
},
|
||||
readFile: os.ReadFile,
|
||||
verify: func(data, sig []byte) error { return nil },
|
||||
inspectDeb: func(path string) (debIdentity, error) {
|
||||
return debIdentity{Package: packageName, Version: "1.2.0", Arch: "amd64"}, nil
|
||||
},
|
||||
installedVersion: func() (string, error) { return "1.1.0", nil },
|
||||
compareVersions: func(a, b string) (int, error) { return 1, nil },
|
||||
aptInstall: func(pkgPath string, allowDowngrade bool) error {
|
||||
p.aptCalls = append(p.aptCalls, pkgPath)
|
||||
// Assert argv shape through the pure helper used by production.
|
||||
argv := aptInstallArgv(pkgPath, allowDowngrade)
|
||||
if argv[0] != aptGetPath || argv[1] != "install" {
|
||||
return fmt.Errorf("bad argv: %v", argv)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
verifyInstalled: func(want string) error {
|
||||
if want != "1.2.0" {
|
||||
return errors.New("installed version mismatch")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
writePhase: func(phase string) { p.phases = append(p.phases, phase) },
|
||||
writeResult: func(r helperResult) { p.results = append(p.results, r) },
|
||||
goArch: "amd64",
|
||||
maxInputBytes: maxInputBytes,
|
||||
}
|
||||
}
|
||||
|
||||
func writeInstallInputs(t *testing.T) (pkg, sig string) {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
pkg = filepath.Join(dir, "Reasonix.deb")
|
||||
sig = filepath.Join(dir, "Reasonix.deb.minisig")
|
||||
if err := os.WriteFile(pkg, []byte("deb-payload"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(sig, []byte("sig-payload"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return pkg, sig
|
||||
}
|
||||
|
||||
func TestRunInstallSuccessEmitsInstallingPhaseBeforeApt(t *testing.T) {
|
||||
pkg, sig := writeInstallInputs(t)
|
||||
var probe installProbe
|
||||
d := baseFakeDeps(&probe)
|
||||
aptStarted := false
|
||||
d.aptInstall = func(pkgPath string, allowDowngrade bool) error {
|
||||
aptStarted = true
|
||||
if allowDowngrade {
|
||||
t.Fatal("ordinary upgrade unexpectedly enabled downgrades")
|
||||
}
|
||||
if len(probe.phases) == 0 || probe.phases[0] != "installing" {
|
||||
t.Fatalf("phase installing must be emitted before apt; phases=%v", probe.phases)
|
||||
}
|
||||
probe.aptCalls = append(probe.aptCalls, pkgPath)
|
||||
return nil
|
||||
}
|
||||
code := runWith(d, []string{"install", "--package", pkg, "--signature", sig})
|
||||
if code != exitOK {
|
||||
t.Fatalf("exit = %d, results=%+v", code, probe.results)
|
||||
}
|
||||
if !aptStarted || len(probe.aptCalls) != 1 {
|
||||
t.Fatalf("apt not called: %+v", probe.aptCalls)
|
||||
}
|
||||
if len(probe.results) != 1 || !probe.results[0].OK || probe.results[0].Version != "1.2.0" {
|
||||
t.Fatalf("result = %+v", probe.results)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunInstallTable(t *testing.T) {
|
||||
pkg, sig := writeInstallInputs(t)
|
||||
|
||||
type tc struct {
|
||||
name string
|
||||
mutate func(*installDeps, *installProbe)
|
||||
args []string
|
||||
wantCode int
|
||||
wantCodeStr string
|
||||
wantPhase bool // installing emitted only on success path past validation
|
||||
wantOK bool
|
||||
}
|
||||
cases := []tc{
|
||||
{
|
||||
name: "usage missing flags",
|
||||
args: []string{"install"},
|
||||
wantCode: exitUsage,
|
||||
wantCodeStr: "usage",
|
||||
},
|
||||
{
|
||||
name: "not root",
|
||||
mutate: func(d *installDeps, _ *installProbe) {
|
||||
d.geteuid = func() int { return 1000 }
|
||||
},
|
||||
args: []string{"install", "--package", pkg, "--signature", sig},
|
||||
wantCode: exitNotRoot,
|
||||
wantCodeStr: "not_root",
|
||||
},
|
||||
{
|
||||
name: "missing PKEXEC_UID",
|
||||
mutate: func(d *installDeps, _ *installProbe) {
|
||||
d.getenv = func(string) string { return "" }
|
||||
},
|
||||
args: []string{"install", "--package", pkg, "--signature", sig},
|
||||
wantCode: exitNotRoot,
|
||||
wantCodeStr: "not_root",
|
||||
},
|
||||
{
|
||||
name: "bad input copy",
|
||||
mutate: func(d *installDeps, _ *installProbe) {
|
||||
d.copyOwnedRegular = func(string, string, os.FileMode, int, int64) error {
|
||||
return errors.New("owner mismatch")
|
||||
}
|
||||
},
|
||||
args: []string{"install", "--package", pkg, "--signature", sig},
|
||||
wantCode: exitBadInput,
|
||||
wantCodeStr: "bad_input",
|
||||
},
|
||||
{
|
||||
name: "verify failed",
|
||||
mutate: func(d *installDeps, _ *installProbe) {
|
||||
d.verify = func([]byte, []byte) error { return errors.New("bad sig") }
|
||||
},
|
||||
args: []string{"install", "--package", pkg, "--signature", sig},
|
||||
wantCode: exitVerifyFailed,
|
||||
wantCodeStr: "verify_failed",
|
||||
},
|
||||
{
|
||||
name: "wrong package name",
|
||||
mutate: func(d *installDeps, _ *installProbe) {
|
||||
d.inspectDeb = func(string) (debIdentity, error) {
|
||||
return debIdentity{Package: "other", Version: "2.0", Arch: "amd64"}, nil
|
||||
}
|
||||
},
|
||||
args: []string{"install", "--package", pkg, "--signature", sig},
|
||||
wantCode: exitPackageRejected,
|
||||
wantCodeStr: "package_rejected",
|
||||
},
|
||||
{
|
||||
name: "wrong architecture",
|
||||
mutate: func(d *installDeps, _ *installProbe) {
|
||||
d.inspectDeb = func(string) (debIdentity, error) {
|
||||
return debIdentity{Package: packageName, Version: "2.0", Arch: "arm64"}, nil
|
||||
}
|
||||
},
|
||||
args: []string{"install", "--package", pkg, "--signature", sig},
|
||||
wantCode: exitPackageRejected,
|
||||
wantCodeStr: "package_rejected",
|
||||
},
|
||||
{
|
||||
name: "same version rejected",
|
||||
mutate: func(d *installDeps, _ *installProbe) {
|
||||
d.compareVersions = func(a, b string) (int, error) { return 0, nil }
|
||||
},
|
||||
args: []string{"install", "--package", pkg, "--signature", sig},
|
||||
wantCode: exitPackageRejected,
|
||||
wantCodeStr: "package_rejected",
|
||||
},
|
||||
{
|
||||
name: "downgrade rejected",
|
||||
mutate: func(d *installDeps, _ *installProbe) {
|
||||
d.compareVersions = func(a, b string) (int, error) { return -1, nil }
|
||||
},
|
||||
args: []string{"install", "--package", pkg, "--signature", sig},
|
||||
wantCode: exitPackageRejected,
|
||||
wantCodeStr: "package_rejected",
|
||||
},
|
||||
{
|
||||
name: "preview to stable downgrade allowed",
|
||||
mutate: func(d *installDeps, p *installProbe) {
|
||||
d.inspectDeb = func(string) (debIdentity, error) {
|
||||
return debIdentity{Package: packageName, Version: "1.2.0", Arch: "amd64"}, nil
|
||||
}
|
||||
d.installedVersion = func() (string, error) { return "1.3.0~preview.1", nil }
|
||||
d.compareVersions = func(a, b string) (int, error) { return -1, nil }
|
||||
d.aptInstall = func(pkgPath string, allowDowngrade bool) error {
|
||||
if !allowDowngrade {
|
||||
return errors.New("preview-to-stable downgrade flag missing")
|
||||
}
|
||||
p.aptCalls = append(p.aptCalls, pkgPath)
|
||||
return nil
|
||||
}
|
||||
},
|
||||
args: []string{"install", "--package", pkg, "--signature", sig},
|
||||
wantCode: exitOK,
|
||||
wantPhase: true,
|
||||
wantOK: true,
|
||||
},
|
||||
{
|
||||
name: "apt busy",
|
||||
mutate: func(d *installDeps, _ *installProbe) {
|
||||
d.aptInstall = func(string, bool) error {
|
||||
return errors.New("Could not get lock /var/lib/dpkg/lock-frontend")
|
||||
}
|
||||
},
|
||||
args: []string{"install", "--package", pkg, "--signature", sig},
|
||||
wantCode: exitBusy,
|
||||
wantCodeStr: "package_manager_busy",
|
||||
wantPhase: true,
|
||||
},
|
||||
{
|
||||
name: "apt non-zero",
|
||||
mutate: func(d *installDeps, _ *installProbe) {
|
||||
d.aptInstall = func(string, bool) error {
|
||||
return errors.New("E: Sub-process /usr/bin/dpkg returned an error code (1)")
|
||||
}
|
||||
},
|
||||
args: []string{"install", "--package", pkg, "--signature", sig},
|
||||
wantCode: exitInstallFailed,
|
||||
wantCodeStr: "install_failed",
|
||||
wantPhase: true,
|
||||
},
|
||||
{
|
||||
name: "post install version mismatch",
|
||||
mutate: func(d *installDeps, _ *installProbe) {
|
||||
d.verifyInstalled = func(string) error { return errors.New("installed version mismatch") }
|
||||
},
|
||||
args: []string{"install", "--package", pkg, "--signature", sig},
|
||||
wantCode: exitPostVerify,
|
||||
wantCodeStr: "package_verify_failed",
|
||||
wantPhase: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
var probe installProbe
|
||||
d := baseFakeDeps(&probe)
|
||||
if tc.mutate != nil {
|
||||
tc.mutate(&d, &probe)
|
||||
}
|
||||
code := runWith(d, tc.args)
|
||||
if code != tc.wantCode {
|
||||
t.Fatalf("exit = %d, want %d; results=%+v", code, tc.wantCode, probe.results)
|
||||
}
|
||||
if len(probe.results) == 0 {
|
||||
t.Fatal("expected a written result")
|
||||
}
|
||||
last := probe.results[len(probe.results)-1]
|
||||
if last.OK != tc.wantOK {
|
||||
t.Fatalf("result OK = %v, want %v: %+v", last.OK, tc.wantOK, last)
|
||||
}
|
||||
if tc.wantCodeStr != "" && last.Code != tc.wantCodeStr {
|
||||
t.Fatalf("code = %q, want %q", last.Code, tc.wantCodeStr)
|
||||
}
|
||||
if tc.wantPhase {
|
||||
if len(probe.phases) == 0 || probe.phases[0] != "installing" {
|
||||
t.Fatalf("expected installing phase before failure after validation, got %v", probe.phases)
|
||||
}
|
||||
} else if len(probe.phases) > 0 {
|
||||
// Validation failures must not claim apt started.
|
||||
t.Fatalf("unexpected phase before validation failure: %v", probe.phases)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunInstallCleansTempDirOnFailure(t *testing.T) {
|
||||
pkg, sig := writeInstallInputs(t)
|
||||
var probe installProbe
|
||||
d := baseFakeDeps(&probe)
|
||||
var created, removed string
|
||||
d.mkTempDir = func() (string, error) {
|
||||
dir, err := os.MkdirTemp("", "reasonix-helper-test-*")
|
||||
created = dir
|
||||
return dir, err
|
||||
}
|
||||
d.removeAll = func(path string) error {
|
||||
removed = path
|
||||
return os.RemoveAll(path)
|
||||
}
|
||||
d.verify = func([]byte, []byte) error { return errors.New("fail after tmp") }
|
||||
code := runWith(d, []string{"install", "--package", pkg, "--signature", sig})
|
||||
if code != exitVerifyFailed {
|
||||
t.Fatalf("exit = %d", code)
|
||||
}
|
||||
if created == "" || removed != created {
|
||||
t.Fatalf("temp dir not cleaned: created=%q removed=%q", created, removed)
|
||||
}
|
||||
if _, err := os.Stat(created); !os.IsNotExist(err) {
|
||||
t.Fatalf("temp dir still on disk: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunInstallRejectsUnknownCommand(t *testing.T) {
|
||||
var probe installProbe
|
||||
d := baseFakeDeps(&probe)
|
||||
code := runWith(d, []string{"upgrade"})
|
||||
if code != exitUsage {
|
||||
t.Fatalf("exit = %d", code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMinisignRejectsWrongKeyAndTamper pins that the shared verify path used by
|
||||
// the helper rejects throwaway-key signatures and tampered payloads. Production
|
||||
// uses update.Verify with the embedded key; here we exercise the same minisign
|
||||
// library contract the helper depends on.
|
||||
func TestMinisignRejectsWrongKeyAndTamper(t *testing.T) {
|
||||
pub, priv, err := minisign.GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data := []byte("artifact")
|
||||
sig := minisign.Sign(priv, data)
|
||||
if !minisign.Verify(pub, data, sig) {
|
||||
t.Fatal("genuine signature under matching key must verify")
|
||||
}
|
||||
if minisign.Verify(pub, []byte("tampered"), sig) {
|
||||
t.Fatal("tampered payload must not verify")
|
||||
}
|
||||
otherPub, _, err := minisign.GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if minisign.Verify(otherPub, data, sig) {
|
||||
t.Fatal("signature under a different public key must not verify")
|
||||
}
|
||||
// Garbage signature must fail under the matching public key.
|
||||
if minisign.Verify(pub, data, []byte("not-a-sig")) {
|
||||
t.Fatal("garbage signature must not verify")
|
||||
}
|
||||
// Wire the same contract into installDeps.verify so a wrong-key path fails
|
||||
// runInstall with exitVerifyFailed.
|
||||
var probe installProbe
|
||||
d := baseFakeDeps(&probe)
|
||||
d.verify = func(payload, signature []byte) error {
|
||||
if !minisign.Verify(pub, payload, signature) {
|
||||
return errors.New("signature verification failed")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
pkg, sigPath := writeInstallInputs(t)
|
||||
// Input files contain "deb-payload"/"sig-payload", not a real signature.
|
||||
code := runWith(d, []string{"install", "--package", pkg, "--signature", sigPath})
|
||||
if code != exitVerifyFailed {
|
||||
t.Fatalf("exit = %d, want verify failed; results=%+v", code, probe.results)
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
//go:build !windows
|
||||
//go:build !windows && !linux
|
||||
|
||||
package main
|
||||
|
||||
@@ -8,6 +8,6 @@ import (
|
||||
)
|
||||
|
||||
func main() {
|
||||
fmt.Fprintln(os.Stderr, "reasonix-update-helper is only used by Windows desktop builds")
|
||||
fmt.Fprintln(os.Stderr, "reasonix-update-helper is only used by Windows and Linux desktop builds")
|
||||
os.Exit(2)
|
||||
}
|
||||
@@ -3,12 +3,17 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
@@ -19,20 +24,35 @@ import (
|
||||
|
||||
const parentExitTimeout = 2 * time.Minute
|
||||
|
||||
var (
|
||||
waitForProcessExitFn = waitForProcessExit
|
||||
runInstallerFn = runInstaller
|
||||
startRelaunchFn = startRelaunch
|
||||
claimPendingFileUpdateFn = repair.ClaimPendingFileUpdateExact
|
||||
installStagedReleaseUnitFn = installStagedWindowsReleaseUnit
|
||||
recordInstalledUpdateFn = repair.RecordClaimedFileUpdateInstalled
|
||||
stageInstallerFn = stageVerifiedInstaller
|
||||
claimInstallerExecutionFn = claimVerifiedInstallerForExecution
|
||||
lstatUpdateStagingFn = os.Lstat
|
||||
)
|
||||
|
||||
func main() {
|
||||
os.Exit(run(os.Args[1:]))
|
||||
}
|
||||
|
||||
func run(args []string) int {
|
||||
var parentPID uint
|
||||
var installer, installDir, relaunch, toVersion string
|
||||
var installer, installerSHA256, installDir, relaunch, toVersion, createdAt, transactionID string
|
||||
fs := flag.NewFlagSet("reasonix-update-helper", flag.ContinueOnError)
|
||||
fs.SetOutput(os.Stderr)
|
||||
fs.UintVar(&parentPID, "parent-pid", 0, "Reasonix process id to wait for before installing")
|
||||
fs.StringVar(&installer, "installer", "", "verified NSIS installer path")
|
||||
fs.StringVar(&installerSHA256, "installer-sha256", "", "expected SHA-256 of the verified NSIS installer")
|
||||
fs.StringVar(&installDir, "install-dir", "", "Reasonix installation directory")
|
||||
fs.StringVar(&relaunch, "relaunch", "", "Reasonix executable to start after the installer succeeds")
|
||||
fs.StringVar(&toVersion, "to-version", "", "Reasonix version being installed")
|
||||
fs.StringVar(&createdAt, "created-at", "", "pending update creation timestamp")
|
||||
fs.StringVar(&transactionID, "transaction-id", "", "complete pending update identity")
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return 2
|
||||
}
|
||||
@@ -41,35 +61,129 @@ func run(args []string) int {
|
||||
logger.Print("missing --installer")
|
||||
return 2
|
||||
}
|
||||
if !validSHA256(installerSHA256) {
|
||||
logger.Print("missing or invalid --installer-sha256")
|
||||
return 2
|
||||
}
|
||||
if toVersion == "" {
|
||||
logger.Print("missing --to-version")
|
||||
return 2
|
||||
}
|
||||
if createdAt == "" {
|
||||
logger.Print("missing --created-at")
|
||||
return 2
|
||||
}
|
||||
if transactionID == "" {
|
||||
logger.Print("missing --transaction-id")
|
||||
return 2
|
||||
}
|
||||
if installDir == "" {
|
||||
logger.Print("missing --install-dir")
|
||||
return 2
|
||||
}
|
||||
if parentPID != 0 {
|
||||
if err := waitForProcessExit(uint32(parentPID), parentExitTimeout); err != nil {
|
||||
if err := waitForProcessExitFn(uint32(parentPID), parentExitTimeout); err != nil {
|
||||
logger.Printf("wait for parent process %d: %v", parentPID, err)
|
||||
return 1
|
||||
}
|
||||
}
|
||||
if err := runInstaller(installer, installDir); err != nil {
|
||||
logger.Printf("run installer: %v", err)
|
||||
// The desktop already exited cleanly, so nothing would notice this
|
||||
// failure: record it and relaunch through Guard, which rolls the
|
||||
// release unit back on startup (the helper itself runs from the cache
|
||||
// directory, outside the validated install, and must not restore
|
||||
// binaries directly).
|
||||
if markErr := repair.MarkUpdateApplyFailed(toVersion, err.Error()); markErr != nil {
|
||||
logger.Printf("record install failure: %v", markErr)
|
||||
// The old desktop may acquire the pending lock during its normal shutdown.
|
||||
// Wait for that exact process first, then claim the transaction and hold both
|
||||
// pending and target locks across the installer replacement window.
|
||||
claimed, releaseClaim, err := claimPendingFileUpdateFn(
|
||||
toVersion,
|
||||
createdAt,
|
||||
transactionID,
|
||||
filepath.Join(installDir, "reasonix-desktop.exe"),
|
||||
windowsReleaseUnitPaths(installDir),
|
||||
parentExitTimeout,
|
||||
)
|
||||
if err != nil {
|
||||
logger.Printf("claim pending update: %v", err)
|
||||
return 1
|
||||
}
|
||||
defer releaseClaim()
|
||||
recoverUnstarted := func() int {
|
||||
releaseClaim()
|
||||
if cancelErr := repair.CancelPendingUpdateExact(claimed); cancelErr != nil {
|
||||
logger.Printf("cancel unstarted update: %v", cancelErr)
|
||||
}
|
||||
if relaunch != "" {
|
||||
if relaunchErr := startRelaunch(relaunch, installDir); relaunchErr != nil {
|
||||
logger.Printf("relaunch after failed install: %v", relaunchErr)
|
||||
if relaunchErr := startRelaunchFn(relaunch, installDir); relaunchErr != nil {
|
||||
logger.Printf("relaunch after unstarted update failure: %v", relaunchErr)
|
||||
}
|
||||
}
|
||||
return 1
|
||||
}
|
||||
claimedInstaller, cleanupInstaller, err := stageInstallerFn(installer, installerSHA256)
|
||||
if err != nil {
|
||||
logger.Printf("bind update installer: %v", err)
|
||||
return recoverUnstarted()
|
||||
}
|
||||
defer func() {
|
||||
if cleanupErr := cleanupInstaller(); cleanupErr != nil {
|
||||
logger.Printf("preserving update installer staging: %v", cleanupErr)
|
||||
}
|
||||
}()
|
||||
stagingDir, err := os.MkdirTemp("", "reasonix-update-stage-*")
|
||||
if err != nil {
|
||||
logger.Printf("create update staging: %v", err)
|
||||
return recoverUnstarted()
|
||||
}
|
||||
stagingOwner, err := lstatUpdateStagingFn(stagingDir)
|
||||
if err != nil {
|
||||
logger.Printf("bind update staging: %v", err)
|
||||
return recoverUnstarted()
|
||||
}
|
||||
defer func() {
|
||||
if cleanupErr := cleanupOwnedWindowsUpdateDirectory(stagingDir, stagingOwner); cleanupErr != nil {
|
||||
logger.Printf("preserving update staging: %v", cleanupErr)
|
||||
}
|
||||
}()
|
||||
releaseInstallerExecution, err := claimInstallerExecutionFn(claimedInstaller, installerSHA256)
|
||||
if err != nil {
|
||||
logger.Printf("recheck staged installer: %v", err)
|
||||
return recoverUnstarted()
|
||||
}
|
||||
installerErr := runInstallerFn(claimedInstaller, stagingDir)
|
||||
releaseInstallerExecution()
|
||||
if installerErr != nil {
|
||||
logger.Printf("extract installer payload: %v", installerErr)
|
||||
return recoverUnstarted()
|
||||
}
|
||||
publishStarted, receipts, err := installStagedReleaseUnitFn(claimed, stagingDir)
|
||||
if err != nil {
|
||||
logger.Printf("publish staged release unit: %v", err)
|
||||
if !publishStarted {
|
||||
releaseClaim()
|
||||
if cancelErr := repair.CancelPendingUpdateExact(claimed); cancelErr != nil {
|
||||
logger.Printf("cancel unstarted update: %v", cancelErr)
|
||||
}
|
||||
}
|
||||
if relaunch != "" {
|
||||
if relaunchErr := startRelaunchFn(relaunch, installDir); relaunchErr != nil {
|
||||
logger.Printf("relaunch after publish failure: %v", relaunchErr)
|
||||
}
|
||||
}
|
||||
return 1
|
||||
}
|
||||
if _, err := recordInstalledUpdateFn(claimed, receipts...); err != nil {
|
||||
logger.Printf("record installed release unit: %v", err)
|
||||
if markErr := repair.MarkUpdateApplyFailedExact(claimed, err.Error()); markErr != nil {
|
||||
logger.Printf("record install failure: %v", markErr)
|
||||
}
|
||||
if relaunch != "" {
|
||||
if relaunchErr := startRelaunchFn(relaunch, installDir); relaunchErr != nil {
|
||||
logger.Printf("relaunch after failed install verification: %v", relaunchErr)
|
||||
}
|
||||
}
|
||||
return 1
|
||||
}
|
||||
if err := repair.ClearUpdateApplyFailureExact(claimed); err != nil {
|
||||
logger.Printf("clear completed update marker: %v", err)
|
||||
}
|
||||
if relaunch != "" {
|
||||
if err := startRelaunch(relaunch, installDir); err != nil {
|
||||
if err := startRelaunchFn(relaunch, installDir); err != nil {
|
||||
logger.Printf("relaunch: %v", err)
|
||||
return 1
|
||||
}
|
||||
@@ -77,6 +191,179 @@ func run(args []string) int {
|
||||
return 0
|
||||
}
|
||||
|
||||
func validSHA256(value string) bool {
|
||||
value = strings.TrimSpace(value)
|
||||
if len(value) != sha256.Size*2 {
|
||||
return false
|
||||
}
|
||||
_, err := hex.DecodeString(value)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func stageVerifiedInstaller(sourcePath, expectedSHA256 string) (string, func() error, error) {
|
||||
if !validSHA256(expectedSHA256) {
|
||||
return "", nil, fmt.Errorf("installer SHA-256 is invalid")
|
||||
}
|
||||
sourceInfo, err := os.Lstat(sourcePath)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
if !sourceInfo.Mode().IsRegular() {
|
||||
return "", nil, fmt.Errorf("installer is not a regular file")
|
||||
}
|
||||
dir, err := os.MkdirTemp("", "reasonix-update-installer-*")
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
owner, err := os.Lstat(dir)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
cleanup := func() error {
|
||||
return cleanupOwnedWindowsUpdateDirectory(dir, owner)
|
||||
}
|
||||
fail := func(err error) (string, func() error, error) {
|
||||
_ = cleanup()
|
||||
return "", nil, err
|
||||
}
|
||||
source, err := os.Open(sourcePath)
|
||||
if err != nil {
|
||||
return fail(err)
|
||||
}
|
||||
defer source.Close()
|
||||
stagedPath := filepath.Join(dir, "reasonix-installer.exe")
|
||||
staged, err := os.OpenFile(stagedPath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o700)
|
||||
if err != nil {
|
||||
return fail(err)
|
||||
}
|
||||
hash := sha256.New()
|
||||
_, copyErr := io.Copy(io.MultiWriter(staged, hash), source)
|
||||
syncErr := staged.Sync()
|
||||
closeErr := staged.Close()
|
||||
switch {
|
||||
case copyErr != nil:
|
||||
return fail(copyErr)
|
||||
case syncErr != nil:
|
||||
return fail(syncErr)
|
||||
case closeErr != nil:
|
||||
return fail(closeErr)
|
||||
case !strings.EqualFold(hex.EncodeToString(hash.Sum(nil)), strings.TrimSpace(expectedSHA256)):
|
||||
return fail(fmt.Errorf("installer SHA-256 changed after desktop verification"))
|
||||
}
|
||||
if err := verifyInstallerSHA256(stagedPath, expectedSHA256); err != nil {
|
||||
return fail(err)
|
||||
}
|
||||
return stagedPath, cleanup, nil
|
||||
}
|
||||
|
||||
func verifyInstallerSHA256(path, expectedSHA256 string) error {
|
||||
info, err := os.Lstat(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !info.Mode().IsRegular() {
|
||||
return fmt.Errorf("installer is not a regular file")
|
||||
}
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
hash := sha256.New()
|
||||
if _, err := io.Copy(hash, f); err != nil {
|
||||
return err
|
||||
}
|
||||
if !strings.EqualFold(hex.EncodeToString(hash.Sum(nil)), strings.TrimSpace(expectedSHA256)) {
|
||||
return fmt.Errorf("installer SHA-256 mismatch")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func claimVerifiedInstallerForExecution(path, expectedSHA256 string) (func(), error) {
|
||||
if !validSHA256(expectedSHA256) {
|
||||
return nil, fmt.Errorf("installer SHA-256 is invalid")
|
||||
}
|
||||
pathUTF16, err := windows.UTF16PtrFromString(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
handle, err := windows.CreateFile(
|
||||
pathUTF16,
|
||||
windows.GENERIC_READ,
|
||||
windows.FILE_SHARE_READ,
|
||||
nil,
|
||||
windows.OPEN_EXISTING,
|
||||
windows.FILE_ATTRIBUTE_NORMAL|windows.FILE_FLAG_SEQUENTIAL_SCAN,
|
||||
0,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
file := os.NewFile(uintptr(handle), path)
|
||||
fail := func(err error) (func(), error) {
|
||||
_ = file.Close()
|
||||
return nil, err
|
||||
}
|
||||
info, err := file.Stat()
|
||||
if err != nil {
|
||||
return fail(err)
|
||||
}
|
||||
if !info.Mode().IsRegular() {
|
||||
return fail(fmt.Errorf("installer is not a regular file"))
|
||||
}
|
||||
hash := sha256.New()
|
||||
if _, err := io.Copy(hash, file); err != nil {
|
||||
return fail(err)
|
||||
}
|
||||
if !strings.EqualFold(hex.EncodeToString(hash.Sum(nil)), strings.TrimSpace(expectedSHA256)) {
|
||||
return fail(fmt.Errorf("installer SHA-256 mismatch"))
|
||||
}
|
||||
var once sync.Once
|
||||
return func() {
|
||||
once.Do(func() { _ = file.Close() })
|
||||
}, nil
|
||||
}
|
||||
|
||||
func installStagedWindowsReleaseUnit(
|
||||
claimed *repair.UpdateTransaction,
|
||||
stagingDir string,
|
||||
) (bool, []repair.FileUpdateInstallReceipt, error) {
|
||||
members, err := loadWindowsStagedReleaseUnit(claimed, stagingDir)
|
||||
if err != nil {
|
||||
return false, nil, err
|
||||
}
|
||||
// Persist recovery intent before the first live rename. If the helper exits
|
||||
// between release-unit members, Guard rolls the exact pending transaction
|
||||
// back instead of launching a mixed install.
|
||||
if err := repair.MarkUpdateApplyFailedExact(claimed, "Windows update publish did not complete"); err != nil {
|
||||
return false, nil, fmt.Errorf("record update recovery intent: %w", err)
|
||||
}
|
||||
receipts, err := publishLoadedFileUpdateReleaseUnit(claimed, members, repair.PublishClaimedFileUpdateMemberExact)
|
||||
if err != nil {
|
||||
return true, receipts, err
|
||||
}
|
||||
return true, receipts, nil
|
||||
}
|
||||
|
||||
func windowsReleaseUnitPaths(installDir string) []string {
|
||||
if installDir == "" {
|
||||
return nil
|
||||
}
|
||||
names := []string{
|
||||
"reasonix-desktop.exe",
|
||||
"reasonix-guard.exe",
|
||||
"reasonix-launcher.exe",
|
||||
"reasonix-update-helper.exe",
|
||||
"reasonix-cli.exe",
|
||||
"Reasonix.exe",
|
||||
}
|
||||
paths := make([]string, 0, len(names))
|
||||
for _, name := range names {
|
||||
paths = append(paths, filepath.Join(installDir, name))
|
||||
}
|
||||
return paths
|
||||
}
|
||||
|
||||
func newLogger() *log.Logger {
|
||||
dir, err := os.UserCacheDir()
|
||||
if err == nil {
|
||||
@@ -117,12 +404,55 @@ func waitForProcessExit(pid uint32, timeout time.Duration) error {
|
||||
func runInstaller(installer, installDir string) error {
|
||||
cmd := exec.Command(installer)
|
||||
// Keep the helper itself hidden, but let the NSIS update-progress window be
|
||||
// visible. The dedicated /REASONIXUPDATE mode prevents directory changes and
|
||||
// closes automatically after the update finishes.
|
||||
// visible. /REASONIXSTAGE makes the signed installer extract only; the helper
|
||||
// performs every live replacement through the claimed transaction.
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{CmdLine: installerCommandLine(installer, installDir)}
|
||||
return cmd.Run()
|
||||
}
|
||||
|
||||
func cleanupOwnedWindowsUpdateDirectory(path string, owner os.FileInfo) error {
|
||||
if path == "" || owner == nil || !owner.IsDir() {
|
||||
return fmt.Errorf("Windows update cleanup identity is incomplete")
|
||||
}
|
||||
for attempt := 0; attempt < 16; attempt++ {
|
||||
cleanup := fmt.Sprintf("%s.reasonix-cleanup-%d-%d", path, time.Now().UTC().UnixNano(), attempt)
|
||||
from, err := windows.UTF16PtrFromString(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
to, err := windows.UTF16PtrFromString(cleanup)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := windows.MoveFileEx(from, to, windows.MOVEFILE_WRITE_THROUGH); err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
if os.IsExist(err) {
|
||||
continue
|
||||
}
|
||||
return err
|
||||
}
|
||||
actual, err := os.Lstat(cleanup)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !os.SameFile(owner, actual) {
|
||||
restoreFrom, fromErr := windows.UTF16PtrFromString(cleanup)
|
||||
restoreTo, toErr := windows.UTF16PtrFromString(path)
|
||||
if fromErr != nil || toErr != nil {
|
||||
return fmt.Errorf("Windows update staging changed before cleanup; preserve replacement at %s", cleanup)
|
||||
}
|
||||
if restoreErr := windows.MoveFileEx(restoreFrom, restoreTo, windows.MOVEFILE_WRITE_THROUGH); restoreErr != nil {
|
||||
return fmt.Errorf("Windows update staging changed before cleanup; preserve replacement at %s: %w", cleanup, restoreErr)
|
||||
}
|
||||
return fmt.Errorf("Windows update staging changed before cleanup")
|
||||
}
|
||||
return os.RemoveAll(cleanup)
|
||||
}
|
||||
return fmt.Errorf("cannot allocate Windows update cleanup path")
|
||||
}
|
||||
|
||||
func startRelaunch(relaunch, installDir string) error {
|
||||
cmd := exec.Command(relaunch)
|
||||
cmd.Dir = installDir
|
||||
|
||||
@@ -2,10 +2,427 @@
|
||||
|
||||
package main
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/repair"
|
||||
)
|
||||
|
||||
const testInstallerSHA256 = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
|
||||
|
||||
func TestStageVerifiedInstallerFreezesExpectedBytes(t *testing.T) {
|
||||
source := filepath.Join(t.TempDir(), "installer.exe")
|
||||
content := []byte("verified-installer")
|
||||
if err := os.WriteFile(source, content, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sum := sha256.Sum256(content)
|
||||
expected := hex.EncodeToString(sum[:])
|
||||
staged, cleanup, err := stageVerifiedInstaller(source, expected)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = cleanup() })
|
||||
if staged == source {
|
||||
t.Fatal("installer was not isolated from the mutable cache path")
|
||||
}
|
||||
if err := os.WriteFile(source, []byte("changed-after-handoff"), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := verifyInstallerSHA256(staged, expected); err != nil {
|
||||
t.Fatalf("isolated installer changed with source: %v", err)
|
||||
}
|
||||
if got, err := os.ReadFile(staged); err != nil || string(got) != string(content) {
|
||||
t.Fatalf("staged installer = %q, %v", got, err)
|
||||
}
|
||||
if err := os.WriteFile(staged, []byte("tampered"), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := verifyInstallerSHA256(staged, expected); err == nil {
|
||||
t.Fatal("tampered staged installer passed final verification")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStageVerifiedInstallerRejectsSourceHashDrift(t *testing.T) {
|
||||
source := filepath.Join(t.TempDir(), "installer.exe")
|
||||
if err := os.WriteFile(source, []byte("different"), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, cleanup, err := stageVerifiedInstaller(source, testInstallerSHA256); err == nil {
|
||||
if cleanup != nil {
|
||||
_ = cleanup()
|
||||
}
|
||||
t.Fatal("installer bytes different from desktop verification were accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunRequiresTargetVersionBeforeStartingInstaller(t *testing.T) {
|
||||
if code := run([]string{"--installer", `C:\Temp\Reasonix-installer.exe`}); code != 2 {
|
||||
if code := run([]string{
|
||||
"--installer", `C:\Temp\Reasonix-installer.exe`,
|
||||
"--installer-sha256", testInstallerSHA256,
|
||||
}); code != 2 {
|
||||
t.Fatalf("run without --to-version = %d, want 2", code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunHoldsReleaseUnitLockAcrossInstallerHandoff(t *testing.T) {
|
||||
installDir := t.TempDir()
|
||||
var events []string
|
||||
originalWait := waitForProcessExitFn
|
||||
originalInstaller := runInstallerFn
|
||||
originalRelaunch := startRelaunchFn
|
||||
originalClaim := claimPendingFileUpdateFn
|
||||
originalInstallStaged := installStagedReleaseUnitFn
|
||||
originalRecord := recordInstalledUpdateFn
|
||||
originalStageInstaller := stageInstallerFn
|
||||
originalClaimInstallerExecution := claimInstallerExecutionFn
|
||||
t.Cleanup(func() {
|
||||
waitForProcessExitFn = originalWait
|
||||
runInstallerFn = originalInstaller
|
||||
startRelaunchFn = originalRelaunch
|
||||
claimPendingFileUpdateFn = originalClaim
|
||||
installStagedReleaseUnitFn = originalInstallStaged
|
||||
recordInstalledUpdateFn = originalRecord
|
||||
stageInstallerFn = originalStageInstaller
|
||||
claimInstallerExecutionFn = originalClaimInstallerExecution
|
||||
})
|
||||
waitForProcessExitFn = func(uint32, time.Duration) error {
|
||||
events = append(events, "wait")
|
||||
return nil
|
||||
}
|
||||
runInstallerFn = func(string, string) error {
|
||||
events = append(events, "installer")
|
||||
return nil
|
||||
}
|
||||
startRelaunchFn = func(string, string) error {
|
||||
events = append(events, "relaunch")
|
||||
return nil
|
||||
}
|
||||
claimPendingFileUpdateFn = func(toVersion, createdAt, transactionID, launcherPath string, paths []string, _ time.Duration) (*repair.UpdateTransaction, func(), error) {
|
||||
events = append(events, "claim:"+strings.Join([]string{toVersion, createdAt, transactionID, launcherPath, strings.Join(paths, "\x00")}, "\x01"))
|
||||
return &repair.UpdateTransaction{}, func() { events = append(events, "release") }, nil
|
||||
}
|
||||
installStagedReleaseUnitFn = func(*repair.UpdateTransaction, string) (bool, []repair.FileUpdateInstallReceipt, error) {
|
||||
events = append(events, "publish")
|
||||
return true, nil, nil
|
||||
}
|
||||
recordInstalledUpdateFn = func(tx *repair.UpdateTransaction, _ ...repair.FileUpdateInstallReceipt) (*repair.UpdateTransaction, error) {
|
||||
events = append(events, "record-installed")
|
||||
return tx, nil
|
||||
}
|
||||
stageInstallerFn = func(path, sha256 string) (string, func() error, error) {
|
||||
events = append(events, "stage-installer:"+sha256)
|
||||
return path + ".claimed", func() error { return nil }, nil
|
||||
}
|
||||
claimInstallerExecutionFn = func(string, string) (func(), error) {
|
||||
events = append(events, "claim-installer")
|
||||
return func() { events = append(events, "release-installer") }, nil
|
||||
}
|
||||
|
||||
if code := run([]string{
|
||||
"--parent-pid", "1234",
|
||||
"--installer", filepath.Join(installDir, "installer.exe"),
|
||||
"--installer-sha256", testInstallerSHA256,
|
||||
"--install-dir", installDir,
|
||||
"--relaunch", filepath.Join(installDir, "reasonix-desktop.exe"),
|
||||
"--to-version", "v2",
|
||||
"--created-at", "2026-07-29T00:00:00Z",
|
||||
"--transaction-id", "transaction-1",
|
||||
}); code != 0 {
|
||||
t.Fatalf("run exit code = %d", code)
|
||||
}
|
||||
wantClaim := "claim:" + strings.Join([]string{
|
||||
"v2",
|
||||
"2026-07-29T00:00:00Z",
|
||||
"transaction-1",
|
||||
filepath.Join(installDir, "reasonix-desktop.exe"),
|
||||
strings.Join(windowsReleaseUnitPaths(installDir), "\x00"),
|
||||
}, "\x01")
|
||||
if len(events) != 10 || events[0] != "wait" || events[1] != wantClaim ||
|
||||
events[2] != "stage-installer:"+testInstallerSHA256 ||
|
||||
events[3] != "claim-installer" || events[4] != "installer" ||
|
||||
events[5] != "release-installer" || events[6] != "publish" ||
|
||||
events[7] != "record-installed" || events[8] != "relaunch" ||
|
||||
events[9] != "release" {
|
||||
t.Fatalf("handoff events = %#v", events)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunDoesNotClaimUpdateBeforeParentExits(t *testing.T) {
|
||||
installDir := t.TempDir()
|
||||
originalWait := waitForProcessExitFn
|
||||
originalInstaller := runInstallerFn
|
||||
originalClaim := claimPendingFileUpdateFn
|
||||
originalRecord := recordInstalledUpdateFn
|
||||
t.Cleanup(func() {
|
||||
waitForProcessExitFn = originalWait
|
||||
runInstallerFn = originalInstaller
|
||||
claimPendingFileUpdateFn = originalClaim
|
||||
recordInstalledUpdateFn = originalRecord
|
||||
})
|
||||
waitForProcessExitFn = func(uint32, time.Duration) error {
|
||||
return errors.New("parent still running")
|
||||
}
|
||||
claimed := false
|
||||
claimPendingFileUpdateFn = func(string, string, string, string, []string, time.Duration) (*repair.UpdateTransaction, func(), error) {
|
||||
claimed = true
|
||||
return &repair.UpdateTransaction{}, func() {}, nil
|
||||
}
|
||||
installerRan := false
|
||||
runInstallerFn = func(string, string) error {
|
||||
installerRan = true
|
||||
return nil
|
||||
}
|
||||
|
||||
if code := run([]string{
|
||||
"--parent-pid", "1234",
|
||||
"--installer", filepath.Join(installDir, "installer.exe"),
|
||||
"--installer-sha256", testInstallerSHA256,
|
||||
"--install-dir", installDir,
|
||||
"--to-version", "v2",
|
||||
"--created-at", "2026-07-29T00:00:00Z",
|
||||
"--transaction-id", "transaction-1",
|
||||
}); code != 1 {
|
||||
t.Fatalf("run exit code = %d, want 1", code)
|
||||
}
|
||||
if claimed || installerRan {
|
||||
t.Fatalf("claim=%v installer=%v before parent exit", claimed, installerRan)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunCancelsTransactionWhenStagedExtractionFails(t *testing.T) {
|
||||
t.Setenv("REASONIX_HOME", t.TempDir())
|
||||
installDir := t.TempDir()
|
||||
originalWait := waitForProcessExitFn
|
||||
originalInstaller := runInstallerFn
|
||||
originalRelaunch := startRelaunchFn
|
||||
originalClaim := claimPendingFileUpdateFn
|
||||
originalInstallStaged := installStagedReleaseUnitFn
|
||||
originalRecord := recordInstalledUpdateFn
|
||||
originalStageInstaller := stageInstallerFn
|
||||
originalClaimInstallerExecution := claimInstallerExecutionFn
|
||||
t.Cleanup(func() {
|
||||
waitForProcessExitFn = originalWait
|
||||
runInstallerFn = originalInstaller
|
||||
startRelaunchFn = originalRelaunch
|
||||
claimPendingFileUpdateFn = originalClaim
|
||||
installStagedReleaseUnitFn = originalInstallStaged
|
||||
recordInstalledUpdateFn = originalRecord
|
||||
stageInstallerFn = originalStageInstaller
|
||||
claimInstallerExecutionFn = originalClaimInstallerExecution
|
||||
})
|
||||
waitForProcessExitFn = func(uint32, time.Duration) error { return nil }
|
||||
runInstallerFn = func(string, string) error { return errors.New("installer failed") }
|
||||
startRelaunchFn = func(string, string) error { return nil }
|
||||
claimed := &repair.UpdateTransaction{
|
||||
SchemaVersion: 1,
|
||||
ToVersion: "v2",
|
||||
CreatedAt: "2026-07-29T00:00:00Z",
|
||||
TargetKind: "file",
|
||||
}
|
||||
claimPendingFileUpdateFn = func(string, string, string, string, []string, time.Duration) (*repair.UpdateTransaction, func(), error) {
|
||||
return claimed, func() {}, nil
|
||||
}
|
||||
stageInstallerFn = func(path, _ string) (string, func() error, error) {
|
||||
return path, func() error { return nil }, nil
|
||||
}
|
||||
claimInstallerExecutionFn = func(string, string) (func(), error) {
|
||||
return func() {}, nil
|
||||
}
|
||||
const createdAt = "2026-07-29T00:00:00Z"
|
||||
transactionID := repair.UpdateTransactionID(claimed)
|
||||
if code := run([]string{
|
||||
"--installer", filepath.Join(installDir, "installer.exe"),
|
||||
"--installer-sha256", testInstallerSHA256,
|
||||
"--install-dir", installDir,
|
||||
"--relaunch", filepath.Join(installDir, "reasonix-desktop.exe"),
|
||||
"--to-version", "v2",
|
||||
"--created-at", createdAt,
|
||||
"--transaction-id", transactionID,
|
||||
}); code != 1 {
|
||||
t.Fatalf("run exit code = %d, want 1", code)
|
||||
}
|
||||
if _, ok := repair.ReadUpdateApplyFailure(); ok {
|
||||
t.Fatal("staging-only installer failure left a recovery marker despite no live publish")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunTreatsInstalledReleaseUnitRecordingFailureAsApplyFailure(t *testing.T) {
|
||||
t.Setenv("REASONIX_HOME", t.TempDir())
|
||||
installDir := t.TempDir()
|
||||
originalWait := waitForProcessExitFn
|
||||
originalInstaller := runInstallerFn
|
||||
originalRelaunch := startRelaunchFn
|
||||
originalClaim := claimPendingFileUpdateFn
|
||||
originalRecord := recordInstalledUpdateFn
|
||||
originalStageInstaller := stageInstallerFn
|
||||
originalClaimInstallerExecution := claimInstallerExecutionFn
|
||||
t.Cleanup(func() {
|
||||
waitForProcessExitFn = originalWait
|
||||
runInstallerFn = originalInstaller
|
||||
startRelaunchFn = originalRelaunch
|
||||
claimPendingFileUpdateFn = originalClaim
|
||||
recordInstalledUpdateFn = originalRecord
|
||||
stageInstallerFn = originalStageInstaller
|
||||
claimInstallerExecutionFn = originalClaimInstallerExecution
|
||||
})
|
||||
waitForProcessExitFn = func(uint32, time.Duration) error { return nil }
|
||||
runInstallerFn = func(string, string) error { return nil }
|
||||
relaunched := false
|
||||
startRelaunchFn = func(string, string) error {
|
||||
relaunched = true
|
||||
return nil
|
||||
}
|
||||
claimed := &repair.UpdateTransaction{
|
||||
SchemaVersion: 1,
|
||||
ToVersion: "v2",
|
||||
CreatedAt: "2026-07-29T00:00:00Z",
|
||||
TargetKind: "file",
|
||||
}
|
||||
claimPendingFileUpdateFn = func(string, string, string, string, []string, time.Duration) (*repair.UpdateTransaction, func(), error) {
|
||||
return claimed, func() {}, nil
|
||||
}
|
||||
stageInstallerFn = func(path, _ string) (string, func() error, error) {
|
||||
return path, func() error { return nil }, nil
|
||||
}
|
||||
claimInstallerExecutionFn = func(string, string) (func(), error) {
|
||||
return func() {}, nil
|
||||
}
|
||||
installStagedReleaseUnitFn = func(*repair.UpdateTransaction, string) (bool, []repair.FileUpdateInstallReceipt, error) {
|
||||
return true, nil, nil
|
||||
}
|
||||
recordInstalledUpdateFn = func(*repair.UpdateTransaction, ...repair.FileUpdateInstallReceipt) (*repair.UpdateTransaction, error) {
|
||||
return nil, errors.New("release unit drifted")
|
||||
}
|
||||
transactionID := repair.UpdateTransactionID(claimed)
|
||||
|
||||
if code := run([]string{
|
||||
"--installer", filepath.Join(installDir, "installer.exe"),
|
||||
"--installer-sha256", testInstallerSHA256,
|
||||
"--install-dir", installDir,
|
||||
"--relaunch", filepath.Join(installDir, "reasonix-desktop.exe"),
|
||||
"--to-version", "v2",
|
||||
"--created-at", claimed.CreatedAt,
|
||||
"--transaction-id", transactionID,
|
||||
}); code != 1 {
|
||||
t.Fatalf("run exit code = %d, want 1", code)
|
||||
}
|
||||
if !relaunched {
|
||||
t.Fatal("helper did not relaunch through recovery after post-install verification failure")
|
||||
}
|
||||
failure, ok := repair.ReadUpdateApplyFailure()
|
||||
if !ok || failure.UpdateTransactionID != transactionID {
|
||||
t.Fatalf("failure marker = %+v, present=%v", failure, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunRelaunchesWhenStagingIdentityCannotBeBound(t *testing.T) {
|
||||
t.Setenv("REASONIX_HOME", t.TempDir())
|
||||
installDir := t.TempDir()
|
||||
originalWait := waitForProcessExitFn
|
||||
originalInstaller := runInstallerFn
|
||||
originalRelaunch := startRelaunchFn
|
||||
originalClaim := claimPendingFileUpdateFn
|
||||
originalStageInstaller := stageInstallerFn
|
||||
originalLstatStaging := lstatUpdateStagingFn
|
||||
t.Cleanup(func() {
|
||||
waitForProcessExitFn = originalWait
|
||||
runInstallerFn = originalInstaller
|
||||
startRelaunchFn = originalRelaunch
|
||||
claimPendingFileUpdateFn = originalClaim
|
||||
stageInstallerFn = originalStageInstaller
|
||||
lstatUpdateStagingFn = originalLstatStaging
|
||||
})
|
||||
waitForProcessExitFn = func(uint32, time.Duration) error { return nil }
|
||||
installerRan := false
|
||||
runInstallerFn = func(string, string) error {
|
||||
installerRan = true
|
||||
return nil
|
||||
}
|
||||
relaunched := false
|
||||
startRelaunchFn = func(string, string) error {
|
||||
relaunched = true
|
||||
return nil
|
||||
}
|
||||
claimed := &repair.UpdateTransaction{
|
||||
SchemaVersion: 1,
|
||||
ToVersion: "v2",
|
||||
CreatedAt: "2026-07-29T00:00:00Z",
|
||||
TargetKind: "file",
|
||||
}
|
||||
releases := 0
|
||||
claimPendingFileUpdateFn = func(string, string, string, string, []string, time.Duration) (*repair.UpdateTransaction, func(), error) {
|
||||
return claimed, func() { releases++ }, nil
|
||||
}
|
||||
stageInstallerFn = func(path, _ string) (string, func() error, error) {
|
||||
return path, func() error { return nil }, nil
|
||||
}
|
||||
lstatUpdateStagingFn = func(string) (os.FileInfo, error) {
|
||||
return nil, errors.New("staging identity unavailable")
|
||||
}
|
||||
|
||||
if code := run([]string{
|
||||
"--installer", filepath.Join(installDir, "installer.exe"),
|
||||
"--installer-sha256", testInstallerSHA256,
|
||||
"--install-dir", installDir,
|
||||
"--relaunch", filepath.Join(installDir, "reasonix-desktop.exe"),
|
||||
"--to-version", claimed.ToVersion,
|
||||
"--created-at", claimed.CreatedAt,
|
||||
"--transaction-id", repair.UpdateTransactionID(claimed),
|
||||
}); code != 1 {
|
||||
t.Fatalf("run exit code = %d, want 1", code)
|
||||
}
|
||||
if installerRan {
|
||||
t.Fatal("installer ran without a bound staging directory")
|
||||
}
|
||||
if !relaunched {
|
||||
t.Fatal("helper did not relaunch after staging identity failure")
|
||||
}
|
||||
if releases == 0 {
|
||||
t.Fatal("helper did not release the update claim")
|
||||
}
|
||||
}
|
||||
|
||||
func TestClaimVerifiedInstallerForExecutionFreezesPath(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "installer.exe")
|
||||
content := []byte("verified-installer")
|
||||
if err := os.WriteFile(path, content, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sum := sha256.Sum256(content)
|
||||
release, err := claimVerifiedInstallerForExecution(path, hex.EncodeToString(sum[:]))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte("tampered"), 0o700); err == nil {
|
||||
release()
|
||||
t.Fatal("installer remained writable while execution claim was held")
|
||||
}
|
||||
if err := os.Rename(path, path+".replaced"); err == nil {
|
||||
release()
|
||||
t.Fatal("installer remained renameable while execution claim was held")
|
||||
}
|
||||
release()
|
||||
release()
|
||||
if err := os.WriteFile(path, []byte("replacement"), 0o700); err != nil {
|
||||
t.Fatalf("installer remained frozen after claim release: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClaimVerifiedInstallerForExecutionRejectsHashDrift(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "installer.exe")
|
||||
if err := os.WriteFile(path, []byte("tampered"), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if release, err := claimVerifiedInstallerForExecution(path, testInstallerSHA256); err == nil {
|
||||
release()
|
||||
t.Fatal("installer with the wrong SHA-256 received an execution claim")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"reasonix/desktop/internal/update"
|
||||
"reasonix/internal/repair"
|
||||
)
|
||||
|
||||
const maxWindowsPayloadMetadataSize = 64 << 10
|
||||
|
||||
var verifyWindowsPayloadManifestFn = update.Verify
|
||||
|
||||
type stagedFileUpdateMember struct {
|
||||
targetPath string
|
||||
content []byte
|
||||
mode os.FileMode
|
||||
}
|
||||
|
||||
// loadWindowsStagedReleaseUnit validates and reads the complete NSIS payload
|
||||
// before any live release-unit member is moved. An existing Reasonix.exe is the
|
||||
// portable alias of reasonix-launcher.exe and reuses those staged bytes; an
|
||||
// installed package that did not have the alias remains unchanged.
|
||||
func loadWindowsStagedReleaseUnit(claimed *repair.UpdateTransaction, stagingDir string) ([]stagedFileUpdateMember, error) {
|
||||
if claimed == nil || claimed.TargetKind != "file" || len(claimed.Files) == 0 {
|
||||
return nil, fmt.Errorf("load staged release unit: transaction identity is incomplete")
|
||||
}
|
||||
if err := validateWindowsClaimedReleaseUnit(claimed); err != nil {
|
||||
return nil, fmt.Errorf("load staged release unit: %w", err)
|
||||
}
|
||||
stagingDir = filepath.Clean(strings.TrimSpace(stagingDir))
|
||||
if stagingDir == "" || stagingDir == "." || !filepath.IsAbs(stagingDir) {
|
||||
return nil, fmt.Errorf("load staged release unit: staging directory is invalid")
|
||||
}
|
||||
info, err := os.Lstat(stagingDir)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load staged release unit: inspect staging directory: %w", err)
|
||||
}
|
||||
if !info.IsDir() || info.Mode()&os.ModeSymlink != 0 {
|
||||
return nil, fmt.Errorf("load staged release unit: staging path is not a directory")
|
||||
}
|
||||
hashes, err := loadWindowsPayloadManifest(stagingDir, claimed.ToVersion)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load staged release unit: %w", err)
|
||||
}
|
||||
|
||||
contents := make(map[string][]byte)
|
||||
members := make([]stagedFileUpdateMember, 0, len(claimed.Files))
|
||||
seenTargets := make(map[string]struct{}, len(claimed.Files))
|
||||
for _, file := range claimed.Files {
|
||||
targetPath := filepath.Clean(strings.TrimSpace(file.TargetPath))
|
||||
targetKey := strings.ToLower(targetPath)
|
||||
if targetPath == "" || targetPath == "." {
|
||||
return nil, fmt.Errorf("load staged release unit: target path is invalid")
|
||||
}
|
||||
if _, ok := seenTargets[targetKey]; ok {
|
||||
return nil, fmt.Errorf("load staged release unit: duplicate target %s", filepath.Base(targetPath))
|
||||
}
|
||||
seenTargets[targetKey] = struct{}{}
|
||||
if strings.EqualFold(filepath.Base(targetPath), "Reasonix.exe") && file.MissingBefore {
|
||||
continue
|
||||
}
|
||||
|
||||
sourceName, err := windowsStagedSourceName(filepath.Base(targetPath))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sourcePath := filepath.Join(stagingDir, sourceName)
|
||||
content, ok := contents[sourceName]
|
||||
if !ok {
|
||||
sourceInfo, statErr := os.Lstat(sourcePath)
|
||||
if statErr != nil {
|
||||
return nil, fmt.Errorf("load staged release unit: inspect %s: %w", sourceName, statErr)
|
||||
}
|
||||
if !sourceInfo.Mode().IsRegular() {
|
||||
return nil, fmt.Errorf("load staged release unit: %s is not a regular file", sourceName)
|
||||
}
|
||||
content, err = readVerifiedWindowsStagedPayloadFn(sourcePath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load staged release unit: read %s: %w", sourceName, err)
|
||||
}
|
||||
if !strings.EqualFold(update.WindowsPayloadSHA256(content), hashes[sourceName]) {
|
||||
return nil, fmt.Errorf("load staged release unit: %s does not match the signed release manifest", sourceName)
|
||||
}
|
||||
contents[sourceName] = content
|
||||
}
|
||||
members = append(members, stagedFileUpdateMember{
|
||||
targetPath: targetPath,
|
||||
content: content,
|
||||
mode: 0o700,
|
||||
})
|
||||
}
|
||||
|
||||
// Publish the running desktop last. If an earlier member fails, the old
|
||||
// desktop remains the executable entry point that can report/retry recovery.
|
||||
sort.SliceStable(members, func(i, j int) bool {
|
||||
iPrimary := strings.EqualFold(members[i].targetPath, claimed.TargetPath)
|
||||
jPrimary := strings.EqualFold(members[j].targetPath, claimed.TargetPath)
|
||||
return !iPrimary && jPrimary
|
||||
})
|
||||
return members, nil
|
||||
}
|
||||
|
||||
func validateWindowsClaimedReleaseUnit(claimed *repair.UpdateTransaction) error {
|
||||
if claimed == nil ||
|
||||
!strings.EqualFold(filepath.Base(claimed.TargetPath), "reasonix-desktop.exe") {
|
||||
return fmt.Errorf("claimed release unit primary executable is invalid")
|
||||
}
|
||||
required := map[string]bool{
|
||||
"reasonix-desktop.exe": false,
|
||||
"reasonix-guard.exe": false,
|
||||
"reasonix-launcher.exe": false,
|
||||
"reasonix-update-helper.exe": false,
|
||||
"reasonix-cli.exe": false,
|
||||
"reasonix.exe": false,
|
||||
}
|
||||
installDir := filepath.Clean(filepath.Dir(claimed.TargetPath))
|
||||
for _, file := range claimed.Files {
|
||||
target := filepath.Clean(strings.TrimSpace(file.TargetPath))
|
||||
if target == "" || target == "." ||
|
||||
!strings.EqualFold(filepath.Dir(target), installDir) {
|
||||
return fmt.Errorf("claimed release unit target is outside the installation directory")
|
||||
}
|
||||
name := strings.ToLower(filepath.Base(target))
|
||||
seen, ok := required[name]
|
||||
if !ok {
|
||||
return fmt.Errorf("claimed release unit contains an unexpected target")
|
||||
}
|
||||
if seen {
|
||||
return fmt.Errorf("claimed release unit contains a duplicate target")
|
||||
}
|
||||
required[name] = true
|
||||
}
|
||||
for name, seen := range required {
|
||||
if !seen {
|
||||
return fmt.Errorf("claimed release unit omits %s", name)
|
||||
}
|
||||
}
|
||||
if len(claimed.Files) != len(required) {
|
||||
return fmt.Errorf("claimed release unit contains an unexpected target")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func loadWindowsPayloadManifest(stagingDir, expectedVersion string) (map[string]string, error) {
|
||||
manifest, err := readWindowsPayloadMetadata(filepath.Join(stagingDir, update.WindowsPayloadManifestName))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read signed release manifest: %w", err)
|
||||
}
|
||||
signature, err := readWindowsPayloadMetadata(filepath.Join(stagingDir, update.WindowsPayloadSignatureName))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read signed release manifest signature: %w", err)
|
||||
}
|
||||
if err := verifyWindowsPayloadManifestFn(manifest, signature); err != nil {
|
||||
return nil, fmt.Errorf("verify signed release manifest: %w", err)
|
||||
}
|
||||
hashes, err := update.DecodeWindowsPayloadManifest(manifest, expectedVersion)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return hashes, nil
|
||||
}
|
||||
|
||||
func readWindowsPayloadMetadata(path string) ([]byte, error) {
|
||||
pathInfo, err := os.Lstat(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !pathInfo.Mode().IsRegular() {
|
||||
return nil, fmt.Errorf("%s is not a regular file", filepath.Base(path))
|
||||
}
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer file.Close()
|
||||
info, err := file.Stat()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !info.Mode().IsRegular() || info.Size() <= 0 || info.Size() > maxWindowsPayloadMetadataSize {
|
||||
return nil, fmt.Errorf("%s is not a bounded regular file", filepath.Base(path))
|
||||
}
|
||||
if !os.SameFile(pathInfo, info) {
|
||||
return nil, fmt.Errorf("%s changed before it was opened", filepath.Base(path))
|
||||
}
|
||||
data, err := io.ReadAll(io.LimitReader(file, maxWindowsPayloadMetadataSize+1))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(data) == 0 || len(data) > maxWindowsPayloadMetadataSize {
|
||||
return nil, fmt.Errorf("%s changed size while it was read", filepath.Base(path))
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func windowsStagedSourceName(targetBase string) (string, error) {
|
||||
switch strings.ToLower(strings.TrimSpace(targetBase)) {
|
||||
case "reasonix-desktop.exe":
|
||||
return "reasonix-desktop.exe", nil
|
||||
case "reasonix-guard.exe":
|
||||
return "reasonix-guard.exe", nil
|
||||
case "reasonix-launcher.exe":
|
||||
return "reasonix-launcher.exe", nil
|
||||
case "reasonix-update-helper.exe":
|
||||
return "reasonix-update-helper.exe", nil
|
||||
case "reasonix-cli.exe":
|
||||
return "reasonix-cli.exe", nil
|
||||
case "reasonix.exe":
|
||||
return "reasonix-launcher.exe", nil
|
||||
default:
|
||||
return "", fmt.Errorf("load staged release unit: unsupported target %q", targetBase)
|
||||
}
|
||||
}
|
||||
|
||||
func publishLoadedFileUpdateReleaseUnit(
|
||||
claimed *repair.UpdateTransaction,
|
||||
members []stagedFileUpdateMember,
|
||||
publish func(*repair.UpdateTransaction, string, []byte, os.FileMode) (repair.FileUpdateInstallReceipt, error),
|
||||
) ([]repair.FileUpdateInstallReceipt, error) {
|
||||
if publish == nil || len(members) == 0 {
|
||||
return nil, fmt.Errorf("publish staged release unit: payload is incomplete")
|
||||
}
|
||||
receipts := make([]repair.FileUpdateInstallReceipt, 0, len(members))
|
||||
for _, member := range members {
|
||||
receipt, err := publish(claimed, member.targetPath, member.content, member.mode)
|
||||
if err != nil {
|
||||
return receipts, fmt.Errorf("publish staged release unit %s: %w", filepath.Base(member.targetPath), err)
|
||||
}
|
||||
receipts = append(receipts, receipt)
|
||||
}
|
||||
return receipts, nil
|
||||
}
|
||||
@@ -0,0 +1,376 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"reasonix/desktop/internal/update"
|
||||
"reasonix/internal/repair"
|
||||
)
|
||||
|
||||
func TestLoadWindowsStagedReleaseUnitPreflightsAllMembersAndPublishesDesktopLast(t *testing.T) {
|
||||
staging := t.TempDir()
|
||||
for name, content := range map[string]string{
|
||||
"reasonix-desktop.exe": "desktop-v2",
|
||||
"reasonix-guard.exe": "guard-v2",
|
||||
"reasonix-launcher.exe": "launcher-v2",
|
||||
"reasonix-update-helper.exe": "helper-v2",
|
||||
"reasonix-cli.exe": "cli-v2",
|
||||
} {
|
||||
if err := os.WriteFile(filepath.Join(staging, name), []byte(content), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
useTestWindowsPayloadManifest(t, staging, "v2")
|
||||
installDir := t.TempDir()
|
||||
claimed := &repair.UpdateTransaction{
|
||||
SchemaVersion: 1,
|
||||
ToVersion: "v2",
|
||||
TargetKind: "file",
|
||||
TargetPath: filepath.Join(installDir, "reasonix-desktop.exe"),
|
||||
Files: []repair.UpdateTransactionFile{
|
||||
{TargetPath: filepath.Join(installDir, "reasonix-desktop.exe")},
|
||||
{TargetPath: filepath.Join(installDir, "reasonix-guard.exe")},
|
||||
{TargetPath: filepath.Join(installDir, "reasonix-launcher.exe")},
|
||||
{TargetPath: filepath.Join(installDir, "reasonix-update-helper.exe")},
|
||||
{TargetPath: filepath.Join(installDir, "reasonix-cli.exe")},
|
||||
{TargetPath: filepath.Join(installDir, "Reasonix.exe")},
|
||||
},
|
||||
}
|
||||
|
||||
members, err := loadWindowsStagedReleaseUnit(claimed, staging)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := filepath.Base(members[len(members)-1].targetPath); !strings.EqualFold(got, "reasonix-desktop.exe") {
|
||||
t.Fatalf("last published member = %q, want desktop", got)
|
||||
}
|
||||
var published []string
|
||||
receipts, err := publishLoadedFileUpdateReleaseUnit(claimed, members, func(_ *repair.UpdateTransaction, target string, content []byte, _ os.FileMode) (repair.FileUpdateInstallReceipt, error) {
|
||||
published = append(published, filepath.Base(target)+"="+string(content))
|
||||
return repair.FileUpdateInstallReceipt{TargetPath: target}, nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(receipts) != len(members) {
|
||||
t.Fatalf("publish receipts = %d, want %d", len(receipts), len(members))
|
||||
}
|
||||
if got := strings.Join(published, ","); !strings.Contains(got, "Reasonix.exe=launcher-v2") {
|
||||
t.Fatalf("portable alias did not reuse launcher payload: %s", got)
|
||||
}
|
||||
if !strings.HasPrefix(published[len(published)-1], "reasonix-desktop.exe=") {
|
||||
t.Fatalf("publish order = %v", published)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadWindowsStagedReleaseUnitRejectsIncompletePayloadBeforePublish(t *testing.T) {
|
||||
staging := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(staging, "reasonix-desktop.exe"), []byte("desktop-v2"), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
useTestWindowsPayloadManifest(t, staging, "v2")
|
||||
installDir := t.TempDir()
|
||||
claimed := &repair.UpdateTransaction{
|
||||
SchemaVersion: 1,
|
||||
ToVersion: "v2",
|
||||
TargetKind: "file",
|
||||
TargetPath: filepath.Join(installDir, "reasonix-desktop.exe"),
|
||||
Files: []repair.UpdateTransactionFile{
|
||||
{TargetPath: filepath.Join(installDir, "reasonix-desktop.exe")},
|
||||
{TargetPath: filepath.Join(installDir, "reasonix-guard.exe")},
|
||||
},
|
||||
}
|
||||
if _, err := loadWindowsStagedReleaseUnit(claimed, staging); err == nil {
|
||||
t.Fatal("incomplete staged release unit was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateWindowsClaimedReleaseUnitRequiresExactTargets(t *testing.T) {
|
||||
_, complete := completeWindowsStagedReleaseUnitForTest(t, "v2")
|
||||
for i, file := range complete.Files {
|
||||
t.Run("missing-"+strings.ToLower(filepath.Base(file.TargetPath)), func(t *testing.T) {
|
||||
claimed := *complete
|
||||
claimed.Files = append([]repair.UpdateTransactionFile(nil), complete.Files[:i]...)
|
||||
claimed.Files = append(claimed.Files, complete.Files[i+1:]...)
|
||||
if err := validateWindowsClaimedReleaseUnit(&claimed); err == nil ||
|
||||
!strings.Contains(err.Error(), "omits") {
|
||||
t.Fatalf("missing target error = %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
t.Run("extra", func(t *testing.T) {
|
||||
claimed := *complete
|
||||
claimed.Files = append(append([]repair.UpdateTransactionFile(nil), complete.Files...),
|
||||
repair.UpdateTransactionFile{TargetPath: filepath.Join(filepath.Dir(complete.TargetPath), "other.exe")})
|
||||
if err := validateWindowsClaimedReleaseUnit(&claimed); err == nil ||
|
||||
!strings.Contains(err.Error(), "unexpected") {
|
||||
t.Fatalf("extra target error = %v", err)
|
||||
}
|
||||
})
|
||||
t.Run("duplicate", func(t *testing.T) {
|
||||
claimed := *complete
|
||||
claimed.Files = append(append([]repair.UpdateTransactionFile(nil), complete.Files...), complete.Files[0])
|
||||
if err := validateWindowsClaimedReleaseUnit(&claimed); err == nil ||
|
||||
!strings.Contains(err.Error(), "duplicate") {
|
||||
t.Fatalf("duplicate target error = %v", err)
|
||||
}
|
||||
})
|
||||
t.Run("outside", func(t *testing.T) {
|
||||
claimed := *complete
|
||||
claimed.Files = append([]repair.UpdateTransactionFile(nil), complete.Files...)
|
||||
claimed.Files[1].TargetPath = filepath.Join(t.TempDir(), filepath.Base(claimed.Files[1].TargetPath))
|
||||
if err := validateWindowsClaimedReleaseUnit(&claimed); err == nil ||
|
||||
!strings.Contains(err.Error(), "outside") {
|
||||
t.Fatalf("outside target error = %v", err)
|
||||
}
|
||||
})
|
||||
t.Run("primary", func(t *testing.T) {
|
||||
claimed := *complete
|
||||
claimed.TargetPath = filepath.Join(filepath.Dir(complete.TargetPath), "reasonix-guard.exe")
|
||||
if err := validateWindowsClaimedReleaseUnit(&claimed); err == nil ||
|
||||
!strings.Contains(err.Error(), "primary") {
|
||||
t.Fatalf("primary target error = %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestLoadWindowsStagedReleaseUnitDoesNotCreateMissingPortableAlias(t *testing.T) {
|
||||
staging := t.TempDir()
|
||||
for _, name := range update.WindowsPayloadFileNames() {
|
||||
content := "payload:" + name
|
||||
if err := os.WriteFile(filepath.Join(staging, name), []byte(content), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
useTestWindowsPayloadManifest(t, staging, "v2")
|
||||
installDir := t.TempDir()
|
||||
claimed := &repair.UpdateTransaction{
|
||||
SchemaVersion: 1,
|
||||
ToVersion: "v2",
|
||||
TargetKind: "file",
|
||||
TargetPath: filepath.Join(installDir, "reasonix-desktop.exe"),
|
||||
Files: []repair.UpdateTransactionFile{
|
||||
{TargetPath: filepath.Join(installDir, "reasonix-desktop.exe")},
|
||||
{TargetPath: filepath.Join(installDir, "reasonix-guard.exe")},
|
||||
{TargetPath: filepath.Join(installDir, "reasonix-launcher.exe")},
|
||||
{TargetPath: filepath.Join(installDir, "reasonix-update-helper.exe")},
|
||||
{TargetPath: filepath.Join(installDir, "reasonix-cli.exe")},
|
||||
{TargetPath: filepath.Join(installDir, "Reasonix.exe"), MissingBefore: true},
|
||||
},
|
||||
}
|
||||
|
||||
members, err := loadWindowsStagedReleaseUnit(claimed, staging)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, member := range members {
|
||||
if strings.EqualFold(filepath.Base(member.targetPath), "Reasonix.exe") {
|
||||
t.Fatalf("missing portable alias was added to publish set: %+v", members)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublishLoadedFileUpdateReleaseUnitStopsOnFirstFailedCompareAndPublish(t *testing.T) {
|
||||
claimed := &repair.UpdateTransaction{TargetKind: "file"}
|
||||
members := []stagedFileUpdateMember{
|
||||
{targetPath: "guard.exe", content: []byte("guard"), mode: 0o700},
|
||||
{targetPath: "desktop.exe", content: []byte("desktop"), mode: 0o700},
|
||||
}
|
||||
var published []string
|
||||
receipts, err := publishLoadedFileUpdateReleaseUnit(claimed, members, func(_ *repair.UpdateTransaction, target string, _ []byte, _ os.FileMode) (repair.FileUpdateInstallReceipt, error) {
|
||||
published = append(published, target)
|
||||
return repair.FileUpdateInstallReceipt{}, errors.New("concurrent recreation")
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "concurrent recreation") {
|
||||
t.Fatalf("publish error = %v", err)
|
||||
}
|
||||
if len(published) != 1 {
|
||||
t.Fatalf("published members = %v, want one attempted member", published)
|
||||
}
|
||||
if len(receipts) != 0 {
|
||||
t.Fatalf("failed first publish returned receipts: %+v", receipts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadWindowsStagedReleaseUnitRejectsUnverifiedPayloadBeforePublish(t *testing.T) {
|
||||
staging := t.TempDir()
|
||||
for _, name := range []string{
|
||||
"reasonix-desktop.exe",
|
||||
"reasonix-guard.exe",
|
||||
"reasonix-launcher.exe",
|
||||
"reasonix-update-helper.exe",
|
||||
"reasonix-cli.exe",
|
||||
} {
|
||||
if err := os.WriteFile(filepath.Join(staging, name), []byte(name), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
writeWindowsPayloadManifestForTest(t, staging, "v2")
|
||||
acceptWindowsPayloadManifestForTest(t)
|
||||
_, claimed := completeWindowsStagedReleaseUnitForTest(t, "v2")
|
||||
original := readVerifiedWindowsStagedPayloadFn
|
||||
readVerifiedWindowsStagedPayloadFn = func(string) ([]byte, error) {
|
||||
return nil, errors.New("payload signature rejected")
|
||||
}
|
||||
t.Cleanup(func() { readVerifiedWindowsStagedPayloadFn = original })
|
||||
|
||||
if _, err := loadWindowsStagedReleaseUnit(claimed, staging); err == nil {
|
||||
t.Fatal("unsigned staged payload must be rejected before publication")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadWindowsStagedReleaseUnitReadsEachSourceThroughVerifier(t *testing.T) {
|
||||
staging := t.TempDir()
|
||||
for name, content := range map[string]string{
|
||||
"reasonix-desktop.exe": "desktop-v2",
|
||||
"reasonix-guard.exe": "guard-v2",
|
||||
"reasonix-launcher.exe": "launcher-v2",
|
||||
"reasonix-update-helper.exe": "helper-v2",
|
||||
"reasonix-cli.exe": "cli-v2",
|
||||
} {
|
||||
if err := os.WriteFile(filepath.Join(staging, name), []byte(content), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
writeWindowsPayloadManifestForTest(t, staging, "v2")
|
||||
acceptWindowsPayloadManifestForTest(t)
|
||||
_, claimed := completeWindowsStagedReleaseUnitForTest(t, "v2")
|
||||
var verified []string
|
||||
original := readVerifiedWindowsStagedPayloadFn
|
||||
readVerifiedWindowsStagedPayloadFn = func(path string) ([]byte, error) {
|
||||
verified = append(verified, filepath.Base(path))
|
||||
return os.ReadFile(path)
|
||||
}
|
||||
t.Cleanup(func() { readVerifiedWindowsStagedPayloadFn = original })
|
||||
|
||||
if _, err := loadWindowsStagedReleaseUnit(claimed, staging); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(verified) != len(update.WindowsPayloadFileNames()) {
|
||||
t.Fatalf("verified payload count = %d, want %d (%v)", len(verified), len(update.WindowsPayloadFileNames()), verified)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadWindowsStagedReleaseUnitRejectsMissingManifest(t *testing.T) {
|
||||
staging, claimed := completeWindowsStagedReleaseUnitForTest(t, "v2")
|
||||
useUnverifiedPayloadReaderForTest(t)
|
||||
if _, err := loadWindowsStagedReleaseUnit(claimed, staging); err == nil ||
|
||||
!strings.Contains(err.Error(), update.WindowsPayloadManifestName) {
|
||||
t.Fatalf("missing signed manifest error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadWindowsStagedReleaseUnitRejectsBadManifestSignature(t *testing.T) {
|
||||
staging, claimed := completeWindowsStagedReleaseUnitForTest(t, "v2")
|
||||
writeWindowsPayloadManifestForTest(t, staging, "v2")
|
||||
useUnverifiedPayloadReaderForTest(t)
|
||||
if _, err := loadWindowsStagedReleaseUnit(claimed, staging); err == nil ||
|
||||
!strings.Contains(err.Error(), "verify signed release manifest") {
|
||||
t.Fatalf("bad signed manifest error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadWindowsStagedReleaseUnitRejectsManifestVersionDrift(t *testing.T) {
|
||||
staging, claimed := completeWindowsStagedReleaseUnitForTest(t, "v2")
|
||||
writeWindowsPayloadManifestForTest(t, staging, "v3")
|
||||
acceptWindowsPayloadManifestForTest(t)
|
||||
useUnverifiedPayloadReaderForTest(t)
|
||||
if _, err := loadWindowsStagedReleaseUnit(claimed, staging); err == nil ||
|
||||
!strings.Contains(err.Error(), "identity does not match") {
|
||||
t.Fatalf("release version drift error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadWindowsStagedReleaseUnitRejectsManifestMemberHashDrift(t *testing.T) {
|
||||
staging, claimed := completeWindowsStagedReleaseUnitForTest(t, "v2")
|
||||
writeWindowsPayloadManifestForTest(t, staging, "v2")
|
||||
acceptWindowsPayloadManifestForTest(t)
|
||||
useUnverifiedPayloadReaderForTest(t)
|
||||
if err := os.WriteFile(filepath.Join(staging, "reasonix-guard.exe"), []byte("tampered"), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := loadWindowsStagedReleaseUnit(claimed, staging); err == nil ||
|
||||
!strings.Contains(err.Error(), "does not match the signed release manifest") {
|
||||
t.Fatalf("release member drift error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func completeWindowsStagedReleaseUnitForTest(t *testing.T, version string) (string, *repair.UpdateTransaction) {
|
||||
t.Helper()
|
||||
staging := t.TempDir()
|
||||
for _, name := range update.WindowsPayloadFileNames() {
|
||||
if err := os.WriteFile(filepath.Join(staging, name), []byte("payload:"+name), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
installDir := t.TempDir()
|
||||
files := make([]repair.UpdateTransactionFile, 0, len(update.WindowsPayloadFileNames())+1)
|
||||
for _, name := range update.WindowsPayloadFileNames() {
|
||||
files = append(files, repair.UpdateTransactionFile{TargetPath: filepath.Join(installDir, name)})
|
||||
}
|
||||
files = append(files, repair.UpdateTransactionFile{
|
||||
TargetPath: filepath.Join(installDir, "Reasonix.exe"),
|
||||
MissingBefore: true,
|
||||
})
|
||||
return staging, &repair.UpdateTransaction{
|
||||
SchemaVersion: 1,
|
||||
ToVersion: version,
|
||||
TargetKind: "file",
|
||||
TargetPath: filepath.Join(installDir, "reasonix-desktop.exe"),
|
||||
Files: files,
|
||||
}
|
||||
}
|
||||
|
||||
func writeWindowsPayloadManifestForTest(t *testing.T, staging, version string) {
|
||||
t.Helper()
|
||||
hashes := make(map[string]string)
|
||||
for _, name := range update.WindowsPayloadFileNames() {
|
||||
content, err := os.ReadFile(filepath.Join(staging, name))
|
||||
if os.IsNotExist(err) {
|
||||
content = []byte("missing:" + name)
|
||||
} else if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
hashes[name] = update.WindowsPayloadSHA256(content)
|
||||
}
|
||||
manifest, err := update.EncodeWindowsPayloadManifest(version, hashes)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(staging, update.WindowsPayloadManifestName), manifest, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(staging, update.WindowsPayloadSignatureName), []byte("test signature"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func acceptWindowsPayloadManifestForTest(t *testing.T) {
|
||||
t.Helper()
|
||||
original := verifyWindowsPayloadManifestFn
|
||||
verifyWindowsPayloadManifestFn = func(_, signature []byte) error {
|
||||
if string(signature) != "test signature" {
|
||||
return errors.New("unexpected test signature")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
t.Cleanup(func() { verifyWindowsPayloadManifestFn = original })
|
||||
}
|
||||
|
||||
func useTestWindowsPayloadManifest(t *testing.T, staging, version string) {
|
||||
t.Helper()
|
||||
writeWindowsPayloadManifestForTest(t, staging, version)
|
||||
acceptWindowsPayloadManifestForTest(t)
|
||||
useUnverifiedPayloadReaderForTest(t)
|
||||
}
|
||||
|
||||
func useUnverifiedPayloadReaderForTest(t *testing.T) {
|
||||
t.Helper()
|
||||
original := readVerifiedWindowsStagedPayloadFn
|
||||
readVerifiedWindowsStagedPayloadFn = os.ReadFile
|
||||
t.Cleanup(func() { readVerifiedWindowsStagedPayloadFn = original })
|
||||
}
|
||||
@@ -11,8 +11,9 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
// crash_app.go is the crash/feedback/performance reporting surface. Reports are
|
||||
// sent only on an explicit user click in the frontend UI — never automatically.
|
||||
// crash_app.go is the crash/feedback/performance reporting surface. Frontend
|
||||
// reports are sent on an explicit user click. Native fatal/lifecycle reports are
|
||||
// queued locally and sent on a later launch only when desktop telemetry is on.
|
||||
|
||||
var crashEndpoint = "https://crash.reasonix.io/v1/report"
|
||||
|
||||
|
||||
@@ -0,0 +1,270 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/repair"
|
||||
)
|
||||
|
||||
func TestParseWebView2ProcessFailure(t *testing.T) {
|
||||
kind, ok := parseWebView2ProcessFailure("windows | WebVie2wProcess failed with kind 6")
|
||||
if !ok || kind != 6 {
|
||||
t.Fatalf("kind=%d ok=%v", kind, ok)
|
||||
}
|
||||
if _, ok := parseWebView2ProcessFailure("unrelated failure"); ok {
|
||||
t.Fatal("unrelated log message matched WebView2 failure")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebView2ProcessFailureReportIsStructured(t *testing.T) {
|
||||
report := webView2ProcessFailureReport(2)
|
||||
if report.Source != "webview2.process" || report.Label != "windows.webview2.process_failed" {
|
||||
t.Fatalf("report = %+v", report)
|
||||
}
|
||||
if report.FingerprintHint != "windows.webview2.render_process_unresponsive" {
|
||||
t.Fatalf("fingerprint hint = %q", report.FingerprintHint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreviousRunReportUsesOnlyBoundedLifecycleContext(t *testing.T) {
|
||||
report := previousRunReport(repair.PreviousRunObservation{
|
||||
Abnormal: true,
|
||||
Phase: "healthy",
|
||||
Version: "v2",
|
||||
InstallProfile: "installer",
|
||||
UpdateFrom: "v1",
|
||||
UpdateTo: "v2",
|
||||
UptimeBucket: "m_2_10",
|
||||
})
|
||||
if report.Source != "native.lifecycle" || report.Label != "desktop.abnormal_exit" {
|
||||
t.Fatalf("report = %+v", report)
|
||||
}
|
||||
if !strings.Contains(report.Message, "uptime bucket: m_2_10") {
|
||||
t.Fatalf("message missing bounded uptime: %q", report.Message)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCapturePreviousFatalCrashQueuesAndRemovesRawDump(t *testing.T) {
|
||||
resetFatalCrashArtifacts(t)
|
||||
const crashedPID = 424242
|
||||
raw := "fatal error: concurrent map writes\n\ngoroutine 1 [running]:\nmain.run()\n\t/home/alice/project/main.go:12\n"
|
||||
path := fatalCrashPathForPID(crashedPID)
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte(raw), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
capturePreviousFatalCrash()
|
||||
if _, err := os.Stat(path); !os.IsNotExist(err) {
|
||||
t.Fatalf("raw fatal dump was not removed: %v", err)
|
||||
}
|
||||
report, ok := readPending(t)
|
||||
if !ok || report.Label != "go.fatal" || report.Source != "go.runtime" {
|
||||
t.Fatalf("queued report = %+v ok=%v", report, ok)
|
||||
}
|
||||
if strings.Contains(report.Stack, "/home/alice") {
|
||||
t.Fatalf("fatal stack leaked home path: %q", report.Stack)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizeFatalRuntimeDumpRemovesPanicValue(t *testing.T) {
|
||||
got := sanitizeFatalRuntimeDump("panic: private prompt text\n\ngoroutine 1 [running]:\nmain.run()\n\t/home/alice/project/main.go:12\n")
|
||||
if strings.Contains(got, "private prompt text") || strings.Contains(got, "/home/alice") {
|
||||
t.Fatalf("fatal dump leaked user-controlled text: %q", got)
|
||||
}
|
||||
if !strings.Contains(got, "panic: [redacted panic value]") || !strings.Contains(got, "main.go:12") {
|
||||
t.Fatalf("fatal dump lost diagnostic structure: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCapturePreviousFatalCrashSkipsRuntimeDuplicateOfStructuredPanic(t *testing.T) {
|
||||
resetFatalCrashArtifacts(t)
|
||||
const crashedPID = 424243
|
||||
path := fatalCrashPathForPID(crashedPID)
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte("panic: duplicate\n\ngoroutine 1 [running]:\nmain.run()\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
markFatalCrashCoveredForPID(crashedPID)
|
||||
capturePreviousFatalCrash()
|
||||
if _, ok := readPending(t); ok {
|
||||
t.Fatal("runtime duplicate was queued despite structured panic marker")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCapturePreviousFatalCrashDoesNotTouchLiveOwner(t *testing.T) {
|
||||
resetFatalCrashArtifacts(t)
|
||||
const livePID = 424244
|
||||
path := fatalCrashPathForPID(livePID)
|
||||
raw := []byte("fatal error: still being written\n\ngoroutine 1 [running]:\n")
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(path, raw, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
oldProcessAlive := fatalCrashProcessAlive
|
||||
fatalCrashProcessAlive = func(pid int) bool { return pid == livePID }
|
||||
t.Cleanup(func() { fatalCrashProcessAlive = oldProcessAlive })
|
||||
|
||||
capturePreviousFatalCrash()
|
||||
|
||||
got, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("live owner's fatal file was removed: %v", err)
|
||||
}
|
||||
if string(got) != string(raw) {
|
||||
t.Fatalf("live owner's fatal file changed: got %q want %q", got, raw)
|
||||
}
|
||||
if _, ok := readPending(t); ok {
|
||||
t.Fatal("live owner's partial fatal output was queued")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCapturePreviousFatalCrashKeepsEmptyLegacyFile(t *testing.T) {
|
||||
resetFatalCrashArtifacts(t)
|
||||
if err := os.MkdirAll(filepath.Dir(legacyFatalCrashPath()), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(legacyFatalCrashPath(), nil, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
capturePreviousFatalCrash()
|
||||
|
||||
if _, err := os.Stat(legacyFatalCrashPath()); err != nil {
|
||||
t.Fatalf("empty legacy fatal file may belong to a live older process: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCapturePreviousFatalCrashMigratesLegacyDump(t *testing.T) {
|
||||
resetFatalCrashArtifacts(t)
|
||||
raw := "fatal error: legacy crash\n\ngoroutine 1 [running]:\nmain.run()\n\t/home/alice/project/main.go:12\n"
|
||||
if err := os.MkdirAll(filepath.Dir(legacyFatalCrashPath()), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(legacyFatalCrashPath(), []byte(raw), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
capturePreviousFatalCrash()
|
||||
|
||||
if _, err := os.Stat(legacyFatalCrashPath()); !os.IsNotExist(err) {
|
||||
t.Fatalf("captured legacy fatal dump was not removed: %v", err)
|
||||
}
|
||||
report, ok := readPending(t)
|
||||
if !ok || report.Label != "go.fatal" || report.Source != "go.runtime" {
|
||||
t.Fatalf("legacy fatal dump was not migrated: report=%+v ok=%v", report, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAwaitWindowRestoreRequiresNativeConfirmation(t *testing.T) {
|
||||
ticks := make(chan time.Time)
|
||||
deadline := make(chan time.Time, 1)
|
||||
deadline <- time.Now()
|
||||
|
||||
if awaitWindowRestoreConfirmation(func() bool { return false }, ticks, deadline) {
|
||||
t.Fatal("an enqueued show request without native confirmation was treated as restored")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAwaitWindowRestoreAcceptsConfirmationAfterTick(t *testing.T) {
|
||||
ticks := make(chan time.Time, 1)
|
||||
deadline := make(chan time.Time)
|
||||
var confirmed atomic.Bool
|
||||
result := make(chan bool, 1)
|
||||
go func() {
|
||||
result <- awaitWindowRestoreConfirmation(confirmed.Load, ticks, deadline)
|
||||
}()
|
||||
|
||||
confirmed.Store(true)
|
||||
ticks <- time.Now()
|
||||
if !<-result {
|
||||
t.Fatal("native window confirmation was not accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSupersededWindowRestoreDoesNotRemoveLatestJournal(t *testing.T) {
|
||||
path := windowRestoreStatePath()
|
||||
_ = os.Remove(path)
|
||||
t.Cleanup(func() { _ = os.Remove(path) })
|
||||
oldSequence := windowRestoreSequence.Load()
|
||||
t.Cleanup(func() { windowRestoreSequence.Store(oldSequence) })
|
||||
|
||||
latest := windowRestoreState{
|
||||
SchemaVersion: windowRestoreStateVersion,
|
||||
PID: os.Getpid(),
|
||||
AttemptID: 2,
|
||||
Source: "tray",
|
||||
StartedAt: "2026-07-24T08:01:00Z",
|
||||
}
|
||||
if !writeWindowRestoreState(latest) {
|
||||
t.Fatal("write latest window restore state")
|
||||
}
|
||||
windowRestoreSequence.Store(latest.AttemptID)
|
||||
|
||||
NewApp().completeWindowRestoreAttempt(1, windowRestoreState{AttemptID: 1}, true)
|
||||
|
||||
got, err := readWindowRestoreState()
|
||||
if err != nil {
|
||||
t.Fatalf("latest journal was removed by superseded attempt: %v", err)
|
||||
}
|
||||
if got != latest {
|
||||
t.Fatalf("latest journal changed: got %+v want %+v", got, latest)
|
||||
}
|
||||
}
|
||||
|
||||
func resetFatalCrashArtifacts(t *testing.T) {
|
||||
t.Helper()
|
||||
oldProcessAlive := fatalCrashProcessAlive
|
||||
fatalCrashProcessAlive = func(int) bool { return false }
|
||||
removeAllPendingCrashes()
|
||||
_ = os.Remove(legacyFatalCrashPath())
|
||||
_ = os.Remove(legacyFatalCrashCoveredPath())
|
||||
_ = os.RemoveAll(fatalCrashDir())
|
||||
t.Cleanup(func() {
|
||||
removeAllPendingCrashes()
|
||||
_ = os.Remove(legacyFatalCrashPath())
|
||||
_ = os.Remove(legacyFatalCrashCoveredPath())
|
||||
_ = os.RemoveAll(fatalCrashDir())
|
||||
fatalCrashProcessAlive = oldProcessAlive
|
||||
})
|
||||
}
|
||||
|
||||
func TestWindowRestoreFailureReportSeparatesTimeoutAndSource(t *testing.T) {
|
||||
report := windowRestoreFailureReport("timeout", "second_instance", "2026-07-24T08:00:00Z")
|
||||
if report.Label != "windows.window_restore.timeout" || report.TopFrame != "windows.window_restore.second_instance" {
|
||||
t.Fatalf("report = %+v", report)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteWindowRestoreStateCreatesReadableJournal(t *testing.T) {
|
||||
path := windowRestoreStatePath()
|
||||
_ = os.Remove(path)
|
||||
t.Cleanup(func() { _ = os.Remove(path) })
|
||||
|
||||
want := windowRestoreState{
|
||||
SchemaVersion: windowRestoreStateVersion,
|
||||
PID: os.Getpid(),
|
||||
Source: "tray",
|
||||
StartedAt: "2026-07-24T08:00:00Z",
|
||||
}
|
||||
if !writeWindowRestoreState(want) {
|
||||
t.Fatal("writeWindowRestoreState returned false")
|
||||
}
|
||||
got, err := readWindowRestoreState()
|
||||
if err != nil {
|
||||
t.Fatalf("readWindowRestoreState: %v", err)
|
||||
}
|
||||
if got != want {
|
||||
t.Fatalf("journal = %+v, want %+v", got, want)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime/debug"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/config"
|
||||
)
|
||||
|
||||
const (
|
||||
fatalCrashDirName = "crash-fatal"
|
||||
fatalCrashLogSuffix = ".log"
|
||||
fatalCrashCoveredSuffix = ".covered"
|
||||
legacyFatalCrashFile = "crash-fatal.log"
|
||||
legacyFatalCrashCoveredFile = "crash-fatal-covered"
|
||||
)
|
||||
|
||||
var fatalCrashProcessAlive = desktopProcessAlive
|
||||
|
||||
func fatalCrashDir() string {
|
||||
return filepath.Join(config.MemoryUserDir(), fatalCrashDirName)
|
||||
}
|
||||
|
||||
func fatalCrashPath() string {
|
||||
return fatalCrashPathForPID(os.Getpid())
|
||||
}
|
||||
|
||||
func fatalCrashCoveredPath() string {
|
||||
return fatalCrashCoveredPathForPID(os.Getpid())
|
||||
}
|
||||
|
||||
func fatalCrashPathForPID(pid int) string {
|
||||
return filepath.Join(fatalCrashDir(), strconv.Itoa(pid)+fatalCrashLogSuffix)
|
||||
}
|
||||
|
||||
func fatalCrashCoveredPathForPID(pid int) string {
|
||||
return filepath.Join(fatalCrashDir(), strconv.Itoa(pid)+fatalCrashCoveredSuffix)
|
||||
}
|
||||
|
||||
func legacyFatalCrashPath() string {
|
||||
return filepath.Join(config.MemoryUserDir(), legacyFatalCrashFile)
|
||||
}
|
||||
|
||||
func legacyFatalCrashCoveredPath() string {
|
||||
return filepath.Join(config.MemoryUserDir(), legacyFatalCrashCoveredFile)
|
||||
}
|
||||
|
||||
func markFatalCrashCovered() {
|
||||
markFatalCrashCoveredForPID(os.Getpid())
|
||||
}
|
||||
|
||||
func markFatalCrashCoveredForPID(pid int) {
|
||||
path := fatalCrashCoveredPathForPID(pid)
|
||||
if os.MkdirAll(filepath.Dir(path), 0o700) == nil {
|
||||
_ = os.WriteFile(path, []byte("structured\n"), 0o600)
|
||||
}
|
||||
}
|
||||
|
||||
// capturePreviousFatalCrash converts runtime.SetCrashOutput dumps from dead
|
||||
// processes into the normal scrubbed queue. Per-PID files keep a routine second
|
||||
// launch from truncating or unlinking the running primary process's dump.
|
||||
func capturePreviousFatalCrash() {
|
||||
// Preserve compatibility with the single-file format used by older builds.
|
||||
// An empty legacy file may still be owned by a running older process, so it
|
||||
// must be left untouched until it contains a completed crash dump.
|
||||
captureFatalCrashFile(legacyFatalCrashPath(), legacyFatalCrashCoveredPath(), false)
|
||||
|
||||
entries, err := os.ReadDir(fatalCrashDir())
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
for _, entry := range entries {
|
||||
pid, ok := fatalCrashPID(entry.Name())
|
||||
if !ok || pid == os.Getpid() || fatalCrashProcessAlive(pid) {
|
||||
continue
|
||||
}
|
||||
captureFatalCrashFile(
|
||||
filepath.Join(fatalCrashDir(), entry.Name()),
|
||||
fatalCrashCoveredPathForPID(pid),
|
||||
true,
|
||||
)
|
||||
}
|
||||
for _, entry := range entries {
|
||||
pid, ok := fatalCrashCoveredPID(entry.Name())
|
||||
if !ok || pid == os.Getpid() || fatalCrashProcessAlive(pid) {
|
||||
continue
|
||||
}
|
||||
if _, err := os.Stat(fatalCrashPathForPID(pid)); os.IsNotExist(err) {
|
||||
_ = os.Remove(filepath.Join(fatalCrashDir(), entry.Name()))
|
||||
}
|
||||
}
|
||||
// Best effort: succeeds only when no live/current process artifacts remain.
|
||||
_ = os.Remove(fatalCrashDir())
|
||||
}
|
||||
|
||||
func fatalCrashPID(name string) (int, bool) {
|
||||
return fatalCrashPIDWithSuffix(name, fatalCrashLogSuffix)
|
||||
}
|
||||
|
||||
func fatalCrashCoveredPID(name string) (int, bool) {
|
||||
return fatalCrashPIDWithSuffix(name, fatalCrashCoveredSuffix)
|
||||
}
|
||||
|
||||
func fatalCrashPIDWithSuffix(name, suffix string) (int, bool) {
|
||||
if !strings.HasSuffix(name, suffix) {
|
||||
return 0, false
|
||||
}
|
||||
pid, err := strconv.Atoi(strings.TrimSuffix(name, suffix))
|
||||
return pid, err == nil && pid > 0
|
||||
}
|
||||
|
||||
func captureFatalCrashFile(path, coveredPath string, removeEmpty bool) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
occurredAt := time.Now().UTC()
|
||||
if info, statErr := f.Stat(); statErr == nil {
|
||||
occurredAt = info.ModTime().UTC()
|
||||
}
|
||||
raw, readErr := io.ReadAll(io.LimitReader(f, maxCrashStackBytes+1))
|
||||
_ = f.Close()
|
||||
if readErr != nil || len(strings.TrimSpace(string(raw))) == 0 {
|
||||
if removeEmpty {
|
||||
_ = os.Remove(coveredPath)
|
||||
_ = os.Remove(path)
|
||||
}
|
||||
return
|
||||
}
|
||||
if _, err := os.Stat(coveredPath); err == nil {
|
||||
_ = os.Remove(coveredPath)
|
||||
_ = os.Remove(path)
|
||||
return
|
||||
}
|
||||
stack := sanitizeFatalRuntimeDump(string(raw))
|
||||
report := baseCrashReport("crash")
|
||||
report.SchemaVersion = 2
|
||||
report.Source = "go.runtime"
|
||||
report.Label = "go.fatal"
|
||||
report.ErrorType = "GoRuntimeFatal"
|
||||
report.ErrorMessage = "Go runtime terminated the desktop process."
|
||||
report.Stack = stack
|
||||
report.TopFrame = topFrameFromStack(stack)
|
||||
report.FingerprintHint = "go.runtime.fatal"
|
||||
report.OccurredAt = occurredAt.Format(time.RFC3339)
|
||||
report.Message = sanitizeCrashText("[go.runtime.fatal]\n\n"+stack, maxCrashDetailBytes)
|
||||
if writePendingReport(report, true) {
|
||||
_ = os.Remove(coveredPath)
|
||||
_ = os.Remove(path)
|
||||
}
|
||||
}
|
||||
|
||||
// sanitizeFatalRuntimeDump removes panic values and preamble text that could
|
||||
// originate in user-controlled errors, while retaining runtime classification
|
||||
// and symbolized goroutine stacks for diagnosis.
|
||||
func sanitizeFatalRuntimeDump(raw string) string {
|
||||
lines := strings.Split(raw, "\n")
|
||||
classification := "runtime crash output"
|
||||
stackStart := -1
|
||||
for i, line := range lines {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
switch {
|
||||
case strings.HasPrefix(trimmed, "fatal error:"):
|
||||
classification = sanitizeCrashText(trimmed, 256)
|
||||
case strings.HasPrefix(trimmed, "panic:"):
|
||||
classification = "panic: [redacted panic value]"
|
||||
}
|
||||
if strings.HasPrefix(trimmed, "goroutine ") {
|
||||
stackStart = i
|
||||
break
|
||||
}
|
||||
}
|
||||
stack := ""
|
||||
if stackStart >= 0 {
|
||||
stack = strings.Join(lines[stackStart:], "\n")
|
||||
}
|
||||
return sanitizeCrashText(classification+"\n\n"+stack, maxCrashStackBytes)
|
||||
}
|
||||
|
||||
// installFatalCrashOutput asks the Go runtime to mirror unrecovered panics and
|
||||
// fatal runtime errors to a durable file. The runtime duplicates the descriptor,
|
||||
// so the file may be closed after SetCrashOutput returns.
|
||||
func installFatalCrashOutput() {
|
||||
path := fatalCrashPath()
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
||||
return
|
||||
}
|
||||
f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if err := debug.SetCrashOutput(f, debug.CrashOptions{}); err != nil {
|
||||
_ = f.Close()
|
||||
_ = os.Remove(path)
|
||||
return
|
||||
}
|
||||
_ = f.Close()
|
||||
}
|
||||
+89
-21
@@ -6,6 +6,11 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime/debug"
|
||||
"sort"
|
||||
"strconv"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/config"
|
||||
)
|
||||
@@ -16,12 +21,25 @@ import (
|
||||
// otherwise never surface a single report. The resend is gated on the same
|
||||
// desktop.telemetry opt-out as the launch ping.
|
||||
|
||||
const pendingCrashFile = "crash-pending.json"
|
||||
const (
|
||||
pendingCrashFile = "crash-pending.json" // legacy single-report path
|
||||
pendingCrashQueueDir = "crash-pending"
|
||||
maxPendingCrashes = 10
|
||||
)
|
||||
|
||||
var (
|
||||
pendingCrashMu sync.Mutex
|
||||
pendingCrashSequence atomic.Uint64
|
||||
)
|
||||
|
||||
func pendingCrashPath() string {
|
||||
return filepath.Join(config.MemoryUserDir(), pendingCrashFile)
|
||||
}
|
||||
|
||||
func pendingCrashDir() string {
|
||||
return filepath.Join(config.MemoryUserDir(), pendingCrashQueueDir)
|
||||
}
|
||||
|
||||
// recoverToPending records a panicking goroutine to the pending-crash file and
|
||||
// re-raises, so the process still crashes exactly as before — the stack is now
|
||||
// shipped next launch instead of lost.
|
||||
@@ -36,44 +54,84 @@ func (a *App) recoverToPending(site string) {
|
||||
|
||||
func writePendingCrash(site string, r any, stack []byte) {
|
||||
stackText := string(stack)
|
||||
msg := sanitizeCrashText(fmt.Sprintf("[go panic] %s: %v\n\n%s", site, r, stackText), maxCrashDetailBytes)
|
||||
msg := sanitizeCrashText(fmt.Sprintf("[go panic] %s\n\n%s", site, stackText), maxCrashDetailBytes)
|
||||
report := baseCrashReport("crash")
|
||||
report.SchemaVersion = 2
|
||||
report.Source = "go"
|
||||
report.Label = sanitizeCrashField(site, 64)
|
||||
report.ErrorType = sanitizeCrashField(fmt.Sprintf("%T", r), 128)
|
||||
report.ErrorMessage = sanitizeCrashText(fmt.Sprint(r), maxCrashFieldBytes)
|
||||
report.ErrorMessage = sanitizeCrashText("Go panic captured at "+site+".", maxCrashFieldBytes)
|
||||
report.Stack = sanitizeCrashText(stackText, maxCrashStackBytes)
|
||||
report.TopFrame = topFrameFromStack(report.Stack)
|
||||
report.Message = msg
|
||||
_ = writePendingReport(report, true)
|
||||
if writePendingReport(report, true) {
|
||||
markFatalCrashCovered()
|
||||
}
|
||||
}
|
||||
|
||||
func writePendingReport(report crashReport, overwrite bool) bool {
|
||||
_ = overwrite // retained for source compatibility; the queue never overwrites.
|
||||
body, err := json.Marshal(report)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
path := pendingCrashPath()
|
||||
if os.MkdirAll(filepath.Dir(path), 0o755) != nil {
|
||||
dir := pendingCrashDir()
|
||||
if os.MkdirAll(dir, 0o700) != nil {
|
||||
return false
|
||||
}
|
||||
if overwrite {
|
||||
return os.WriteFile(path, body, 0o644) == nil
|
||||
}
|
||||
f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o644)
|
||||
pendingCrashMu.Lock()
|
||||
defer pendingCrashMu.Unlock()
|
||||
name := strconv.FormatInt(time.Now().UTC().UnixNano(), 10) + "-" +
|
||||
strconv.Itoa(os.Getpid()) + "-" +
|
||||
strconv.FormatUint(pendingCrashSequence.Add(1), 10) + ".json"
|
||||
path := filepath.Join(dir, name)
|
||||
f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
defer f.Close()
|
||||
n, err := f.Write(body)
|
||||
if err != nil || n != len(body) {
|
||||
closeErr := f.Close()
|
||||
if err != nil || closeErr != nil || n != len(body) {
|
||||
_ = os.Remove(path)
|
||||
return false
|
||||
}
|
||||
paths := pendingCrashQueuePaths()
|
||||
for len(paths) > maxPendingCrashes {
|
||||
_ = os.Remove(paths[0])
|
||||
paths = paths[1:]
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func pendingCrashQueuePaths() []string {
|
||||
entries, err := os.ReadDir(pendingCrashDir())
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
paths := make([]string, 0, len(entries))
|
||||
for _, entry := range entries {
|
||||
if !entry.IsDir() && filepath.Ext(entry.Name()) == ".json" {
|
||||
paths = append(paths, filepath.Join(pendingCrashDir(), entry.Name()))
|
||||
}
|
||||
}
|
||||
sort.Strings(paths)
|
||||
return paths
|
||||
}
|
||||
|
||||
func pendingCrashPaths() []string {
|
||||
paths := make([]string, 0, maxPendingCrashes+1)
|
||||
if _, err := os.Stat(pendingCrashPath()); err == nil {
|
||||
paths = append(paths, pendingCrashPath())
|
||||
}
|
||||
return append(paths, pendingCrashQueuePaths()...)
|
||||
}
|
||||
|
||||
func removeAllPendingCrashes() {
|
||||
_ = os.Remove(pendingCrashPath())
|
||||
_ = os.RemoveAll(pendingCrashDir())
|
||||
_ = os.Remove(fatalCrashCoveredPath())
|
||||
}
|
||||
|
||||
func (a *App) goSafe(site string, fn func()) {
|
||||
go func() {
|
||||
defer a.recoverToPending(site)
|
||||
@@ -94,26 +152,36 @@ func (a *App) flushPendingCrash() {
|
||||
if config.SafeModeRequested() {
|
||||
return
|
||||
}
|
||||
path := pendingCrashPath()
|
||||
body, err := readFileUTF8(path)
|
||||
if err != nil {
|
||||
paths := pendingCrashPaths()
|
||||
if len(paths) == 0 {
|
||||
return
|
||||
}
|
||||
cfg, err := config.Load()
|
||||
if err != nil || !cfg.DesktopTelemetry() {
|
||||
_ = os.Remove(path)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
var r crashReport
|
||||
if json.Unmarshal(body, &r) != nil {
|
||||
_ = os.Remove(path)
|
||||
if !cfg.DesktopTelemetry() {
|
||||
removeAllPendingCrashes()
|
||||
return
|
||||
}
|
||||
c, err := httpClient()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if postCrashReport(a.bootContext(), c, crashEndpoint, r) == nil {
|
||||
for _, path := range paths {
|
||||
body, readErr := readFileUTF8(path)
|
||||
if readErr != nil {
|
||||
continue
|
||||
}
|
||||
var r crashReport
|
||||
if json.Unmarshal(body, &r) != nil {
|
||||
_ = os.Remove(path)
|
||||
continue
|
||||
}
|
||||
if postCrashReport(a.bootContext(), c, crashEndpoint, r) != nil {
|
||||
break
|
||||
}
|
||||
_ = os.Remove(path)
|
||||
}
|
||||
_ = os.Remove(pendingCrashDir())
|
||||
}
|
||||
@@ -13,7 +13,11 @@ import (
|
||||
|
||||
func readPending(t *testing.T) (crashReport, bool) {
|
||||
t.Helper()
|
||||
body, err := os.ReadFile(pendingCrashPath())
|
||||
paths := pendingCrashPaths()
|
||||
if len(paths) == 0 {
|
||||
return crashReport{}, false
|
||||
}
|
||||
body, err := os.ReadFile(paths[0])
|
||||
if err != nil {
|
||||
return crashReport{}, false
|
||||
}
|
||||
@@ -25,7 +29,7 @@ func readPending(t *testing.T) (crashReport, bool) {
|
||||
}
|
||||
|
||||
func TestRecoverToPendingCapturesAndReraises(t *testing.T) {
|
||||
t.Cleanup(func() { os.Remove(pendingCrashPath()) })
|
||||
t.Cleanup(removeAllPendingCrashes)
|
||||
|
||||
func() {
|
||||
defer func() {
|
||||
@@ -45,7 +49,7 @@ func TestRecoverToPendingCapturesAndReraises(t *testing.T) {
|
||||
if r.Kind != "crash" {
|
||||
t.Errorf("kind = %q, want crash", r.Kind)
|
||||
}
|
||||
if !strings.Contains(r.Message, "[go panic] unit:") {
|
||||
if !strings.Contains(r.Message, "[go panic] unit") {
|
||||
t.Errorf("message missing site prefix: %q", r.Message)
|
||||
}
|
||||
if r.Source != "go" || r.Label != "unit" || r.ErrorMessage == "" || r.Stack == "" || r.TopFrame == "" {
|
||||
@@ -57,7 +61,7 @@ func TestRecoverToPendingCapturesAndReraises(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestWritePendingCrashCaps(t *testing.T) {
|
||||
t.Cleanup(func() { os.Remove(pendingCrashPath()) })
|
||||
t.Cleanup(removeAllPendingCrashes)
|
||||
writePendingCrash("big", "x", []byte(strings.Repeat("a", 64<<10)))
|
||||
r, ok := readPending(t)
|
||||
if !ok {
|
||||
@@ -68,8 +72,8 @@ func TestWritePendingCrashCaps(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestWritePendingReportCanAvoidOverwritingExistingCrash(t *testing.T) {
|
||||
t.Cleanup(func() { os.Remove(pendingCrashPath()) })
|
||||
func TestWritePendingReportQueuesWithoutOverwritingExistingCrash(t *testing.T) {
|
||||
t.Cleanup(removeAllPendingCrashes)
|
||||
writePendingCrash("panic", "boom", []byte("stack"))
|
||||
before, ok := readPending(t)
|
||||
if !ok {
|
||||
@@ -80,8 +84,8 @@ func TestWritePendingReportCanAvoidOverwritingExistingCrash(t *testing.T) {
|
||||
hang.Source = "native.watchdog"
|
||||
hang.Label = "mac.main_thread.hang"
|
||||
hang.Message = "hang"
|
||||
if writePendingReport(hang, false) {
|
||||
t.Fatal("writePendingReport overwrite=false should not replace existing report")
|
||||
if !writePendingReport(hang, false) {
|
||||
t.Fatal("writePendingReport should enqueue the second report")
|
||||
}
|
||||
after, ok := readPending(t)
|
||||
if !ok {
|
||||
@@ -90,10 +94,13 @@ func TestWritePendingReportCanAvoidOverwritingExistingCrash(t *testing.T) {
|
||||
if after.Label != before.Label || after.Message != before.Message {
|
||||
t.Fatalf("pending crash was overwritten: before=%+v after=%+v", before, after)
|
||||
}
|
||||
if got := len(pendingCrashPaths()); got != 2 {
|
||||
t.Fatalf("pending reports = %d, want 2", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWritePendingReportNonOverwriteAllowsOneConcurrentWriter(t *testing.T) {
|
||||
t.Cleanup(func() { os.Remove(pendingCrashPath()) })
|
||||
func TestWritePendingReportQueueIsBoundedUnderConcurrentWriters(t *testing.T) {
|
||||
t.Cleanup(removeAllPendingCrashes)
|
||||
const writers = 32
|
||||
start := make(chan struct{})
|
||||
var ready sync.WaitGroup
|
||||
@@ -120,27 +127,28 @@ func TestWritePendingReportNonOverwriteAllowsOneConcurrentWriter(t *testing.T) {
|
||||
close(start)
|
||||
done.Wait()
|
||||
|
||||
if got := successes.Load(); got != 1 {
|
||||
t.Fatalf("successful non-overwrite writers = %d, want 1", got)
|
||||
if got := successes.Load(); got != writers {
|
||||
t.Fatalf("successful queued writers = %d, want %d", got, writers)
|
||||
}
|
||||
if _, ok := readPending(t); !ok {
|
||||
t.Fatal("expected one pending report")
|
||||
if got := len(pendingCrashPaths()); got != maxPendingCrashes {
|
||||
t.Fatalf("pending reports = %d, want bounded queue of %d", got, maxPendingCrashes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWritePendingCrashScrubsSensitiveText(t *testing.T) {
|
||||
t.Cleanup(func() { os.Remove(pendingCrashPath()) })
|
||||
t.Cleanup(removeAllPendingCrashes)
|
||||
apiKey := "sk-proj-" + "abcdefghijklmnopqrstuvwxyz1234567890"
|
||||
bearer := "abcdefghijklmnopqrstuvwxyz1234567890ABCDE"
|
||||
longHex := "0123456789abcdef0123456789abcdef"
|
||||
|
||||
writePendingCrash("unit", "boom api_key="+apiKey+" user [email protected]", []byte("goroutine\nAuthorization: Bearer "+bearer+"\n/home/alice/project/x.go:12\nhash "+longHex))
|
||||
privatePanicValue := "private user prompt contents"
|
||||
writePendingCrash("unit", privatePanicValue+" api_key="+apiKey+" user [email protected]", []byte("goroutine\nAuthorization: Bearer "+bearer+"\n/home/alice/project/x.go:12\nhash "+longHex))
|
||||
r, ok := readPending(t)
|
||||
if !ok {
|
||||
t.Fatal("expected a pending crash file")
|
||||
}
|
||||
freeText := strings.Join([]string{r.Message, r.ErrorMessage, r.Stack, r.TopFrame}, "\n")
|
||||
for _, leaked := range []string{apiKey, bearer, longHex, "[email protected]", "/home/alice"} {
|
||||
for _, leaked := range []string{privatePanicValue, apiKey, bearer, longHex, "[email protected]", "/home/alice"} {
|
||||
if strings.Contains(freeText, leaked) {
|
||||
t.Fatalf("sensitive value leaked %q in %+v", leaked, r)
|
||||
}
|
||||
@@ -151,7 +159,7 @@ func TestFlushPendingCrashSendsAndClears(t *testing.T) {
|
||||
oldVersion, oldEndpoint := version, crashEndpoint
|
||||
t.Cleanup(func() {
|
||||
version, crashEndpoint = oldVersion, oldEndpoint
|
||||
os.Remove(pendingCrashPath())
|
||||
removeAllPendingCrashes()
|
||||
})
|
||||
version = "v9.9.9"
|
||||
|
||||
@@ -178,7 +186,7 @@ func TestFlushPendingCrashDevGuard(t *testing.T) {
|
||||
oldVersion := version
|
||||
t.Cleanup(func() {
|
||||
version = oldVersion
|
||||
os.Remove(pendingCrashPath())
|
||||
removeAllPendingCrashes()
|
||||
})
|
||||
version = "dev"
|
||||
|
||||
@@ -195,7 +203,7 @@ func TestFlushPendingCrashRetainsInSafeMode(t *testing.T) {
|
||||
oldVersion := version
|
||||
t.Cleanup(func() {
|
||||
version = oldVersion
|
||||
os.Remove(pendingCrashPath())
|
||||
removeAllPendingCrashes()
|
||||
})
|
||||
version = "v9.9.9"
|
||||
|
||||
|
||||
+26
-10
@@ -5,7 +5,6 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
@@ -239,6 +238,13 @@ func (a *App) retryDeferredStartupBuild(tabID string, tab *WorkspaceTab) {
|
||||
a.clearDeferredRebuild(tabID)
|
||||
return
|
||||
}
|
||||
a.mu.RLock()
|
||||
path := strings.TrimSpace(tab.SessionPath)
|
||||
a.mu.RUnlock()
|
||||
if path != "" && a.attachExistingSessionRuntime(tab, path, a.ctx) {
|
||||
a.clearDeferredRebuild(tabID)
|
||||
return
|
||||
}
|
||||
if !a.deferredRebuildLeaseLooksFree(tab) {
|
||||
return
|
||||
}
|
||||
@@ -289,6 +295,7 @@ func (a *App) rebuildStartupTabLocked(tab *WorkspaceTab) error {
|
||||
tab.buildCancel = cancel
|
||||
tab.Ready = false
|
||||
clearTabStartupError(tab)
|
||||
a.setSessionRuntimePhaseLocked(tab, sessionRuntimeStarting, nil)
|
||||
tab.ActivityStatus = ""
|
||||
if tab.sink == nil {
|
||||
tab.sink = &tabEventSink{tabID: tab.ID, app: a, ctx: a.ctx}
|
||||
@@ -366,16 +373,25 @@ func (a *App) deferredRebuildLeaseLooksFree(tab *WorkspaceTab) bool {
|
||||
if path == "" {
|
||||
return true // nothing to probe; let the rebuild decide
|
||||
}
|
||||
lease, err := agent.TryAcquireSessionLease(sessionRuntimeKey(path))
|
||||
key := sessionRuntimeKey(path)
|
||||
a.mu.RLock()
|
||||
rt := a.runtimeBySessionKey[key]
|
||||
ownedByTab := rt != nil && rt.Owner == tab
|
||||
ownedByOther := rt != nil && rt.Owner != nil && rt.Owner != tab && a.runtimeOwnerLiveLocked(rt)
|
||||
a.mu.RUnlock()
|
||||
if ownedByOther {
|
||||
return false
|
||||
}
|
||||
if ownedByTab && tab.sessionLeaseRuntimeKey() == key {
|
||||
return true
|
||||
}
|
||||
lease, err := agent.TryAcquireSessionLease(key)
|
||||
if err != nil {
|
||||
var leaseErr *agent.SessionLeaseError
|
||||
if errors.As(err, &leaseErr) && leaseErr.Info != nil && leaseErr.Info.PID == os.Getpid() {
|
||||
if host, _ := os.Hostname(); leaseErr.Info.Hostname == host {
|
||||
// This process (usually this very tab) holds the probed path;
|
||||
// the blocking lease is some other path. Attempt the rebuild
|
||||
// and let its own lease checks decide.
|
||||
return true
|
||||
}
|
||||
if sameCurrentProcessLease(err) {
|
||||
// The registry ruled out a live sibling owner above, so this is an
|
||||
// orphaned current-process lease. Let the rebuild helper reclaim it
|
||||
// under runtimeRebuildMu instead of looping against our own marker.
|
||||
return true
|
||||
}
|
||||
return !errors.Is(err, agent.ErrSessionLeaseHeld)
|
||||
}
|
||||
|
||||
@@ -5,21 +5,27 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "node scripts/check-css-syntax.mjs src/styles.css && node scripts/check-z-index-tokens.mjs src/styles.css && tsc --noEmit && vite build",
|
||||
"build": "node scripts/check-css-syntax.mjs src/styles.css && node scripts/check-z-index-tokens.mjs src/styles.css && tsc --noEmit && vite build && node scripts/check-bundle-budget.mjs",
|
||||
"check:bundle": "node scripts/check-bundle-budget.mjs",
|
||||
"preview": "vite preview",
|
||||
"check:css": "node scripts/check-css-syntax.mjs src/styles.css && node scripts/check-z-index-tokens.mjs src/styles.css",
|
||||
"test:todo-visibility": "node scripts/test-todo-visibility.mjs",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "node scripts/test-todo-visibility.mjs && tsx src/__tests__/session-export.test.ts && tsx src/__tests__/conversation-width.test.ts && tsx src/__tests__/external-opener.test.tsx && tsx src/__tests__/anchored-popover-scroll.test.tsx && tsx src/__tests__/confirm-dialog.test.tsx && tsx src/__tests__/scroll-manager.test.tsx && tsx src/__tests__/layout-style-defaults.test.ts && tsx src/__tests__/scroll-content-observer.test.ts && tsx src/__tests__/math-golden.test.ts && tsx src/__tests__/mermaid-rendering.test.tsx && tsx src/__tests__/markdown-image-proxy.test.ts && tsx src/__tests__/text-size.test.ts && tsx src/__tests__/font-availability.test.ts && tsx src/__tests__/typography-preferences.test.ts && tsx src/__tests__/theme-auto-background.test.ts && tsx src/__tests__/theme-pack.test.ts && tsx src/__tests__/theme-editor-keyboard.test.tsx && tsx src/__tests__/typography-overflow-contract.test.ts && tsx src/__tests__/ask-card-layout.test.ts && tsx src/__tests__/approval-modal-file-reference.test.tsx && tsx src/__tests__/ref-token.test.tsx && tsx src/__tests__/decision-surface.test.tsx && tsx src/__tests__/provider-model-refresh.test.ts && tsx src/__tests__/composer-profile.test.ts && tsx src/__tests__/composer-history.test.ts && tsx src/__tests__/session-title-contract.test.ts && tsx src/__tests__/composer-keyboard.test.ts && tsx src/__tests__/composer-goal-toggle.test.tsx && tsx src/__tests__/composer-run-strip.test.tsx && tsx src/__tests__/composer-session-draft.test.tsx && tsx src/__tests__/composer-image-capability.test.tsx && tsx src/__tests__/composer-context-menu-clipboard.test.tsx && tsx src/__tests__/composer-resized-input-css.test.ts && tsx src/__tests__/use-controller-meta.test.ts && tsx src/__tests__/use-controller-stream-progress.test.ts && tsx src/__tests__/use-controller-live-context.test.tsx && tsx src/__tests__/use-controller-cancel-reconcile.test.tsx && tsx src/__tests__/pending-prompt-stale-status.test.tsx && tsx src/__tests__/new-session-load-race.test.tsx && tsx src/__tests__/tab-switch-hydration.test.tsx && tsx src/__tests__/activate-topic-stale.test.tsx && tsx src/__tests__/ready-event-meta-sync.test.tsx && tsx src/__tests__/ready-meta-reconcile.test.tsx && tsx src/__tests__/recovery-banner-privacy.test.ts && tsx src/__tests__/recovery-quiet-notifications.test.ts && tsx src/__tests__/workspace-layout.test.ts && tsx src/__tests__/workspace-changes-errors.test.tsx && tsx src/__tests__/workspace-split.test.ts && tsx src/__tests__/workspace-preview-css.test.ts && tsx src/__tests__/resize-drag.test.ts && tsx src/__tests__/wails-resize-fix.test.tsx && tsx src/__tests__/tool-approval-mode.test.ts && tsx src/__tests__/sound.test.ts && tsx src/__tests__/heartbeat-next-run.test.ts && tsx src/__tests__/keyboard-shortcuts.test.ts && tsx src/__tests__/shortcuts-recorder-focus.test.tsx && tsx src/__tests__/send-failed.test.ts && tsx src/__tests__/edit-replay.test.ts && tsx src/__tests__/message-pasted-blocks.test.ts && tsx src/__tests__/history-tool-status.test.ts && tsx src/__tests__/history-recovery-copies.test.tsx && tsx src/__tests__/message-selection-copy.test.ts && tsx src/__tests__/transcript-selection-menu.test.tsx && tsx src/__tests__/turn-action-copy.test.ts && tsx src/__tests__/turn-actions-rendering.test.ts && tsx src/__tests__/code-block-copy-position.test.tsx && tsx src/__tests__/line-number-code.test.tsx && tsx src/__tests__/message-reasoning-panel.test.tsx && tsx src/__tests__/transcript-process-fold.test.ts && tsx src/__tests__/transcript-fold-preference.test.tsx && tsx src/__tests__/reasoning-display.test.ts && tsx src/__tests__/attachment-display.test.ts && tsx src/__tests__/crash-reporting.test.ts && tsx src/__tests__/bridge-drag-rejection.test.ts && tsx src/__tests__/startup-settings-contract.test.ts && tsx src/__tests__/bundle-contract.test.ts && tsx src/__tests__/command-palette-css.test.ts && tsx src/__tests__/command-palette-interactions.test.tsx && tsx src/__tests__/context-panel-breakdown.test.ts && tsx src/__tests__/context-window-ring.test.tsx && tsx src/__tests__/statusbar-workspace.test.tsx && tsx src/__tests__/settings-refresh-snapshot.test.tsx && tsx src/__tests__/capabilities-panel-actions.test.ts && tsx src/__tests__/diagnostics-settings.test.tsx && tsx src/__tests__/tool-data-archive.test.ts && tsx src/__tests__/tool-subject.test.ts && tsx src/__tests__/diff-rendering.test.ts && tsx src/__tests__/render-optimization.test.ts && tsx src/__tests__/transcript-grouping.test.ts && tsx src/__tests__/project-tree-runtime.test.ts && tsx src/__tests__/app-chrome-tabs.test.ts && tsx src/__tests__/open-topic-coalescing.test.tsx && tsx src/__tests__/delivery-worktree.test.ts && tsx src/__tests__/memory-compiler-display.test.ts && tsx src/__tests__/memory-citation-visibility.test.ts",
|
||||
"test:terminal": "tsx src/__tests__/terminal-events.test.ts && tsx src/__tests__/terminal-store.test.ts && tsx src/__tests__/terminal-output.test.ts && tsx src/__tests__/workspace-layout.test.ts",
|
||||
"pretest": "pnpm test:terminal && tsx src/__tests__/rich-composer-selection.test.tsx && tsx src/__tests__/context-center-contract.test.ts",
|
||||
"test": "node scripts/test-todo-visibility.mjs && tsx src/__tests__/goal-action-errors.test.tsx && tsx src/__tests__/goal-activation-tab-routing.test.tsx && tsx src/__tests__/session-export.test.ts && tsx src/__tests__/conversation-width.test.ts && tsx src/__tests__/external-opener.test.tsx && tsx src/__tests__/anchored-popover-scroll.test.tsx && tsx src/__tests__/confirm-dialog.test.tsx && tsx src/__tests__/scroll-manager.test.tsx && tsx src/__tests__/layout-style-defaults.test.ts && tsx src/__tests__/scroll-content-observer.test.ts && tsx src/__tests__/math-golden.test.ts && tsx src/__tests__/mermaid-rendering.test.tsx && tsx src/__tests__/markdown-image-proxy.test.ts && tsx src/__tests__/text-size.test.ts && tsx src/__tests__/font-availability.test.ts && tsx src/__tests__/typography-preferences.test.ts && tsx src/__tests__/theme-auto-background.test.ts && tsx src/__tests__/theme-pack.test.ts && tsx src/__tests__/theme-editor-keyboard.test.tsx && tsx src/__tests__/typography-overflow-contract.test.ts && tsx src/__tests__/ask-card-layout.test.ts && tsx src/__tests__/approval-modal-file-reference.test.tsx && tsx src/__tests__/ref-token.test.tsx && tsx src/__tests__/decision-surface.test.tsx && tsx src/__tests__/provider-model-refresh.test.ts && tsx src/__tests__/model-switcher-refresh.test.tsx && tsx src/__tests__/composer-profile.test.ts && tsx src/__tests__/composer-history.test.ts && tsx src/__tests__/session-title-contract.test.ts && tsx src/__tests__/composer-keyboard.test.ts && tsx src/__tests__/composer-goal-toggle.test.tsx && tsx src/__tests__/composer-run-strip.test.tsx && tsx src/__tests__/composer-session-draft.test.tsx && tsx src/__tests__/language-highlighting.test.ts && tsx src/__tests__/composer-image-capability.test.tsx && tsx src/__tests__/composer-context-menu-clipboard.test.tsx && tsx src/__tests__/composer-resized-input-css.test.ts && tsx src/__tests__/use-controller-meta.test.ts && tsx src/__tests__/use-controller-stream-progress.test.ts && tsx src/__tests__/use-controller-live-context.test.tsx && tsx src/__tests__/use-controller-cancel-reconcile.test.tsx && tsx src/__tests__/pending-prompt-stale-status.test.tsx && tsx src/__tests__/new-session-load-race.test.tsx && tsx src/__tests__/tab-switch-hydration.test.tsx && tsx src/__tests__/activate-topic-stale.test.tsx && tsx src/__tests__/ready-event-meta-sync.test.tsx && tsx src/__tests__/ready-meta-reconcile.test.tsx && tsx src/__tests__/recovery-banner-privacy.test.ts && tsx src/__tests__/recovery-quiet-notifications.test.ts && tsx src/__tests__/workspace-layout.test.ts && tsx src/__tests__/workspace-changes-errors.test.tsx && tsx src/__tests__/workspace-split.test.ts && tsx src/__tests__/workspace-preview-css.test.ts && tsx src/__tests__/resize-drag.test.ts && tsx src/__tests__/wails-resize-fix.test.tsx && tsx src/__tests__/tool-approval-mode.test.ts && tsx src/__tests__/sound.test.ts && tsx src/__tests__/heartbeat-next-run.test.ts && tsx src/__tests__/keyboard-shortcuts.test.ts && tsx src/__tests__/shortcuts-recorder-focus.test.tsx && tsx src/__tests__/send-failed.test.ts && tsx src/__tests__/edit-replay.test.ts && tsx src/__tests__/message-pasted-blocks.test.ts && tsx src/__tests__/history-tool-status.test.ts && tsx src/__tests__/history-recovery-copies.test.tsx && tsx src/__tests__/message-selection-copy.test.ts && tsx src/__tests__/transcript-selection-menu.test.tsx && tsx src/__tests__/turn-action-copy.test.ts && tsx src/__tests__/turn-actions-rendering.test.ts && tsx src/__tests__/code-block-copy-position.test.tsx && tsx src/__tests__/line-number-code.test.tsx && tsx src/__tests__/message-reasoning-panel.test.tsx && tsx src/__tests__/transcript-process-fold.test.ts && tsx src/__tests__/transcript-fold-preference.test.tsx && tsx src/__tests__/reasoning-display.test.ts && tsx src/__tests__/attachment-display.test.ts && tsx src/__tests__/crash-reporting.test.ts && tsx src/__tests__/bridge-drag-rejection.test.ts && tsx src/__tests__/startup-settings-contract.test.ts && tsx src/__tests__/bundle-contract.test.ts && tsx src/__tests__/command-palette-css.test.ts && tsx src/__tests__/command-palette-interactions.test.tsx && tsx src/__tests__/context-panel-breakdown.test.ts && tsx src/__tests__/context-window-ring.test.tsx && tsx src/__tests__/statusbar-workspace.test.tsx && tsx src/__tests__/settings-refresh-snapshot.test.tsx && tsx src/__tests__/capabilities-panel-actions.test.ts && tsx src/__tests__/diagnostics-settings.test.tsx && tsx src/__tests__/tool-data-archive.test.ts && tsx src/__tests__/tool-subject.test.ts && tsx src/__tests__/diff-rendering.test.ts && tsx src/__tests__/render-optimization.test.ts && tsx src/__tests__/transcript-grouping.test.ts && tsx src/__tests__/project-tree-runtime.test.ts && tsx src/__tests__/app-chrome-tabs.test.ts && tsx src/__tests__/open-topic-coalescing.test.tsx && tsx src/__tests__/delivery-worktree.test.ts && tsx src/__tests__/memory-compiler-display.test.ts && tsx src/__tests__/memory-citationLine truncated
|
||||
"test:remote": "tsx src/__tests__/remote-store.test.ts && tsx src/__tests__/remote-error-ux.test.tsx && tsx src/__tests__/remote-hosts-page.test.tsx && tsx src/__tests__/remote-secret-dialog.test.tsx && tsx src/__tests__/provider-trust-dialog.test.tsx && tsx src/__tests__/workbench-target.test.ts",
|
||||
"test:performance": "tsx src/__tests__/history-performance-benchmark.tsx",
|
||||
"test:typecheck": "tsc --noEmit -p tsconfig.test.json",
|
||||
"test:delivery-worktree": "tsx src/__tests__/delivery-worktree.test.ts",
|
||||
"test:updater": "tsx src/__tests__/updater-shared-state.test.tsx",
|
||||
"test:all": "pnpm test:typecheck && pnpm test:updater && pnpm test && pnpm test:remote"
|
||||
"test:all": "pnpm test:typecheck && pnpm test:updater && pnpm test && pnpm test:remote && pnpm test:performance"
|
||||
},
|
||||
"dependencies": {
|
||||
"@gsap/react": "^2.1.2",
|
||||
"@tanstack/react-virtual": "^3.14.3",
|
||||
"@xterm/addon-fit": "0.11.0",
|
||||
"@xterm/xterm": "6.0.0",
|
||||
"gsap": "^3.15.0",
|
||||
"highlight.js": "^11.10.0",
|
||||
"katex": "^0.17.0",
|
||||
|
||||
Generated
+28
-12
@@ -17,6 +17,12 @@ importers:
|
||||
'@tanstack/react-virtual':
|
||||
specifier: ^3.14.3
|
||||
version: 3.14.3([email protected]([email protected]))([email protected])
|
||||
'@xterm/addon-fit':
|
||||
specifier: 0.11.0
|
||||
version: 0.11.0
|
||||
'@xterm/xterm':
|
||||
specifier: 6.0.0
|
||||
version: 6.0.0
|
||||
gsap:
|
||||
specifier: ^3.15.0
|
||||
version: 3.15.0
|
||||
@@ -646,6 +652,12 @@ packages:
|
||||
babel-plugin-react-compiler:
|
||||
optional: true
|
||||
|
||||
'@xterm/[email protected]':
|
||||
resolution: {integrity: sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g==}
|
||||
|
||||
'@xterm/[email protected]':
|
||||
resolution: {integrity: sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==}
|
||||
engines: {node: '>=0.4.0'}
|
||||
@@ -894,8 +906,8 @@ packages:
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==}
|
||||
|
||||
[email protected]1:
|
||||
resolution: {integrity: sha512-zhlUV12GsaRzMsf9q5M254YhA4+VuF0fG+QFqu6aYpoGlKtz+w8//jBcGVYBgQkR5GHjUomejY84AV+/uPbWdw==}
|
||||
[email protected]2:
|
||||
resolution: {integrity: sha512-zQvGet8Z2sWbQhCmfFz/T5QWH2oBmjnqK3qvOjaqaNLrLEF912WamU+ohnTp0TCep/MFVHpdJuCZEdFOdTnEFg==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==}
|
||||
@@ -1287,8 +1299,8 @@ packages:
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
|
||||
|
||||
[email protected]2:
|
||||
resolution: {integrity: sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==}
|
||||
[email protected]6:
|
||||
resolution: {integrity: sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==}
|
||||
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
|
||||
hasBin: true
|
||||
|
||||
@@ -1320,8 +1332,8 @@ packages:
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-25ClnWWuw7JbWZcgqY/gJ4FQWadKxGWk+3kR/7kD0tCaDtPPMj7oHu2ToLaVhfpnHrZzYby2w6tUA0eOIuUg8g==}
|
||||
|
||||
[email protected].15:
|
||||
resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==}
|
||||
[email protected].23:
|
||||
resolution: {integrity: sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==}
|
||||
engines: {node: ^10 || ^12 || >=14}
|
||||
|
||||
[email protected]:
|
||||
@@ -2066,6 +2078,10 @@ snapshots:
|
||||
'@rolldown/pluginutils': 1.0.1
|
||||
vite: 8.0.16(@types/[email protected])([email protected])([email protected])([email protected])
|
||||
|
||||
'@xterm/[email protected]': {}
|
||||
|
||||
'@xterm/[email protected]': {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
@@ -2324,7 +2340,7 @@ snapshots:
|
||||
dependencies:
|
||||
dequal: 2.0.3
|
||||
|
||||
[email protected]1:
|
||||
[email protected]2:
|
||||
optionalDependencies:
|
||||
'@types/trusted-types': 2.0.7
|
||||
|
||||
@@ -2781,7 +2797,7 @@ snapshots:
|
||||
d3-sankey: 0.12.3
|
||||
dagre-d3-es: 7.0.14
|
||||
dayjs: 1.11.21
|
||||
dompurify: 3.4.11
|
||||
dompurify: 3.4.12
|
||||
es-toolkit: 1.49.0
|
||||
katex: 0.16.47
|
||||
khroma: 2.1.0
|
||||
@@ -2994,7 +3010,7 @@ snapshots:
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]2: {}
|
||||
[email protected]6: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
@@ -3029,9 +3045,9 @@ snapshots:
|
||||
path-data-parser: 0.1.0
|
||||
points-on-curve: 0.2.0
|
||||
|
||||
[email protected].15:
|
||||
[email protected].23:
|
||||
dependencies:
|
||||
nanoid: 3.3.12
|
||||
nanoid: 3.3.16
|
||||
picocolors: 1.1.1
|
||||
source-map-js: 1.2.1
|
||||
|
||||
@@ -3308,7 +3324,7 @@ snapshots:
|
||||
dependencies:
|
||||
lightningcss: 1.32.0
|
||||
picomatch: 4.0.4
|
||||
postcss: 8.5.15
|
||||
postcss: 8.5.23
|
||||
rolldown: 1.0.3
|
||||
tinyglobby: 0.2.17
|
||||
optionalDependencies:
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { readFileSync, readdirSync, statSync } from "node:fs";
|
||||
import { basename, resolve } from "node:path";
|
||||
import { gzipSync } from "node:zlib";
|
||||
|
||||
const distDir = resolve("dist");
|
||||
const indexPath = resolve(distDir, "index.html");
|
||||
const html = readFileSync(indexPath, "utf8");
|
||||
|
||||
function gzipBytes(path) {
|
||||
return gzipSync(readFileSync(path), { level: 9 }).byteLength;
|
||||
}
|
||||
|
||||
function initialAssetPaths(extension) {
|
||||
const pattern = extension === ".js"
|
||||
? /<(?:script|link)\b[^>]+(?:src|href)=["']([^"']+\.js)["'][^>]*>/g
|
||||
: /<link\b[^>]+href=["']([^"']+\.css)["'][^>]*>/g;
|
||||
return [...new Set([...html.matchAll(pattern)].map((match) => resolve(distDir, match[1])))];
|
||||
}
|
||||
|
||||
function formatKiB(bytes) {
|
||||
return `${(bytes / 1024).toFixed(1)} KiB`;
|
||||
}
|
||||
|
||||
function assertBudget(label, actual, budget) {
|
||||
if (actual > budget) {
|
||||
throw new Error(`${label} is ${formatKiB(actual)}; budget is ${formatKiB(budget)}`);
|
||||
}
|
||||
process.stdout.write(` PASS ${label}: ${formatKiB(actual)} / ${formatKiB(budget)}\n`);
|
||||
}
|
||||
|
||||
const initialJS = initialAssetPaths(".js");
|
||||
const initialCSS = initialAssetPaths(".css");
|
||||
if (!initialJS.length) throw new Error("no initial JavaScript assets found in dist/index.html");
|
||||
if (!initialCSS.length) throw new Error("no initial CSS assets found in dist/index.html");
|
||||
|
||||
const initialJSGzip = initialJS.reduce((total, path) => total + gzipBytes(path), 0);
|
||||
const initialCSSGzip = initialCSS.reduce((total, path) => total + gzipBytes(path), 0);
|
||||
const largestInitialJS = Math.max(...initialJS.map(gzipBytes));
|
||||
const largestInitialJSRaw = Math.max(...initialJS.map((path) => statSync(path).size));
|
||||
const localeChunks = readdirSync(resolve(distDir, "assets"))
|
||||
.filter((name) => /^(?:zh|zh-TW)-.+\.js$/.test(name))
|
||||
.map((name) => resolve(distDir, "assets", name));
|
||||
|
||||
console.log("\nbundle budgets");
|
||||
assertBudget("initial JavaScript gzip", initialJSGzip, 430 * 1024);
|
||||
assertBudget("largest initial JavaScript chunk gzip", largestInitialJS, 295 * 1024);
|
||||
assertBudget("initial CSS gzip", initialCSSGzip, 112 * 1024);
|
||||
if (localeChunks.length !== 2) {
|
||||
throw new Error(`expected 2 on-demand Chinese locale chunks, found ${localeChunks.length}`);
|
||||
}
|
||||
for (const path of localeChunks) {
|
||||
assertBudget(`${basename(path)} gzip`, gzipBytes(path), 52 * 1024);
|
||||
}
|
||||
|
||||
const rawInitialBytes = [...initialJS, ...initialCSS].reduce((total, path) => total + statSync(path).size, 0);
|
||||
assertBudget("initial raw JavaScript and CSS", rawInitialBytes, 2_250 * 1024);
|
||||
assertBudget("largest initial JavaScript chunk raw", largestInitialJSRaw, 1_100 * 1024);
|
||||
+349
-83
@@ -33,10 +33,13 @@ import {
|
||||
Cpu,
|
||||
Palette,
|
||||
X,
|
||||
TerminalSquare,
|
||||
} from "lucide-react";
|
||||
import { useToast } from "./lib/toast";
|
||||
import { useGoalActionHandler } from "./lib/goalAction";
|
||||
import { useWailsResizeFix } from "./lib/useWailsResizeFix";
|
||||
import { asArray } from "./lib/array";
|
||||
import { createBoundedRefreshCoordinator, sameTabMetaLists, shouldRefreshTabMetaForEvent, TAB_META_MAX_IN_FLIGHT, tabMetaFallbackDelay } from "./lib/tabMetaRefresh";
|
||||
import { clearLegacyLangPref, normalizeLangPref, readLegacyLangPref, t, useI18n, useT, type Translator } from "./lib/i18n";
|
||||
import { localizedNoticeText, useController, type Item, type LiveStream } from "./lib/useController";
|
||||
import { app, onEvent, onProjectTreeChanged, onReady, onRuntimeRebuilt, onSessionRecovered, onWorkbenchTarget, openExternal } from "./lib/bridge";
|
||||
@@ -77,6 +80,9 @@ import { HeartbeatPanel } from "./custom/features/heartbeat/HeartbeatPanel";
|
||||
import "./custom/features/heartbeat/heartbeat.css";
|
||||
import { CopyButton } from "./components/CopyButton";
|
||||
import { ExternalOpener } from "./components/ExternalOpener";
|
||||
import { startTerminalEventBridge } from "./lib/terminalEvents";
|
||||
import { formatTerminalOutputForComposer } from "./lib/terminalOutput";
|
||||
import { useTerminalStore } from "./store/terminal";
|
||||
import { parseTodos } from "./lib/tools";
|
||||
import {
|
||||
dismissedTodoKeyForScope,
|
||||
@@ -189,6 +195,11 @@ import { useGlobalShortcut } from "./lib/keyboardShortcuts";
|
||||
import { topicShortcutIndexFromEvent, useTopicShortcuts, type TopicShortcutEntry } from "./lib/topicShortcuts";
|
||||
import { composerDraftKeyForTab } from "./lib/composerDraftKey";
|
||||
import { continueDelivery } from "./lib/deliveryContinue";
|
||||
import {
|
||||
activateGoalAndSubmitOnTab,
|
||||
workbenchTargetToken,
|
||||
type WorkbenchTargetToken,
|
||||
} from "./lib/goalSubmit";
|
||||
import logoWordmark from "./assets/logo-wordmark.svg";
|
||||
|
||||
function noticePreviewMockEnabled(): boolean {
|
||||
@@ -264,6 +275,7 @@ function NoticePreviewPanel() {
|
||||
const HistoryPanel = lazy(() => import("./components/HistoryPanel").then((module) => ({ default: module.HistoryPanel })));
|
||||
const SettingsPanel = lazy(() => import("./components/SettingsPanel").then((module) => ({ default: module.SettingsPanel })));
|
||||
const RemotePanel = lazy(() => import("./components/RemotePanel").then((module) => ({ default: module.RemotePanel })));
|
||||
const TerminalPanel = lazy(() => import("./components/TerminalPanel").then((module) => ({ default: module.TerminalPanel })));
|
||||
|
||||
const CHAT_MIN_WIDTH = 400;
|
||||
const CHAT_COMFORT_MIN_WIDTH = 560;
|
||||
@@ -408,12 +420,13 @@ type SidebarImConnection = {
|
||||
allowlistUsers: string[];
|
||||
allowlistMatched: boolean;
|
||||
};
|
||||
type DesktopNavigationInput =
|
||||
type DesktopNavigationIntent =
|
||||
| { kind: "topic"; scope: string; workspaceRoot: string; topicId: string; sessionPath?: string }
|
||||
| { kind: "blank"; scope: string; workspaceRoot: string }
|
||||
| { kind: "delivery-worktree"; workspaceRoot: string }
|
||||
| { kind: "sidebar-im"; connection: SidebarImConnection }
|
||||
| { kind: "resume-session"; session: SessionMeta };
|
||||
type DesktopNavigationInput = DesktopNavigationIntent & { navigationIntentSeq: number };
|
||||
type PendingDesktopNavigationRequest = PendingNavigationRequest<DesktopNavigationInput>;
|
||||
type SidebarImTopicSource = {
|
||||
platform: SidebarImPlatform;
|
||||
@@ -1034,6 +1047,7 @@ function TextSizeHotkeys() {
|
||||
export default function App() {
|
||||
const {
|
||||
state,
|
||||
liveStore,
|
||||
activeTabId,
|
||||
sendToTab,
|
||||
recoverDeliveryToTab,
|
||||
@@ -1046,13 +1060,12 @@ export default function App() {
|
||||
answerQuestion,
|
||||
setControllerMode,
|
||||
setCollaborationMode: setControllerCollaborationMode,
|
||||
setCollaborationModeForTab: setControllerCollaborationModeForTab,
|
||||
setToolApprovalMode: setControllerToolApprovalMode,
|
||||
setToolApprovalModeForTab: setControllerToolApprovalModeForTab,
|
||||
setGoal: setControllerGoal,
|
||||
setComposerProfileForTab: setControllerComposerProfileForTab,
|
||||
setGoalForTab: setControllerGoalForTab,
|
||||
resumeGoalForTab: resumeControllerGoalForTab,
|
||||
clearGoal: clearControllerGoal,
|
||||
clearGoalForTab: clearControllerGoalForTab,
|
||||
clearSession,
|
||||
listSessions,
|
||||
listTrashedSessions,
|
||||
@@ -1080,6 +1093,7 @@ export default function App() {
|
||||
openTopicSession,
|
||||
activateTopic,
|
||||
noteNavigationIntent,
|
||||
isNavigationIntentCurrent,
|
||||
syncActiveTab,
|
||||
ensureBlankTab,
|
||||
ensureBlankSurface,
|
||||
@@ -1118,6 +1132,7 @@ export default function App() {
|
||||
const remoteStatuses = useRemoteStore((s) => s.statuses);
|
||||
const [workbenchTarget, setWorkbenchTarget] = useState<WorkbenchActiveTarget>({ kind: "local" });
|
||||
const { showToast } = useToast();
|
||||
const { runGoalAction, handleGoalActionError } = useGoalActionHandler();
|
||||
const setRemoteHosts = useRemoteStore((s) => s.setHosts);
|
||||
const hydrateRemoteStatuses = useRemoteStore((s) => s.hydrateStatuses);
|
||||
const requestRemoteExplorer = useRemoteStore((s) => s.openExplorer);
|
||||
@@ -1188,6 +1203,7 @@ export default function App() {
|
||||
// Bump dockRefreshKey after each turn so WorkspacePanel/ContextPanel re-fetch
|
||||
// workspace changes, git history, and session metadata after AI tool writes.
|
||||
useEffect(() => {
|
||||
startTerminalEventBridge();
|
||||
const unsub = onEvent((e) => {
|
||||
if (e.kind === "turn_done") {
|
||||
setDockRefreshKey((v) => v + 1);
|
||||
@@ -1479,7 +1495,18 @@ export default function App() {
|
||||
const footerHeightRef = useRef(0);
|
||||
const footerRef = useRef<HTMLElement>(null);
|
||||
const activeTabIdRef = useRef(activeTabId);
|
||||
const commitThenSendRef = useRef<(tabId: string, displayText: string, submitText?: string, structured?: StructuredInvocationSubmit) => Promise<void>>(async () => {});
|
||||
const commitThenSendRef = useRef<(
|
||||
tabId: string,
|
||||
displayText: string,
|
||||
submitText?: string,
|
||||
structured?: StructuredInvocationSubmit,
|
||||
initialGoal?: {
|
||||
goal: string;
|
||||
target: WorkbenchTargetToken;
|
||||
collaborationMode: CollaborationMode;
|
||||
toolApprovalMode: ToolApprovalMode;
|
||||
},
|
||||
) => Promise<void>>(async () => {});
|
||||
const handleInvocationMetadataChange = useCallback((metadata: InvocationMetadataMap) => {
|
||||
const sourceTabId = activeTabIdRef.current;
|
||||
if (!sourceTabId) return;
|
||||
@@ -1519,8 +1546,14 @@ export default function App() {
|
||||
|
||||
const storedWorkspacePanelRenderWidth = workspacePanelMaximized ? preferredWorkspacePanelWidth : resolvedWorkspacePanelWidth;
|
||||
const workspacePanelRenderWidth = liveWorkspacePanelRenderWidth ?? storedWorkspacePanelRenderWidth;
|
||||
// The terminal is a bottom drawer, so it must remain available even when a
|
||||
// narrow window cannot satisfy the right-side workspace panel's width.
|
||||
const workspacePanelRenderable =
|
||||
workspacePanelOpen && (workspacePanelMaximized || workspacePanelRenderWidth >= rightDockMinRenderWidth);
|
||||
workspacePanelOpen && (
|
||||
workspacePanelMaximized ||
|
||||
rightDockMode === "terminal" ||
|
||||
workspacePanelRenderWidth >= rightDockMinRenderWidth
|
||||
);
|
||||
const workspacePanelGridOpen = workspacePanelRenderable && !workspacePanelMaximized;
|
||||
const resolveLiveWorkspacePanelRenderWidth = useCallback(
|
||||
(preferredWidth: number, nextSidebarWidth = sidebarWidth) =>
|
||||
@@ -1548,6 +1581,9 @@ export default function App() {
|
||||
? planRevisionInsertRequest.request
|
||||
: null;
|
||||
const composerInsertRequest = activeTabId ? composerInsertRequestsByTab[activeTabId] ?? null : null;
|
||||
const handleRevisionActiveChange = useCallback((active: boolean) => {
|
||||
setWorkspaceInsertTarget(active ? "planRevision" : "composer");
|
||||
}, []);
|
||||
const selectedTextRequest = activeTabId ? selectedTextRequestsByTab[activeTabId] ?? null : null;
|
||||
const prefillSubagentCommand = useCallback((command: string) => {
|
||||
if (!activeTabId) return;
|
||||
@@ -1623,7 +1659,12 @@ export default function App() {
|
||||
const toolApprovalMode = composerProfile.toolApprovalMode;
|
||||
const tokenMode: TokenMode = composerProfile.tokenMode;
|
||||
const runtimeTransitioning = Boolean(activeTabId && runtimeTransitionsByTab[activeTabId]) || workbenchTargetTransitioning(workbenchTarget);
|
||||
const controllerReady = state.meta?.ready === true && !state.backendActivationPending && !runtimeTransitioning;
|
||||
const controllerReady =
|
||||
state.meta?.ready === true &&
|
||||
(!state.meta.runtime || state.meta.runtime.phase === "ready") &&
|
||||
!state.meta.startupErr &&
|
||||
!state.backendActivationPending &&
|
||||
!runtimeTransitioning;
|
||||
// Single footer decision surface. Composer stays mounted underneath and is
|
||||
// only visually/a11y-hidden so per-session draft caches survive.
|
||||
const decisionSurface = useMemo((): DecisionSurfaceKind | null => {
|
||||
@@ -1663,6 +1704,16 @@ export default function App() {
|
||||
},
|
||||
[activeTabId, composerProfile],
|
||||
);
|
||||
const patchComposerProfileForTab = useCallback(
|
||||
(tabId: string, patch: Partial<Omit<ComposerProfile, "pending">>, pendingFields: ComposerProfileField[]) => {
|
||||
if (!tabId) return;
|
||||
setComposerProfilesByTab((current) => {
|
||||
const base = current[tabId] ?? composerProfileFromTab(tabMetas.find((tab) => tab.id === tabId));
|
||||
return patchComposerProfile(current, tabId, base, patch, pendingFields);
|
||||
});
|
||||
},
|
||||
[tabMetas],
|
||||
);
|
||||
const topicbarEditing = Boolean(activeTab?.topicId && activeTab.topicId === renamingTopicId);
|
||||
const visibleTabId = activeTabId;
|
||||
const visibleTabs = useMemo(() => {
|
||||
@@ -1737,14 +1788,15 @@ export default function App() {
|
||||
);
|
||||
const applyCollaborationMode = useCallback(
|
||||
async (m: CollaborationMode): Promise<void> => {
|
||||
userPlanModeByTabRef.current = updateUserPlanModeIntent(userPlanModeByTabRef.current, activeTabId, m === "plan");
|
||||
if (m === "goal") {
|
||||
userPlanModeByTabRef.current = updateUserPlanModeIntent(userPlanModeByTabRef.current, activeTabId, false);
|
||||
patchActiveComposerProfile({ collaborationMode: "normal", goalDraftMode: true, goal: "" }, ["collaborationMode", "goal"]);
|
||||
return setControllerCollaborationMode("normal");
|
||||
}
|
||||
patchActiveComposerProfile({ collaborationMode: m, goalDraftMode: false, goal: "" }, ["collaborationMode", "goal"]);
|
||||
if (goal.trim()) await clearControllerGoal();
|
||||
await setControllerCollaborationMode(m);
|
||||
userPlanModeByTabRef.current = updateUserPlanModeIntent(userPlanModeByTabRef.current, activeTabId, m === "plan");
|
||||
patchActiveComposerProfile({ collaborationMode: m, goalDraftMode: false, goal: "" }, ["collaborationMode", "goal"]);
|
||||
},
|
||||
[activeTabId, clearControllerGoal, goal, patchActiveComposerProfile, setControllerCollaborationMode],
|
||||
);
|
||||
@@ -1774,18 +1826,35 @@ export default function App() {
|
||||
}
|
||||
applyToolApprovalMode(next.mode);
|
||||
}, [activeTabId, applyToolApprovalMode, toolApprovalMode]);
|
||||
const applyGoal = useCallback(
|
||||
async (nextGoal: string): Promise<void> => {
|
||||
userPlanModeByTabRef.current = updateUserPlanModeIntent(userPlanModeByTabRef.current, activeTabId, false);
|
||||
const patchActivatedGoalForTab = useCallback(
|
||||
(tabId: string, nextGoal: string): void => {
|
||||
const trimmed = nextGoal.trim();
|
||||
patchActiveComposerProfile({
|
||||
patchComposerProfileForTab(tabId, {
|
||||
collaborationMode: trimmed ? "goal" : "normal",
|
||||
goalDraftMode: false,
|
||||
goal: trimmed,
|
||||
}, ["collaborationMode", "goal"]);
|
||||
await (trimmed ? setControllerGoal(trimmed) : clearControllerGoal());
|
||||
userPlanModeByTabRef.current = updateUserPlanModeIntent(userPlanModeByTabRef.current, tabId, false);
|
||||
},
|
||||
[activeTabId, clearControllerGoal, patchActiveComposerProfile, setControllerGoal],
|
||||
[patchComposerProfileForTab],
|
||||
);
|
||||
const applyGoalForTab = useCallback(
|
||||
async (tabId: string, nextGoal: string): Promise<void> => {
|
||||
if (!tabId) return;
|
||||
const trimmed = nextGoal.trim();
|
||||
// Activate the backend Goal first. Only then patch the local profile so a
|
||||
// failed SetGoalForTab cannot leave the Composer thinking a Goal is active.
|
||||
await (trimmed ? setControllerGoalForTab(tabId, trimmed) : clearControllerGoalForTab(tabId));
|
||||
patchActivatedGoalForTab(tabId, trimmed);
|
||||
},
|
||||
[clearControllerGoalForTab, patchActivatedGoalForTab, setControllerGoalForTab],
|
||||
);
|
||||
const applyGoal = useCallback(
|
||||
async (nextGoal: string): Promise<void> => {
|
||||
if (!activeTabId) return;
|
||||
await applyGoalForTab(activeTabId, nextGoal);
|
||||
},
|
||||
[activeTabId, applyGoalForTab],
|
||||
);
|
||||
const applyTokenMode = useCallback(
|
||||
async (m: TokenMode): Promise<void> => {
|
||||
@@ -1817,20 +1886,27 @@ export default function App() {
|
||||
// Shift+Tab toggles only the collaboration axis; Ctrl/Cmd+Y toggles YOLO on the
|
||||
// tool-permission axis while preserving the Ask/Auto base mode.
|
||||
const cycleMode = useCallback(() => {
|
||||
applyCollaborationMode(collaborationMode === "plan" ? "normal" : "plan");
|
||||
}, [applyCollaborationMode, collaborationMode]);
|
||||
runGoalAction(() => applyCollaborationMode(collaborationMode === "plan" ? "normal" : "plan"));
|
||||
}, [applyCollaborationMode, collaborationMode, runGoalAction]);
|
||||
|
||||
// Switching models rebuilds the controller, which starts in normal mode — so
|
||||
// re-apply the current mode, or the pill would say plan/YOLO while the fresh
|
||||
// controller silently uses normal gating.
|
||||
const switchModel = useCallback(
|
||||
async (name: string) => {
|
||||
await setModel(name);
|
||||
await setControllerCollaborationMode(controllerComposerProfileCollaborationMode(composerProfile));
|
||||
await setControllerToolApprovalMode(toolApprovalMode);
|
||||
if (goal.trim()) await setControllerGoal(goal);
|
||||
const switched = await setModel(name);
|
||||
if (!switched) return false;
|
||||
if (!activeTabId) return false;
|
||||
const profileApplied = await setControllerComposerProfileForTab(
|
||||
activeTabId,
|
||||
controllerComposerProfileCollaborationMode(composerProfile),
|
||||
toolApprovalMode,
|
||||
goal,
|
||||
{ propagateError: true },
|
||||
);
|
||||
return profileApplied;
|
||||
},
|
||||
[composerProfile, goal, setControllerCollaborationMode, setControllerGoal, setControllerToolApprovalMode, setModel, toolApprovalMode],
|
||||
[activeTabId, composerProfile, goal, setControllerComposerProfileForTab, setModel, toolApprovalMode],
|
||||
);
|
||||
|
||||
// Startup and workspace/model rebuilds create a fresh controller in normal
|
||||
@@ -1838,11 +1914,17 @@ export default function App() {
|
||||
// where the user picked YOLO while boot was still loading and the legacy
|
||||
// SetBypass binding was a harmless no-op.
|
||||
useEffect(() => {
|
||||
if (!controllerReady) return;
|
||||
void setControllerCollaborationMode(controllerComposerProfileCollaborationMode(composerProfile));
|
||||
void setControllerToolApprovalMode(toolApprovalMode);
|
||||
if (goal.trim()) void setControllerGoal(goal);
|
||||
}, [composerProfile, controllerReady, goal, setControllerCollaborationMode, setControllerGoal, setControllerToolApprovalMode, toolApprovalMode]);
|
||||
if (!controllerReady || !activeTabId) return;
|
||||
runGoalAction(async () => {
|
||||
await setControllerComposerProfileForTab(
|
||||
activeTabId,
|
||||
controllerComposerProfileCollaborationMode(composerProfile),
|
||||
toolApprovalMode,
|
||||
goal,
|
||||
{ propagateError: true },
|
||||
);
|
||||
});
|
||||
}, [activeTabId, composerProfile, controllerReady, goal, runGoalAction, setControllerComposerProfileForTab, toolApprovalMode]);
|
||||
|
||||
// The live task list pinned above the composer comes from the most recent
|
||||
// successful top-level todo_write result; failed or still-running attempts do
|
||||
@@ -1904,12 +1986,12 @@ export default function App() {
|
||||
applyThemeScene(sessionHasContent ? "task" : "home");
|
||||
}, [sessionHasContent]);
|
||||
const getSessionMarkdown = useCallback(
|
||||
() => sessionItemsToMarkdown(sessionTitle, state.items, state.live),
|
||||
[sessionTitle, state.items, state.live],
|
||||
() => sessionItemsToMarkdown(sessionTitle, state.items, liveStore.getSnapshot(activeTabId) ?? state.live),
|
||||
[activeTabId, liveStore, sessionTitle, state.items, state.live],
|
||||
);
|
||||
const getSessionJson = useCallback(
|
||||
() => sessionItemsToJson(sessionTitle, state.items, state.live),
|
||||
[sessionTitle, state.items, state.live],
|
||||
() => sessionItemsToJson(sessionTitle, state.items, liveStore.getSnapshot(activeTabId) ?? state.live),
|
||||
[activeTabId, liveStore, sessionTitle, state.items, state.live],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -2041,7 +2123,7 @@ export default function App() {
|
||||
}
|
||||
const model = /^\/model\s+(\S+)$/.exec(trimmed);
|
||||
if (model) {
|
||||
void switchModel(model[1]);
|
||||
await switchModel(model[1]);
|
||||
return;
|
||||
}
|
||||
if (trimmed === "/memory") {
|
||||
@@ -2079,8 +2161,29 @@ export default function App() {
|
||||
}
|
||||
if (collaborationMode === "goal" && !goal.trim()) {
|
||||
if (!controllerReady) return;
|
||||
await applyGoal(trimmed);
|
||||
await commitThenSendRef.current(sourceTabId, trimmed, `/goal ${submitText.trim()}`);
|
||||
const sourceTarget = workbenchTargetToken(workbenchTarget);
|
||||
if (!sourceTarget) throw new Error(t("composer.workspaceStarting"));
|
||||
await activateGoalAndSubmitOnTab({
|
||||
tabId: sourceTabId,
|
||||
target: sourceTarget,
|
||||
displayText: trimmed,
|
||||
submitText,
|
||||
structured,
|
||||
sendToTab: (tabId, nextGoal, display, routedSubmit, routedStructured, target) =>
|
||||
commitThenSendRef.current(
|
||||
tabId,
|
||||
display,
|
||||
routedSubmit,
|
||||
routedStructured,
|
||||
target ? {
|
||||
goal: nextGoal,
|
||||
target,
|
||||
collaborationMode: controllerComposerProfileCollaborationMode(composerProfile),
|
||||
toolApprovalMode,
|
||||
} : undefined,
|
||||
),
|
||||
});
|
||||
patchActivatedGoalForTab(sourceTabId, trimmed);
|
||||
return;
|
||||
}
|
||||
const theme = /^\/theme(?:\s+(\S+))?$/.exec(trimmed);
|
||||
@@ -2128,13 +2231,17 @@ export default function App() {
|
||||
return;
|
||||
}
|
||||
if (!controllerReady) return;
|
||||
await setControllerCollaborationModeForTab(sourceTabId, controllerComposerProfileCollaborationMode(composerProfile));
|
||||
await setControllerToolApprovalModeForTab(sourceTabId, toolApprovalMode);
|
||||
if (goal.trim()) await setControllerGoalForTab(sourceTabId, goal);
|
||||
const profileApplied = await setControllerComposerProfileForTab(
|
||||
sourceTabId,
|
||||
controllerComposerProfileCollaborationMode(composerProfile),
|
||||
toolApprovalMode,
|
||||
goal,
|
||||
);
|
||||
if (!profileApplied) return;
|
||||
await commitThenSendRef.current(sourceTabId, trimmed, submitText.trim(), structured);
|
||||
},
|
||||
[activeTabId, applyGoal, closeTransientOverlays, collaborationMode, composerProfile, controllerReady, goal, notice, runShellForTab,
|
||||
setControllerCollaborationModeForTab, setControllerGoalForTab, setControllerToolApprovalModeForTab, switchModel, t, toolApprovalMode, showToast],
|
||||
patchActivatedGoalForTab, setControllerComposerProfileForTab, switchModel, t, toolApprovalMode, showToast, workbenchTarget],
|
||||
);
|
||||
|
||||
const handleSteer = useCallback(async (text: string, requestedTabId = activeTabId) => {
|
||||
@@ -2143,9 +2250,37 @@ export default function App() {
|
||||
await steerForTab(sourceTabId, text.trim());
|
||||
}, [activeTabId, steerForTab, t]);
|
||||
|
||||
const refreshTabMetas = useCallback(async (): Promise<TabMeta[]> => {
|
||||
const tabs = asArray(await app.ListTabs().catch(() => [] as TabMeta[]));
|
||||
setTabMetas(tabs);
|
||||
const setCollaborationModeFromUi = useCallback((mode: CollaborationMode) => {
|
||||
runGoalAction(() => applyCollaborationMode(mode));
|
||||
}, [applyCollaborationMode, runGoalAction]);
|
||||
const clearGoalFromUi = useCallback(() => {
|
||||
runGoalAction(() => applyGoal(""));
|
||||
}, [applyGoal, runGoalAction]);
|
||||
const switchModelFromUi = useCallback(async (name: string): Promise<boolean> => {
|
||||
try {
|
||||
return await switchModel(name);
|
||||
} catch (error) {
|
||||
handleGoalActionError(error);
|
||||
return false;
|
||||
}
|
||||
}, [handleGoalActionError, switchModel]);
|
||||
|
||||
const tabMetaRefreshCoordinatorRef = useRef<ReturnType<typeof createBoundedRefreshCoordinator<TabMeta[]>> | null>(null);
|
||||
if (!tabMetaRefreshCoordinatorRef.current) {
|
||||
tabMetaRefreshCoordinatorRef.current = createBoundedRefreshCoordinator<TabMeta[]>(TAB_META_MAX_IN_FLIGHT);
|
||||
}
|
||||
const refreshTabMetas = useCallback(async (
|
||||
apply?: () => boolean,
|
||||
options?: { afterMutation?: boolean },
|
||||
): Promise<TabMeta[]> => {
|
||||
const result = await tabMetaRefreshCoordinatorRef.current!.run(
|
||||
async () => asArray(await app.ListTabs().catch(() => [] as TabMeta[])),
|
||||
options?.afterMutation ? { invalidate: true } : undefined,
|
||||
);
|
||||
const tabs = result.value;
|
||||
if (result.latest && (!apply || apply())) {
|
||||
setTabMetas((current) => sameTabMetaLists(current, tabs) ? current : tabs);
|
||||
}
|
||||
return tabs;
|
||||
}, []);
|
||||
const seedActiveTabMeta = useCallback((tab: TabMeta): void => {
|
||||
@@ -2166,11 +2301,14 @@ export default function App() {
|
||||
|
||||
useEffect(() => {
|
||||
const unsub = onEvent((e) => {
|
||||
if (shouldRefreshTabMetaForEvent(e.kind)) {
|
||||
void refreshTabMetas(undefined, { afterMutation: true });
|
||||
}
|
||||
if (e.kind !== "turn_done") return;
|
||||
const turnTabId = resolvePlanRestoreTabId(e.tabId, activeTabIdRef.current);
|
||||
window.setTimeout(() => {
|
||||
setProjectRevision((value) => value + 1);
|
||||
refreshTabMetas().then((tabs) => {
|
||||
refreshTabMetas(undefined, { afterMutation: true }).then((tabs) => {
|
||||
if (!turnTabId) return;
|
||||
const tab = tabs.find((item) => item.id === turnTabId);
|
||||
const baseProfile = tab ? composerProfileFromTab(tab) : defaultComposerProfile;
|
||||
@@ -2203,15 +2341,41 @@ export default function App() {
|
||||
}, [activeTab?.scope, activeTab?.workspaceRoot]);
|
||||
|
||||
useEffect(() => {
|
||||
void refreshTabMetas();
|
||||
const id = window.setInterval(() => void refreshTabMetas(), 2000);
|
||||
return () => window.clearInterval(id);
|
||||
let cancelled = false;
|
||||
let timer: number | undefined;
|
||||
const schedule = () => {
|
||||
if (cancelled) return;
|
||||
timer = window.setTimeout(() => {
|
||||
void refreshTabMetas();
|
||||
schedule();
|
||||
}, tabMetaFallbackDelay(document.visibilityState));
|
||||
};
|
||||
const refreshAndSchedule = () => {
|
||||
if (timer !== undefined) window.clearTimeout(timer);
|
||||
timer = undefined;
|
||||
void refreshTabMetas();
|
||||
schedule();
|
||||
};
|
||||
const onVisibilityChange = () => {
|
||||
if (document.visibilityState === "visible") refreshAndSchedule();
|
||||
else {
|
||||
if (timer !== undefined) window.clearTimeout(timer);
|
||||
schedule();
|
||||
}
|
||||
};
|
||||
refreshAndSchedule();
|
||||
document.addEventListener("visibilitychange", onVisibilityChange);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (timer !== undefined) window.clearTimeout(timer);
|
||||
document.removeEventListener("visibilitychange", onVisibilityChange);
|
||||
};
|
||||
}, [refreshTabMetas]);
|
||||
|
||||
useEffect(() => {
|
||||
return onProjectTreeChanged(() => {
|
||||
setProjectRevision((value) => value + 1);
|
||||
void refreshTabMetas();
|
||||
void refreshTabMetas(undefined, { afterMutation: true });
|
||||
});
|
||||
}, [refreshTabMetas]);
|
||||
|
||||
@@ -2578,6 +2742,25 @@ export default function App() {
|
||||
[openWorkspacePanel],
|
||||
);
|
||||
|
||||
const openTerminalForPath = useCallback(
|
||||
(path = ".") => {
|
||||
openRightDockMode("terminal");
|
||||
if (!activeTabId) return;
|
||||
void useTerminalStore.getState().createSession(activeTabId, path || ".", "default").catch(() => {});
|
||||
},
|
||||
[activeTabId, openRightDockMode],
|
||||
);
|
||||
|
||||
useGlobalShortcut("terminal.toggle", () => {
|
||||
if (workspacePanelRenderable && rightDockMode === "terminal") closeWorkspacePanel();
|
||||
else openRightDockMode("terminal");
|
||||
}, [closeWorkspacePanel, openRightDockMode, rightDockMode, workspacePanelRenderable]);
|
||||
useGlobalShortcut("terminal.newSession", () => {
|
||||
if (!activeTabId) return;
|
||||
openRightDockMode("terminal");
|
||||
void useTerminalStore.getState().createSession(activeTabId, ".", "default").catch(() => {});
|
||||
}, [activeTabId, openRightDockMode]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!remoteExplorerOpen) return;
|
||||
openRightDockMode("remote");
|
||||
@@ -2707,6 +2890,21 @@ export default function App() {
|
||||
}
|
||||
}, [activeTabId, state.approval, workspaceInsertTarget]);
|
||||
|
||||
const addTerminalOutputToComposer = useCallback(async (sessionId: string) => {
|
||||
if (!activeTabId) return;
|
||||
try {
|
||||
const output = await app.TerminalOutputForTab(activeTabId, sessionId);
|
||||
const formatted = formatTerminalOutputForComposer(output);
|
||||
if (!formatted) {
|
||||
showToast(t("terminal.noOutput"), "info");
|
||||
return;
|
||||
}
|
||||
addWorkspaceTextToComposer(formatted);
|
||||
} catch (error) {
|
||||
showToast(error instanceof Error ? error.message : String(error), "error");
|
||||
}
|
||||
}, [activeTabId, addWorkspaceTextToComposer, showToast, t]);
|
||||
|
||||
const addSelectedTextToComposer = useCallback((text: string) => {
|
||||
const selected = text.trim();
|
||||
if (!activeTabId || !selected) return;
|
||||
@@ -2745,18 +2943,28 @@ export default function App() {
|
||||
// activation around it.
|
||||
const tabSwitchSeqRef = useRef(0);
|
||||
const tabSwitchRunningRef = useRef(false);
|
||||
const tabSwitchPendingRef = useRef<PendingNavigationRequest<{ tabId: string; optimisticTab?: TabMeta }> | null>(null);
|
||||
const tabSwitchPendingRef = useRef<PendingNavigationRequest<{ tabId: string; optimisticTab?: TabMeta; navigationIntentSeq: number }> | null>(null);
|
||||
const enqueueTabSwitch = useCallback(
|
||||
(tabId: string, optimisticTab?: TabMeta): Promise<void> =>
|
||||
enqueueNavigationRequest(
|
||||
(tabId: string, optimisticTab?: TabMeta): Promise<void> => {
|
||||
// Claim the shared navigation epoch at click time, before this request
|
||||
// can wait behind an older tab switch. That immediately invalidates any
|
||||
// in-flight blank/topic completion from a previous user intent.
|
||||
const navigationIntentSeq = noteNavigationIntent();
|
||||
return enqueueNavigationRequest(
|
||||
{ seqRef: tabSwitchSeqRef, runningRef: tabSwitchRunningRef, pendingRef: tabSwitchPendingRef },
|
||||
{ tabId, optimisticTab },
|
||||
{ tabId, optimisticTab, navigationIntentSeq },
|
||||
async (request) => {
|
||||
await switchTab(request.tabId, request.optimisticTab);
|
||||
await refreshTabMetas();
|
||||
if (!isNavigationIntentCurrent(request.navigationIntentSeq)) return;
|
||||
await switchTab(request.tabId, request.optimisticTab, request.navigationIntentSeq);
|
||||
if (!isNavigationIntentCurrent(request.navigationIntentSeq)) return;
|
||||
await refreshTabMetas(
|
||||
() => isNavigationIntentCurrent(request.navigationIntentSeq),
|
||||
{ afterMutation: true },
|
||||
);
|
||||
},
|
||||
),
|
||||
[switchTab, refreshTabMetas],
|
||||
);
|
||||
},
|
||||
[isNavigationIntentCurrent, noteNavigationIntent, refreshTabMetas, switchTab],
|
||||
);
|
||||
|
||||
const handleTabChange = useCallback((id: string) => {
|
||||
@@ -2787,7 +2995,7 @@ export default function App() {
|
||||
return remaining.map((tab) => ({ ...tab, active: tab.id === nextActiveId }));
|
||||
});
|
||||
await closeTab(id);
|
||||
await refreshTabMetas();
|
||||
await refreshTabMetas(undefined, { afterMutation: true });
|
||||
setTabRevealSignal((signal) => signal + 1);
|
||||
}, [activeTabId, closeTab, closeTransientOverlays, refreshTabMetas]);
|
||||
|
||||
@@ -2804,7 +3012,7 @@ export default function App() {
|
||||
setTabMetas((current) => current.map((tab) => ({ ...tab, active: tab.id === nextActiveTabId })));
|
||||
void enqueueTabSwitch(nextActiveTabId, selected);
|
||||
}
|
||||
await refreshTabMetas();
|
||||
await refreshTabMetas(undefined, { afterMutation: true });
|
||||
setTabRevealSignal((signal) => signal + 1);
|
||||
}, [closeTab, closeTransientOverlays, enqueueTabSwitch, refreshTabMetas, tabMetas]);
|
||||
|
||||
@@ -2816,7 +3024,7 @@ export default function App() {
|
||||
return ordered.length === current.length ? ordered : current;
|
||||
});
|
||||
await reorderTabs(ids);
|
||||
await refreshTabMetas();
|
||||
await refreshTabMetas(undefined, { afterMutation: true });
|
||||
setTabRevealSignal((signal) => signal + 1);
|
||||
}, [refreshTabMetas, reorderTabs]);
|
||||
|
||||
@@ -2884,11 +3092,28 @@ export default function App() {
|
||||
}, [state.items]);
|
||||
|
||||
// send wrapper: commits any pending optimistic rewind before sending.
|
||||
const commitThenSend = useCallback(async (sourceTabId: string, displayText: string, submitText?: string, structured?: StructuredInvocationSubmit) => {
|
||||
const commitThenSend = useCallback(async (
|
||||
sourceTabId: string,
|
||||
displayText: string,
|
||||
submitText?: string,
|
||||
structured?: StructuredInvocationSubmit,
|
||||
initialGoal?: {
|
||||
goal: string;
|
||||
target: WorkbenchTargetToken;
|
||||
collaborationMode: CollaborationMode;
|
||||
toolApprovalMode: ToolApprovalMode;
|
||||
},
|
||||
) => {
|
||||
const sourceTab = tabMetas.find((tab) => tab.id === sourceTabId);
|
||||
if (!sourceTab) throw new Error(t("composer.workspaceStarting"));
|
||||
if (sourceTab.readOnly) throw new Error(t("composer.readOnlyChannel"));
|
||||
if (sourceTab.ready === false) throw new Error(t("composer.workspaceStarting"));
|
||||
if (
|
||||
sourceTab.ready !== true ||
|
||||
(sourceTab.runtime && sourceTab.runtime.phase !== "ready") ||
|
||||
sourceTab.startupErr
|
||||
) {
|
||||
throw new Error(sourceTab.runtime?.issue?.message || sourceTab.startupErr || t("composer.workspaceStarting"));
|
||||
}
|
||||
const rs = rewindStatesByTabRef.current[sourceTabId];
|
||||
if (rs) {
|
||||
setRewindStateForTab(sourceTabId, null);
|
||||
@@ -2912,7 +3137,7 @@ export default function App() {
|
||||
setProjectRevision((v) => v + 1);
|
||||
}
|
||||
}
|
||||
await sendToTab(sourceTabId, displayText, submitText, undefined, structured);
|
||||
await sendToTab(sourceTabId, displayText, submitText, undefined, structured, initialGoal);
|
||||
}, [rewindForTab, sendToTab, setRewindCommittingForTab, setRewindStateForTab, t, tabMetas]);
|
||||
|
||||
const handleTranscriptPrompt = useCallback((text: string) => {
|
||||
@@ -2942,7 +3167,7 @@ export default function App() {
|
||||
// Fork still goes through the controller (not optimistic).
|
||||
rewindForTab(sourceTabId, turn, scope).then((ok) => {
|
||||
if (!ok) return;
|
||||
refreshTabMetas();
|
||||
void refreshTabMetas(undefined, { afterMutation: true });
|
||||
setProjectRevision((v) => v + 1);
|
||||
});
|
||||
return;
|
||||
@@ -3076,21 +3301,24 @@ export default function App() {
|
||||
const navigationRunningRef = useRef(false);
|
||||
const navigationPendingRef = useRef<PendingDesktopNavigationRequest | null>(null);
|
||||
const runNavigationRequest = useCallback(async (request: PendingDesktopNavigationRequest) => {
|
||||
const latest = () => request.seq === navigationSeqRef.current;
|
||||
const latest = () => request.seq === navigationSeqRef.current && isNavigationIntentCurrent(request.navigationIntentSeq);
|
||||
if (!latest()) return;
|
||||
const refreshLatestTabMetas = async (): Promise<TabMeta[]> => {
|
||||
const tabs = asArray(await app.ListTabs().catch(() => [] as TabMeta[]));
|
||||
if (latest()) setTabMetas(tabs);
|
||||
return tabs;
|
||||
};
|
||||
const openTopicTarget = async (scope: string, workspaceRoot: string, topicId: string, sessionPath?: string): Promise<TabMeta> => {
|
||||
if (singleSurfaceLayout) return activateTopic(scope, workspaceRoot, topicId, sessionPath || "");
|
||||
if (sessionPath) return openTopicSession(scope, workspaceRoot, topicId, sessionPath);
|
||||
if (scope === "global") return openGlobalTab(topicId);
|
||||
return openProjectTab(workspaceRoot, topicId);
|
||||
if (singleSurfaceLayout) return activateTopic(scope, workspaceRoot, topicId, sessionPath || "", request.navigationIntentSeq);
|
||||
if (sessionPath) return openTopicSession(scope, workspaceRoot, topicId, sessionPath, request.navigationIntentSeq);
|
||||
if (scope === "global") return openGlobalTab(topicId, request.navigationIntentSeq);
|
||||
return openProjectTab(workspaceRoot, topicId, request.navigationIntentSeq);
|
||||
};
|
||||
const openBlankTarget = async (scope: string, workspaceRoot: string): Promise<TabMeta> => {
|
||||
const root = scope === "project" ? workspaceRoot : "";
|
||||
return singleSurfaceLayout ? ensureBlankSurface(scope, root) : ensureBlankTab(scope, root);
|
||||
return singleSurfaceLayout
|
||||
? ensureBlankSurface(scope, root, request.navigationIntentSeq)
|
||||
: ensureBlankTab(scope, root, request.navigationIntentSeq);
|
||||
};
|
||||
|
||||
try {
|
||||
@@ -3117,7 +3345,7 @@ export default function App() {
|
||||
}
|
||||
|
||||
if (request.kind === "delivery-worktree") {
|
||||
const result = await createDeliveryWorktree(request.workspaceRoot);
|
||||
const result = await createDeliveryWorktree(request.workspaceRoot, request.navigationIntentSeq);
|
||||
if (!latest()) return;
|
||||
seedActiveTabMeta(result.tab);
|
||||
setProjectRevision((value) => value + 1);
|
||||
@@ -3146,11 +3374,11 @@ export default function App() {
|
||||
if (connection.sessionSource === "auto" && target.kind === "path") {
|
||||
openedTab = await openBlankTarget(connection.scope, connection.workspaceRoot);
|
||||
if (!latest()) return;
|
||||
await openChannelSession(target.value, openedTab.id);
|
||||
await openChannelSession(target.value, openedTab.id, request.navigationIntentSeq);
|
||||
} else if (target.kind === "path") {
|
||||
openedTab = await openBlankTarget(connection.scope, connection.workspaceRoot);
|
||||
if (!latest()) return;
|
||||
await resumeSession(target.value, openedTab.id);
|
||||
await resumeSession(target.value, openedTab.id, request.navigationIntentSeq);
|
||||
} else {
|
||||
openedTab = await openTopicTarget(connection.scope, connection.workspaceRoot, target.value);
|
||||
}
|
||||
@@ -3170,7 +3398,7 @@ export default function App() {
|
||||
if (isChannelSession(session)) {
|
||||
targetTab = await openBlankTarget(scope === "project" ? "project" : "global", scope === "project" ? session.workspaceRoot || "" : "");
|
||||
if (!latest()) return;
|
||||
await openChannelSession(session.path, targetTab.id);
|
||||
await openChannelSession(session.path, targetTab.id, request.navigationIntentSeq);
|
||||
} else if (scope === "project" && session.workspaceRoot && session.topicId) {
|
||||
targetTab = await openTopicTarget("project", session.workspaceRoot, session.topicId, session.path);
|
||||
} else if (scope === "global" && session.topicId) {
|
||||
@@ -3216,19 +3444,19 @@ export default function App() {
|
||||
showToast(err?.message || String(err));
|
||||
}
|
||||
}
|
||||
}, [activateTopic, createDeliveryWorktree, ensureBlankSurface, ensureBlankTab, openChannelSession, openGlobalTab, openProjectTab, openTopicSession, refreshHistoryView, resumeSession, seedActiveTabMeta, showToast, singleSurfaceLayout, t]);
|
||||
}, [activateTopic, createDeliveryWorktree, ensureBlankSurface, ensureBlankTab, isNavigationIntentCurrent, openChannelSession, openGlobalTab, openProjectTab, openTopicSession, refreshHistoryView, resumeSession, seedActiveTabMeta, showToast, singleSurfaceLayout, t]);
|
||||
|
||||
const enqueueNavigation = useCallback((input: DesktopNavigationInput): Promise<void> => {
|
||||
const enqueueNavigation = useCallback((input: DesktopNavigationIntent): Promise<void> => {
|
||||
// Invalidate any in-flight activation's stale apply at ENQUEUE time. The
|
||||
// queue serializes requests, so a click made while another request runs
|
||||
// only advances the controller's navigation epoch when it eventually
|
||||
// starts — too late: the running request's ActivateTopic would resolve,
|
||||
// pass the controller-local guard, flip the visible tab, and prune the
|
||||
// newer surface's cached state (#6613 review).
|
||||
noteNavigationIntent();
|
||||
const navigationIntentSeq = noteNavigationIntent();
|
||||
return enqueueNavigationRequest(
|
||||
{ seqRef: navigationSeqRef, runningRef: navigationRunningRef, pendingRef: navigationPendingRef },
|
||||
input,
|
||||
{ ...input, navigationIntentSeq } as DesktopNavigationInput,
|
||||
runNavigationRequest,
|
||||
);
|
||||
}, [noteNavigationIntent, runNavigationRequest]);
|
||||
@@ -3239,7 +3467,7 @@ export default function App() {
|
||||
|
||||
useEffect(() => onSessionRecovered(() => {
|
||||
setProjectRevision((value) => value + 1);
|
||||
void refreshTabMetas();
|
||||
void refreshTabMetas(undefined, { afterMutation: true });
|
||||
}), [refreshTabMetas]);
|
||||
|
||||
const handleNewTab = useCallback(async () => {
|
||||
@@ -3340,6 +3568,7 @@ export default function App() {
|
||||
},
|
||||
{ id: "cmd-memory", group: t("palette.group.commands"), title: t("palette.cmd.memory"), icon: <Brain size={15} />, compact: true, keywords: ["memory", "记忆"], run: () => setSettingsTarget("memory") },
|
||||
{ id: "cmd-models", group: t("palette.group.commands"), title: t("palette.cmd.models"), icon: <Cpu size={15} />, compact: true, keywords: ["model", "模型"], run: () => setSettingsTarget("models") },
|
||||
{ id: "cmd-terminal", group: t("palette.group.commands"), title: t("rightDock.terminal"), icon: <TerminalSquare size={15} />, compact: true, keywords: ["terminal", "shell", "终端"], run: () => openRightDockMode("terminal") },
|
||||
];
|
||||
const startOfDay = (d: Date) => new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();
|
||||
const dayLabel = (ms: number) => {
|
||||
@@ -3378,7 +3607,7 @@ export default function App() {
|
||||
};
|
||||
});
|
||||
return [...cmds, ...remoteItems, ...sessionItems];
|
||||
}, [t, paletteSessions, remoteHosts, remoteStatuses, handleNewTab, openTrash, onResumeSession, openRemoteWorkspaceFromStatus, connectAndOpenRemoteWorkspace]);
|
||||
}, [t, paletteSessions, remoteHosts, remoteStatuses, handleNewTab, openTrash, onResumeSession, openRemoteWorkspaceFromStatus, connectAndOpenRemoteWorkspace, openRightDockMode]);
|
||||
// Delete / rename act on disk, then re-fetch so the panel reflects the change.
|
||||
const onDeleteSession = useCallback(
|
||||
async (path: string) => {
|
||||
@@ -3475,14 +3704,14 @@ export default function App() {
|
||||
const picked = path === undefined ? await pickWorkspace() : await switchWorkspace(path);
|
||||
if (picked) {
|
||||
setProjectRevision((value) => value + 1);
|
||||
await refreshTabMetas();
|
||||
await refreshTabMetas(undefined, { afterMutation: true });
|
||||
}
|
||||
return picked;
|
||||
}, [pickWorkspace, switchWorkspace, refreshTabMetas]);
|
||||
|
||||
const refreshProjectsAndTabs = useCallback(async () => {
|
||||
setProjectRevision((value) => value + 1);
|
||||
const tabs = await refreshTabMetas();
|
||||
const tabs = await refreshTabMetas(undefined, { afterMutation: true });
|
||||
if (activeTabId && !tabs.some((tab) => tab.id === activeTabId)) {
|
||||
await syncActiveTab(false);
|
||||
}
|
||||
@@ -3615,6 +3844,7 @@ export default function App() {
|
||||
sidebarCollapsed ? "layout--sidebar-collapsed" : "",
|
||||
sidebarResizing ? "layout--resizing layout--sidebar-resizing" : "",
|
||||
workspacePanelGridOpen ? "layout--workspace-open" : "",
|
||||
workspacePanelGridOpen && rightDockMode === "terminal" ? "layout--terminal-open" : "",
|
||||
workspacePanelOpen && workspacePanelMaximized ? "layout--workspace-maximized" : "",
|
||||
workspacePanelResizing ? "layout--resizing layout--workspace-resizing" : "",
|
||||
]
|
||||
@@ -4054,6 +4284,19 @@ export default function App() {
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
{!sidebarImDetailConnection && (
|
||||
<Tooltip label={t("rightDock.terminal")}>
|
||||
<button
|
||||
className="topicbar__action-btn topicbar__action-btn--icon topicbar__action-btn--utility"
|
||||
type="button"
|
||||
aria-label={t("rightDock.terminal")}
|
||||
aria-pressed={workspacePanelRenderable && rightDockMode === "terminal"}
|
||||
onClick={() => openRightDockMode("terminal")}
|
||||
>
|
||||
<TerminalSquare size={14} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
{!sidebarImDetailConnection && activeTab?.scope === "project" && (
|
||||
<ExternalOpener tabId={activeTab.id} dismissSignal={transientOverlayDismissSignal} />
|
||||
)}
|
||||
@@ -4149,6 +4392,7 @@ export default function App() {
|
||||
<Transcript
|
||||
items={displayItems}
|
||||
live={state.live}
|
||||
liveStore={liveStore}
|
||||
tabId={activeTabId}
|
||||
footerHeight={footerHeight}
|
||||
onPrompt={handleTranscriptPrompt}
|
||||
@@ -4212,7 +4456,7 @@ export default function App() {
|
||||
tabId={activeTabId}
|
||||
workspaceScopeKey={workspaceScopeKey}
|
||||
insertRequest={activePlanRevisionInsertRequest}
|
||||
onRevisionActiveChange={(active) => setWorkspaceInsertTarget(active ? "planRevision" : "composer")}
|
||||
onRevisionActiveChange={handleRevisionActiveChange}
|
||||
onAnswer={async (allow, session, persist) => {
|
||||
// Approving an exit_plan_mode plan leaves plan mode; await the
|
||||
// mode switch before sending the approval so the controller
|
||||
@@ -4284,11 +4528,11 @@ export default function App() {
|
||||
onCancel={cancel}
|
||||
onCycleMode={cycleMode}
|
||||
onSetMode={applyMode}
|
||||
onSetCollaborationMode={applyCollaborationMode}
|
||||
onSetCollaborationMode={setCollaborationModeFromUi}
|
||||
onSetToolApprovalMode={applyToolApprovalMode}
|
||||
onToggleYoloApprovalMode={toggleYoloApprovalMode}
|
||||
onClearGoal={() => applyGoal("")}
|
||||
onSwitchModel={switchModel}
|
||||
onClearGoal={clearGoalFromUi}
|
||||
onSwitchModel={switchModelFromUi}
|
||||
onSetEffort={setEffort}
|
||||
onSetTokenMode={applyTokenMode}
|
||||
insertRequest={composerInsertRequest}
|
||||
@@ -4315,6 +4559,7 @@ export default function App() {
|
||||
showContextWindowRing={sidebarCreation}
|
||||
context={state.context}
|
||||
turnCost={state.turnCost}
|
||||
currency={state.sessionCurrency}
|
||||
cacheHitTokens={state.usage?.cacheHitTokens}
|
||||
cacheMissTokens={state.usage?.cacheMissTokens}
|
||||
balance={state.balance}
|
||||
@@ -4426,6 +4671,16 @@ export default function App() {
|
||||
<span className="workbench-dock__tab-label">{t("rightDock.remote")}</span>
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={rightDockMode === "terminal"}
|
||||
className={`workbench-dock__tab${rightDockMode === "terminal" ? " workbench-dock__tab--active" : ""}`}
|
||||
onClick={() => openRightDockMode("terminal")}
|
||||
>
|
||||
<TerminalSquare size={13} />
|
||||
<span className="workbench-dock__tab-label">{t("rightDock.terminal")}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="workbench-dock__body">
|
||||
@@ -4433,6 +4688,16 @@ export default function App() {
|
||||
<Suspense fallback={null}>
|
||||
<RemotePanel onClose={() => setWorkspacePanel(false)} />
|
||||
</Suspense>
|
||||
) : rightDockMode === "terminal" ? (
|
||||
<Suspense fallback={<div className="terminal-empty"><span className="terminal-empty__spinner" />{t("terminal.loading")}</div>}>
|
||||
<TerminalPanel
|
||||
tabId={activeTabId ?? ""}
|
||||
cwd={state.meta?.cwd}
|
||||
readOnly={Boolean(activeTab?.readOnly)}
|
||||
onClose={() => setWorkspacePanel(false)}
|
||||
onAddOutput={(sessionId) => void addTerminalOutputToComposer(sessionId)}
|
||||
/>
|
||||
</Suspense>
|
||||
) : rightDockMode === "context" && desktopLayoutStyle !== "creation" ? (
|
||||
<ContextPanel
|
||||
tabId={activeTabId}
|
||||
@@ -4469,6 +4734,7 @@ export default function App() {
|
||||
onAddCodeToChat={addWorkspaceCodeToComposer}
|
||||
onRequestPanelWidth={ensureWorkspacePanelWidth}
|
||||
onFileTreeRefresh={refreshComposerFileRefs}
|
||||
onOpenInTerminal={openTerminalForPath}
|
||||
refreshKey={dockRefreshKey}
|
||||
initialViewMode={rightDockMode === "changed" ? "changed" : "files"}
|
||||
showViewTabs={false}
|
||||
|
||||
@@ -298,8 +298,16 @@ await waitFor("queued last click applies once it runs", () => controller?.active
|
||||
// enqueue time — the queue-based scenario above only proves the mechanism.
|
||||
const appSource = readFileSync(new URL("../App.tsx", import.meta.url), "utf8");
|
||||
ok(
|
||||
/const enqueueNavigation = useCallback\(\(input: DesktopNavigationInput\)[\s\S]{0,700}?noteNavigationIntent\(\);[\s\S]{0,700}?enqueueNavigationRequest\(/.test(appSource),
|
||||
"App.enqueueNavigation calls noteNavigationIntent() before enqueueNavigationRequest",
|
||||
/const enqueueNavigation = useCallback\(\(input: DesktopNavigationIntent\)[\s\S]{0,900}?const navigationIntentSeq = noteNavigationIntent\(\);[\s\S]{0,900}?enqueueNavigationRequest\(/.test(appSource),
|
||||
"App.enqueueNavigation captures a shared navigation intent before enqueueNavigationRequest",
|
||||
);
|
||||
ok(
|
||||
/const enqueueTabSwitch = useCallback\([\s\S]{0,1400}?const navigationIntentSeq = noteNavigationIntent\(\);[\s\S]{0,1400}?switchTab\(request\.tabId, request\.optimisticTab, request\.navigationIntentSeq\)/.test(appSource),
|
||||
"App.enqueueTabSwitch invalidates older navigation at enqueue time and forwards the shared intent",
|
||||
);
|
||||
ok(
|
||||
/const latest = \(\) => request\.seq === navigationSeqRef\.current && isNavigationIntentCurrent\(request\.navigationIntentSeq\)/.test(appSource),
|
||||
"App navigation results require both queue ownership and the shared navigation intent",
|
||||
);
|
||||
|
||||
console.log(`\n${passed} passed, ${failed} failed`);
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { createBoundedRefreshCoordinator, sameTabMetaLists, shouldRefreshTabMetaForEvent, tabMetaFallbackDelay } from "../lib/tabMetaRefresh";
|
||||
import type { TabMeta } from "../lib/types";
|
||||
|
||||
const testDir = dirname(fileURLToPath(import.meta.url));
|
||||
const appSource = readFileSync(resolve(testDir, "../App.tsx"), "utf8");
|
||||
@@ -30,6 +32,16 @@ function ok(value: unknown, label: string) {
|
||||
}
|
||||
}
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (reason?: unknown) => void;
|
||||
const promise = new Promise<T>((res, rej) => {
|
||||
resolve = res;
|
||||
reject = rej;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
function matchingBlocks(selector: string): string[] {
|
||||
const blocks: string[] = [];
|
||||
const rule = /([^{}]+)\{([^{}]*)\}/g;
|
||||
@@ -55,6 +67,179 @@ function finalDeclaration(selector: string, property: string): string | undefine
|
||||
|
||||
console.log("\napp chrome tabs");
|
||||
|
||||
const tabMeta = (overrides: Partial<TabMeta> = {}): TabMeta => ({
|
||||
id: "tab-1",
|
||||
scope: "project",
|
||||
workspaceRoot: "/repo",
|
||||
workspaceName: "repo",
|
||||
topicId: "topic-1",
|
||||
topicTitle: "Topic",
|
||||
label: "model",
|
||||
ready: true,
|
||||
running: false,
|
||||
cancellable: false,
|
||||
mode: "normal",
|
||||
active: true,
|
||||
cwd: "/repo",
|
||||
...overrides,
|
||||
});
|
||||
|
||||
ok(sameTabMetaLists([tabMeta()], [tabMeta()]), "identical tab metadata suppresses redundant state writes");
|
||||
ok(!sameTabMetaLists([tabMeta()], [tabMeta({ running: true })]), "runtime tab changes still invalidate metadata state");
|
||||
ok(tabMetaFallbackDelay("visible") === 15_000, "visible tab metadata fallback runs at low frequency");
|
||||
ok(tabMetaFallbackDelay("hidden") === 60_000, "hidden tab metadata fallback backs off further");
|
||||
ok(shouldRefreshTabMetaForEvent("turn_started"), "turn start refreshes tab runtime metadata immediately");
|
||||
ok(shouldRefreshTabMetaForEvent("approval_request"), "approval prompts refresh tab runtime metadata immediately");
|
||||
ok(!shouldRefreshTabMetaForEvent("text_delta"), "stream deltas do not trigger tab-list requests");
|
||||
|
||||
{
|
||||
const coordinator = createBoundedRefreshCoordinator<TabMeta[]>(2);
|
||||
const first = deferred<TabMeta[]>();
|
||||
const second = deferred<TabMeta[]>();
|
||||
let loads = 0;
|
||||
const firstRefresh = coordinator.run(() => {
|
||||
loads += 1;
|
||||
return first.promise;
|
||||
});
|
||||
const secondRefresh = coordinator.run(() => {
|
||||
loads += 1;
|
||||
return second.promise;
|
||||
});
|
||||
const saturatedRefresh = coordinator.run(() => {
|
||||
loads += 1;
|
||||
return Promise.resolve([]);
|
||||
});
|
||||
await Promise.resolve();
|
||||
ok(loads === 2, "tab metadata refresh caps outstanding backend calls");
|
||||
|
||||
const latestTabs = [tabMeta({ id: "tab-latest" })];
|
||||
second.resolve(latestTabs);
|
||||
const saturatedResult = await saturatedRefresh;
|
||||
ok(saturatedResult.coalesced, "saturated tab metadata refresh joins the newest request");
|
||||
ok(saturatedResult.value === latestTabs, "saturated tab metadata refresh returns authoritative tabs instead of an empty sentinel");
|
||||
ok(saturatedResult.latest, "coalesced newest tab metadata remains eligible to update state");
|
||||
|
||||
first.resolve([tabMeta({ id: "tab-stale" })]);
|
||||
const firstResult = await firstRefresh;
|
||||
await secondRefresh;
|
||||
ok(!firstResult.latest, "an older tab metadata response cannot replace a newer snapshot");
|
||||
}
|
||||
|
||||
{
|
||||
const coordinator = createBoundedRefreshCoordinator<TabMeta[]>(2);
|
||||
const preMutationA = deferred<TabMeta[]>();
|
||||
const preMutationB = deferred<TabMeta[]>();
|
||||
const postMutation = deferred<TabMeta[]>();
|
||||
let loads = 0;
|
||||
const loadValues: TabMeta[][] = [
|
||||
[tabMeta({ id: "pre-a" })],
|
||||
[tabMeta({ id: "pre-b" })],
|
||||
[tabMeta({ id: "post-mutation" })],
|
||||
];
|
||||
const loadPromises = [preMutationA.promise, preMutationB.promise, postMutation.promise];
|
||||
|
||||
const firstRefresh = coordinator.run(() => {
|
||||
const index = loads;
|
||||
loads += 1;
|
||||
return loadPromises[index] ?? Promise.resolve(loadValues[index] ?? []);
|
||||
});
|
||||
const secondRefresh = coordinator.run(() => {
|
||||
const index = loads;
|
||||
loads += 1;
|
||||
return loadPromises[index] ?? Promise.resolve(loadValues[index] ?? []);
|
||||
});
|
||||
await Promise.resolve();
|
||||
ok(loads === 2, "pre-mutation tab metadata fills both in-flight slots");
|
||||
|
||||
const mutationRefresh = coordinator.run(
|
||||
() => {
|
||||
const index = loads;
|
||||
loads += 1;
|
||||
return loadPromises[index] ?? Promise.resolve(loadValues[index] ?? []);
|
||||
},
|
||||
{ invalidate: true },
|
||||
);
|
||||
await Promise.resolve();
|
||||
ok(loads === 2, "post-mutation refresh does not start until an in-flight slot frees");
|
||||
|
||||
preMutationA.resolve(loadValues[0]);
|
||||
const firstResult = await firstRefresh;
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
ok(loads === 3, "post-mutation refresh starts as a trailing load after a slot frees");
|
||||
ok(!firstResult.latest, "pre-mutation snapshot is not authoritative after invalidate");
|
||||
|
||||
const postTabs = loadValues[2];
|
||||
postMutation.resolve(postTabs);
|
||||
const mutationResult = await mutationRefresh;
|
||||
ok(!mutationResult.coalesced, "post-mutation refresh does not join a pre-mutation request");
|
||||
ok(mutationResult.value === postTabs, "post-mutation refresh returns the mutation-after snapshot");
|
||||
ok(mutationResult.latest, "post-mutation trailing refresh remains eligible to update state");
|
||||
|
||||
preMutationB.resolve(loadValues[1]);
|
||||
const secondResult = await secondRefresh;
|
||||
ok(!secondResult.latest, "pre-mutation coalesced request cannot overwrite post-mutation state");
|
||||
}
|
||||
|
||||
{
|
||||
const coordinator = createBoundedRefreshCoordinator<TabMeta[]>(1);
|
||||
const preMutation = deferred<TabMeta[]>();
|
||||
const latestPostMutation = deferred<TabMeta[]>();
|
||||
const started: string[] = [];
|
||||
|
||||
const preMutationRefresh = coordinator.run(() => {
|
||||
started.push("pre");
|
||||
return preMutation.promise;
|
||||
});
|
||||
await Promise.resolve();
|
||||
|
||||
const firstMutationRefresh = coordinator.run(
|
||||
() => {
|
||||
started.push("first-mutation");
|
||||
return Promise.resolve([tabMeta({ id: "first-mutation" })]);
|
||||
},
|
||||
{ invalidate: true },
|
||||
);
|
||||
const latestMutationRefresh = coordinator.run(
|
||||
() => {
|
||||
started.push("latest-mutation");
|
||||
return latestPostMutation.promise;
|
||||
},
|
||||
{ invalidate: true },
|
||||
);
|
||||
|
||||
preMutation.resolve([tabMeta({ id: "pre" })]);
|
||||
const preMutationResult = await preMutationRefresh;
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
ok(started.join(",") === "pre,latest-mutation", "queued invalidations retain only the latest trailing load");
|
||||
ok(!preMutationResult.latest, "queued invalidations fence the pre-mutation load");
|
||||
|
||||
const latestTabs = [tabMeta({ id: "latest-mutation" })];
|
||||
latestPostMutation.resolve(latestTabs);
|
||||
const [firstMutationResult, latestMutationResult] = await Promise.all([firstMutationRefresh, latestMutationRefresh]);
|
||||
ok(firstMutationResult.value === latestTabs, "an older queued mutation waits for the latest post-mutation snapshot");
|
||||
ok(latestMutationResult.value === latestTabs, "the latest queued mutation receives its post-mutation snapshot");
|
||||
ok(firstMutationResult.latest && latestMutationResult.latest, "the shared trailing snapshot remains authoritative");
|
||||
}
|
||||
|
||||
ok(
|
||||
!appSource.includes("setInterval(() => void refreshTabMetas(), 2000)") &&
|
||||
appSource.includes('document.addEventListener("visibilitychange", onVisibilityChange)') &&
|
||||
appSource.includes("createBoundedRefreshCoordinator<TabMeta[]>(TAB_META_MAX_IN_FLIGHT)") &&
|
||||
appSource.includes("void refreshTabMetas();\n schedule();"),
|
||||
"tab metadata refresh is event-driven with a visibility-aware fallback",
|
||||
);
|
||||
|
||||
ok(
|
||||
appSource.includes("refreshTabMetas(undefined, { afterMutation: true })") &&
|
||||
appSource.includes("{ afterMutation: true }") &&
|
||||
appSource.includes("if (shouldRefreshTabMetaForEvent(e.kind)) {") &&
|
||||
appSource.includes("void refreshTabMetas(undefined, { afterMutation: true });") &&
|
||||
/await refreshTabMetas\(\s*\(\) => isNavigationIntentCurrent\(request\.navigationIntentSeq\),\s*\{\s*afterMutation:\s*true\s*\},?\s*\)/.test(appSource),
|
||||
"tab lifecycle events and explicit mutations force a post-mutation trailing metadata refresh",
|
||||
);
|
||||
|
||||
ok(
|
||||
/import \{ TabBar \} from "\.\/TabBar";/.test(appChromeSource),
|
||||
"AppChrome keeps the classic top session tab strip implementation",
|
||||
@@ -245,7 +430,7 @@ ok(
|
||||
);
|
||||
|
||||
ok(
|
||||
/const controllerReady = state\.meta\?\.ready === true && !state\.backendActivationPending && !runtimeTransitioning;/.test(appSource) &&
|
||||
/const controllerReady =\s*state\.meta\?\.ready === true &&\s*\(!state\.meta\.runtime \|\| state\.meta\.runtime\.phase === "ready"\) &&\s*!state\.meta\.startupErr &&\s*!state\.backendActivationPending &&\s*!runtimeTransitioning;/.test(appSource) &&
|
||||
/if \(!activeTabId \|\| !controllerReady\) return;\s*void commitThenSend\(activeTabId, text\)\.catch/.test(appSource) &&
|
||||
/onPrompt=\{handleTranscriptPrompt\}/.test(appSource) &&
|
||||
/submitDisabled=\{!controllerReady\}/.test(appSource),
|
||||
@@ -261,7 +446,7 @@ ok(
|
||||
|
||||
ok(
|
||||
/commitThenSendRef\.current\(sourceTabId, trimmed, submitText\.trim\(\), structured\)/.test(appSource) &&
|
||||
/sendToTab\(sourceTabId, displayText, submitText, undefined, structured\)/.test(appSource) &&
|
||||
/sendToTab\(sourceTabId, displayText, submitText, undefined, structured, initialGoal\)/.test(appSource) &&
|
||||
/onSteer=\{handleSteer\}/.test(appSource) &&
|
||||
/composerInsertRequestsByTab\[activeTabId\]/.test(appSource) &&
|
||||
/consumedInsertIdByDraftRef\.current\[draftKey\]/.test(composerSource),
|
||||
@@ -318,11 +503,11 @@ ok(
|
||||
/const navigationRunningRef = useRef\(false\);/.test(appSource) &&
|
||||
/const navigationPendingRef = useRef<PendingDesktopNavigationRequest \| null>\(null\);/.test(appSource) &&
|
||||
/const runNavigationRequest = useCallback\(async \(request: PendingDesktopNavigationRequest\)/.test(appSource) &&
|
||||
/const latest = \(\) => request\.seq === navigationSeqRef\.current;/.test(appSource) &&
|
||||
/return activateTopic\(scope, workspaceRoot, topicId/.test(appSource) &&
|
||||
/return openTopicSession\(scope, workspaceRoot, topicId/.test(appSource) &&
|
||||
/return openGlobalTab\(topicId\)/.test(appSource) &&
|
||||
/return openProjectTab\(workspaceRoot, topicId\)/.test(appSource) &&
|
||||
/const latest = \(\) => request\.seq === navigationSeqRef\.current && isNavigationIntentCurrent\(request\.navigationIntentSeq\);/.test(appSource) &&
|
||||
/return activateTopic\(scope, workspaceRoot, topicId, sessionPath \|\| "", request\.navigationIntentSeq\)/.test(appSource) &&
|
||||
/return openTopicSession\(scope, workspaceRoot, topicId, sessionPath, request\.navigationIntentSeq\)/.test(appSource) &&
|
||||
/return openGlobalTab\(topicId, request\.navigationIntentSeq\)/.test(appSource) &&
|
||||
/return openProjectTab\(workspaceRoot, topicId, request\.navigationIntentSeq\)/.test(appSource) &&
|
||||
/enqueueNavigationRequest\([\s\S]*runningRef: navigationRunningRef, pendingRef: navigationPendingRef/.test(appSource) &&
|
||||
!/openTopicQueueRef\.current\.catch\(\(\) => \{\}\)\.then/.test(appSource) &&
|
||||
/const refreshLatestTabMetas = async \(\): Promise<TabMeta\[]> => \{[\s\S]*if \(latest\(\)\) setTabMetas\(tabs\);/.test(navigationBlock) &&
|
||||
|
||||
@@ -7,7 +7,7 @@ import { createRoot } from "react-dom/client";
|
||||
import gsap from "gsap";
|
||||
import { ApprovalModal } from "../components/ApprovalModal";
|
||||
import { activeFileReferenceToken, pickInlineFileReference } from "../components/FileReferenceMenu";
|
||||
import { LocaleProvider } from "../lib/i18n";
|
||||
import { LocaleProvider, preloadDetectedLocale } from "../lib/i18n";
|
||||
import type { AppBindings } from "../lib/bridge";
|
||||
import type { WireApproval } from "../lib/types";
|
||||
|
||||
@@ -95,6 +95,7 @@ function mockApp(methods: Partial<AppBindings>) {
|
||||
}
|
||||
|
||||
async function renderApproval(props: Partial<Parameters<typeof ApprovalModal>[0]> = {}) {
|
||||
await preloadDetectedLocale();
|
||||
const rootEl = document.getElementById("root");
|
||||
if (!rootEl) throw new Error("missing root");
|
||||
const root = createRoot(rootEl);
|
||||
@@ -328,6 +329,44 @@ console.log("\napproval modal file references");
|
||||
dom.window.close();
|
||||
}
|
||||
|
||||
{
|
||||
const dom = installDom();
|
||||
mockApp({
|
||||
ListDir: async () => [],
|
||||
SearchFileRefs: async () => [],
|
||||
});
|
||||
const { root, rerender } = await renderApproval();
|
||||
|
||||
await clickImmediateAction("Revise plan");
|
||||
|
||||
const textarea = document.querySelector(".plan-revision__input") as HTMLTextAreaElement | null;
|
||||
if (!textarea) throw new Error("plan revision textarea did not render");
|
||||
ok(textarea === document.activeElement, "opening plan revision focuses its textarea once");
|
||||
|
||||
const transcriptText = document.createElement("p");
|
||||
transcriptText.tabIndex = -1;
|
||||
transcriptText.textContent = "copy this plan text";
|
||||
document.body.appendChild(transcriptText);
|
||||
transcriptText.focus();
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(transcriptText);
|
||||
const selection = document.getSelection();
|
||||
selection?.removeAllRanges();
|
||||
selection?.addRange(range);
|
||||
|
||||
// App refreshes tab metadata periodically; emulate callback churn from a parent rerender.
|
||||
await rerender({ onRevisionActiveChange: () => undefined });
|
||||
|
||||
ok(document.activeElement === transcriptText, "parent rerender does not return focus to plan revision");
|
||||
eq(document.getSelection()?.toString(), "copy this plan text", "parent rerender preserves transcript text selection");
|
||||
|
||||
transcriptText.remove();
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
dom.window.close();
|
||||
}
|
||||
|
||||
{
|
||||
const dom = installDom();
|
||||
mockApp({
|
||||
|
||||
@@ -65,7 +65,7 @@ ok(
|
||||
const errorStart = snapshotBreadcrumbs().length;
|
||||
now = 100;
|
||||
try {
|
||||
await app.CheckUpdate();
|
||||
await app.CheckUpdate("");
|
||||
} catch {
|
||||
// Expected: this branch verifies the breadcrumb emitted by the proxy rejection path.
|
||||
}
|
||||
|
||||
@@ -22,6 +22,8 @@ const appSource = readFileSync(resolve(here, "../App.tsx"), "utf8");
|
||||
const projectTreeSource = readFileSync(resolve(here, "../components/ProjectTree.tsx"), "utf8");
|
||||
const settingsSource = readFileSync(resolve(here, "../components/SettingsPanel.tsx"), "utf8");
|
||||
const markdownSource = readFileSync(resolve(here, "../components/Markdown.tsx"), "utf8");
|
||||
const i18nSource = readFileSync(resolve(here, "../lib/i18n.tsx"), "utf8");
|
||||
const mainSource = readFileSync(resolve(here, "../main.tsx"), "utf8");
|
||||
const stylesSource = readFileSync(resolve(here, "../styles.css"), "utf8");
|
||||
|
||||
console.log("\nbundle contract");
|
||||
@@ -97,6 +99,20 @@ ok(
|
||||
markdownSource.includes('import("./MarkdownRenderer")'),
|
||||
"Markdown wrapper loads markdown renderer on demand",
|
||||
);
|
||||
ok(
|
||||
!/import\s+\{\s*zh\s*\}\s+from\s+["']\.\.\/locales\/zh["']/.test(i18nSource) &&
|
||||
!/import\s+\{\s*zhTW\s*\}\s+from\s+["']\.\.\/locales\/zh-TW["']/.test(i18nSource),
|
||||
"i18n keeps Chinese dictionaries out of the unconditional initial chunk",
|
||||
);
|
||||
ok(
|
||||
i18nSource.includes('import("../locales/zh")') &&
|
||||
i18nSource.includes('import("../locales/zh-TW")'),
|
||||
"i18n loads Chinese dictionaries on demand",
|
||||
);
|
||||
ok(
|
||||
mainSource.includes("await preloadDetectedLocale()"),
|
||||
"main preloads the detected locale before mounting React",
|
||||
);
|
||||
|
||||
console.log(`\n${passed} passed, ${failed} failed`);
|
||||
if (failed > 0) process.exit(1);
|
||||
@@ -482,24 +482,35 @@ console.log("capabilities panel MCP actions");
|
||||
active: true,
|
||||
cwd: "/tmp/reasonix-test",
|
||||
}];
|
||||
let projectAuthorizationCount = 0;
|
||||
let servers: ServerView[] = [{
|
||||
name: "github",
|
||||
transport: "stdio",
|
||||
status: "failed",
|
||||
runtimeState: "issue",
|
||||
status: "connected",
|
||||
runtimeState: "ready",
|
||||
configured: true,
|
||||
source: "project",
|
||||
configSource: "reasonix.toml",
|
||||
autoStart: true,
|
||||
tools: 0,
|
||||
tools: 3,
|
||||
prompts: 0,
|
||||
resources: 0,
|
||||
error: "project-provided MCP server is blocked until the user authorizes it",
|
||||
requiresLaunchApproval: true,
|
||||
toolList: [
|
||||
{ name: "issue_read", description: "Read issues.", readOnlyHint: true },
|
||||
{ name: "issue_write", description: "Write issues." },
|
||||
{ name: "wipe", description: "Delete data.", destructiveHint: true },
|
||||
],
|
||||
}, {
|
||||
name: "linear",
|
||||
transport: "http",
|
||||
status: "connected",
|
||||
runtimeState: "ready",
|
||||
configured: true,
|
||||
source: "user",
|
||||
autoStart: true,
|
||||
tools: 1,
|
||||
prompts: 0,
|
||||
resources: 0,
|
||||
toolList: [{ name: "get_issue", description: "Read an issue.", readOnlyHint: true }],
|
||||
}];
|
||||
window.go = {
|
||||
main: {
|
||||
@@ -507,16 +518,6 @@ console.log("capabilities panel MCP actions");
|
||||
Meta: async () => meta,
|
||||
ListTabs: async () => tabs,
|
||||
MCPServers: async () => servers,
|
||||
AuthorizeAndConnectMCPServer: async () => {
|
||||
projectAuthorizationCount += 1;
|
||||
servers = servers.map((item) => ({
|
||||
...item,
|
||||
status: "connected",
|
||||
runtimeState: "ready",
|
||||
requiresLaunchApproval: false,
|
||||
error: "",
|
||||
}));
|
||||
},
|
||||
} as Partial<AppBindings> as AppBindings,
|
||||
},
|
||||
};
|
||||
@@ -533,16 +534,17 @@ console.log("capabilities panel MCP actions");
|
||||
await flush();
|
||||
});
|
||||
};
|
||||
await waitFor("project MCP authorization action", () => Boolean(findButton("Authorize and connect")));
|
||||
ok(!findButton("Review changes"), "project MCP launch approval has no separate change-review workflow");
|
||||
await waitFor("trusted project MCP", () => Boolean(document.querySelector('[data-status="connected"]')));
|
||||
ok(document.body.textContent?.includes("This project"), "project MCP is grouped under This project");
|
||||
ok(document.body.textContent?.includes("Global MCP"), "user-installed MCP is grouped by its global scope");
|
||||
ok(document.body.textContent?.includes("Install once and use automatically in every Reasonix project."), "global MCP explains its cross-project availability");
|
||||
ok(document.body.textContent?.includes("Project"), "project MCP row shows a project source badge");
|
||||
ok(document.body.textContent?.includes("Declared by this project and available automatically."), "project MCP explains zero-confirmation availability");
|
||||
ok(!findButton("Install and use"), "trusted project MCP has no install confirmation");
|
||||
ok(!findButton("Authorize and connect"), "trusted project MCP has no authorization action");
|
||||
ok(!findButton("Review changes"), "project MCP has no separate change-review workflow");
|
||||
ok(!findButton("Refresh catalog"), "catalog maintenance is not part of the normal MCP workflow");
|
||||
ok(!document.querySelector('[role="dialog"]'), "project authorization does not open a second modal");
|
||||
await act(async () => {
|
||||
findButton("Authorize and connect")?.click();
|
||||
await flush();
|
||||
});
|
||||
await waitFor("durable project launch authorization", () => projectAuthorizationCount === 1 && Boolean(document.querySelector('[data-status="connected"]')));
|
||||
ok(!document.querySelector('[role="dialog"]'), "project confirmation connects directly without a second modal");
|
||||
ok(!document.querySelector('[role="dialog"]'), "project MCP does not open a confirmation modal");
|
||||
|
||||
servers = servers.map((item) => ({
|
||||
...item,
|
||||
@@ -576,13 +578,14 @@ console.log("capabilities panel MCP actions");
|
||||
requiresLaunchApproval: false,
|
||||
}));
|
||||
await refreshStatus();
|
||||
await waitFor("authorized project server row", () => Boolean(document.querySelector('[data-status="connected"]')));
|
||||
await waitFor("trusted project server row", () => Boolean(document.querySelector('[data-status="connected"]')));
|
||||
await act(async () => {
|
||||
(document.querySelector(".cap-mcp-list-row__main") as HTMLButtonElement | null)?.click();
|
||||
await flush();
|
||||
});
|
||||
await waitFor("connected project server detail", () => Boolean(document.querySelector(".cap-mcp-subpage")));
|
||||
ok(!findButton("Review changes"), "an authorized connected project server does not show a change alarm");
|
||||
ok(document.body.textContent?.includes("Current project · reasonix.toml"), "project MCP details show their configuration source");
|
||||
ok(!findButton("Review changes"), "a trusted connected project server does not show a change alarm");
|
||||
ok(!findButton("Revoke trust"), "normal MCP details do not expose a second authorization-management workflow");
|
||||
|
||||
await act(async () => {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// Run: tsx src/__tests__/composer-context-menu-clipboard.test.tsx
|
||||
//
|
||||
// Regression coverage for the composer edit context menu's clipboard guards:
|
||||
// - Undo/redo must expose the same fixed shortcuts as the composer keyboard path.
|
||||
// - Cut must not delete the selection when every clipboard path failed.
|
||||
// - Paste must not replace the selection when the clipboard has no text.
|
||||
// - Shortcut hints must use the platform modifier (Ctrl outside macOS).
|
||||
@@ -193,7 +194,7 @@ async function openInputMenu(): Promise<HTMLButtonElement[]> {
|
||||
await flushTimers();
|
||||
});
|
||||
const items = Array.from(document.querySelectorAll(".context-menu__item")) as HTMLButtonElement[];
|
||||
if (items.length !== 4) throw new Error(`expected 4 edit menu items, got ${items.length}`);
|
||||
if (items.length !== 6) throw new Error(`expected 6 edit menu items, got ${items.length}`);
|
||||
return items;
|
||||
}
|
||||
|
||||
@@ -235,8 +236,10 @@ async function main() {
|
||||
await typeAndSelect("hello world", 0, 5);
|
||||
const items = await openInputMenu();
|
||||
const hints = items.map((item) => item.querySelector(".context-menu__shortcut")?.textContent ?? "");
|
||||
eq(hints[0], "Ctrl+X", "cut hint uses the platform modifier");
|
||||
eq(hints[2], "Ctrl+V", "paste hint uses the platform modifier");
|
||||
eq(hints[0], "Ctrl+Z", "undo hint uses the platform modifier");
|
||||
eq(hints[1], "Ctrl+Shift+Z", "redo hint uses the platform modifier");
|
||||
eq(hints[2], "Ctrl+X", "cut hint uses the platform modifier");
|
||||
eq(hints[4], "Ctrl+V", "paste hint uses the platform modifier");
|
||||
ok(hints.every((hint) => !hint.includes("⌘")), "no hardcoded mac glyph on a non-mac platform");
|
||||
// Close the menu without acting.
|
||||
await act(async () => {
|
||||
@@ -245,13 +248,29 @@ async function main() {
|
||||
});
|
||||
}
|
||||
|
||||
// --- context-menu undo and redo share the programmatic edit history ---
|
||||
{
|
||||
await typeAndSelect("menu undo", 9, 9);
|
||||
const undoItems = await openInputMenu();
|
||||
ok(!undoItems[0].disabled, "undo is enabled at a programmatic edit boundary");
|
||||
ok(undoItems[1].disabled, "redo is disabled before an undo");
|
||||
await clickMenuItem(undoItems[0]);
|
||||
eq(textarea().value, "hello world", "context-menu undo restores the previous composer text");
|
||||
|
||||
const redoItems = await openInputMenu();
|
||||
ok(redoItems[0].disabled === false, "older programmatic history remains undoable");
|
||||
ok(!redoItems[1].disabled, "redo is enabled after context-menu undo");
|
||||
await clickMenuItem(redoItems[1]);
|
||||
eq(textarea().value, "menu undo", "context-menu redo restores the undone composer edit");
|
||||
}
|
||||
|
||||
// --- cut keeps the draft when every clipboard path fails ---
|
||||
{
|
||||
stubClipboard({});
|
||||
(document as Document & { execCommand?: () => boolean }).execCommand = () => false;
|
||||
await typeAndSelect("hello world", 0, 5);
|
||||
const items = await openInputMenu();
|
||||
await clickMenuItem(items[0]);
|
||||
await clickMenuItem(items[2]);
|
||||
eq(textarea().value, "hello world", "failed cut must not delete the selection");
|
||||
}
|
||||
|
||||
@@ -260,7 +279,7 @@ async function main() {
|
||||
stubClipboard({ writeText: () => Promise.resolve() });
|
||||
await typeAndSelect("hello world", 0, 6);
|
||||
const items = await openInputMenu();
|
||||
await clickMenuItem(items[0]);
|
||||
await clickMenuItem(items[2]);
|
||||
eq(textarea().value, "world", "successful cut removes the selected text");
|
||||
}
|
||||
|
||||
@@ -269,7 +288,7 @@ async function main() {
|
||||
stubClipboard({ readText: () => Promise.resolve("") });
|
||||
await typeAndSelect("hello world", 0, 5);
|
||||
const items = await openInputMenu();
|
||||
await clickMenuItem(items[2]);
|
||||
await clickMenuItem(items[4]);
|
||||
eq(textarea().value, "hello world", "empty-clipboard paste must not erase the selection");
|
||||
}
|
||||
|
||||
@@ -278,7 +297,7 @@ async function main() {
|
||||
stubClipboard({ readText: () => Promise.resolve("bye") });
|
||||
await typeAndSelect("hello world", 0, 5);
|
||||
const items = await openInputMenu();
|
||||
await clickMenuItem(items[2]);
|
||||
await clickMenuItem(items[4]);
|
||||
eq(textarea().value, "bye world", "text paste replaces the selection");
|
||||
}
|
||||
|
||||
|
||||
@@ -6,10 +6,11 @@ import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { Composer, composerPickFileEntry } from "../components/Composer";
|
||||
import { InvocationMetadataContext, UserMessage } from "../components/Message";
|
||||
import { selectionFromDom } from "../components/RichComposerInput";
|
||||
import { LocaleProvider } from "../lib/i18n";
|
||||
import { ToastProvider } from "../lib/toast";
|
||||
import type { AppBindings } from "../lib/bridge";
|
||||
import type { StructuredInvocationSubmit } from "../lib/invocationDisplay";
|
||||
import type { ComposerInvocation, StructuredInvocationSubmit } from "../lib/invocationDisplay";
|
||||
import type { CollaborationMode, CommandInfo, DirEntry, ToolApprovalMode, TokenMode } from "../lib/types";
|
||||
|
||||
let passed = 0;
|
||||
@@ -334,6 +335,142 @@ console.log("\ncomposer goal toggle");
|
||||
dom.window.close();
|
||||
}
|
||||
|
||||
{
|
||||
const dom = installDom();
|
||||
mockApp({
|
||||
Commands: async () => [
|
||||
{ name: "ui-ux-pro-max", description: "Review the interface", kind: "skill" },
|
||||
],
|
||||
ListDirForTab: async () => [],
|
||||
SearchFileRefsForTab: async () => [],
|
||||
});
|
||||
const { root, calls, rerender } = await renderComposer({ collaborationMode: "goal", goal: "" });
|
||||
await replaceComposerDraft(rerender, 4199, "/ui-ux-pro-max");
|
||||
await waitFor("skill menu for the initial goal", () => Boolean(document.querySelector(".slashmenu")));
|
||||
let textarea = document.querySelector("textarea") as HTMLTextAreaElement | null;
|
||||
if (!textarea) throw new Error("composer textarea did not render for the initial goal skill");
|
||||
await act(async () => {
|
||||
textarea.dispatchEvent(new window.KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true }));
|
||||
await flushTimers();
|
||||
});
|
||||
|
||||
let sendButton = document.querySelector(".composer__btn--send") as HTMLButtonElement | null;
|
||||
if (!sendButton) throw new Error("composer send button did not render for the initial goal skill");
|
||||
await act(async () => {
|
||||
sendButton.click();
|
||||
await flushTimers();
|
||||
});
|
||||
eq(calls.send.length, 0, "a skill alone cannot become the initial goal");
|
||||
ok(document.body.textContent?.includes("Enter a goal") === true, "a skill-only initial goal asks for task text");
|
||||
|
||||
await replaceComposerDraft(rerender, 4200, "List the existing notes");
|
||||
sendButton = document.querySelector(".composer__btn--send") as HTMLButtonElement | null;
|
||||
if (!sendButton) throw new Error("composer send button disappeared after entering the goal");
|
||||
await act(async () => {
|
||||
sendButton.click();
|
||||
await flushTimers();
|
||||
});
|
||||
eq(calls.send[0], "List the existing notes", "the initial goal keeps its visible task text");
|
||||
eq(calls.submit[0], "/ui-ux-pro-max List the existing notes", "the initial goal preserves the selected skill");
|
||||
eq(calls.structured[0]?.input, "List the existing notes", "the initial goal sends structured skill input");
|
||||
eq(calls.structured[0]?.invocations[0]?.name, "ui-ux-pro-max", "the initial goal submits the selected skill entity");
|
||||
|
||||
await replaceComposerDraft(rerender, 4201, "/ui-ux-pro-max List the notes again");
|
||||
sendButton = document.querySelector(".composer__btn--send") as HTMLButtonElement | null;
|
||||
if (!sendButton) throw new Error("composer send button did not render for a pasted skill invocation");
|
||||
await act(async () => {
|
||||
sendButton.click();
|
||||
await flushTimers();
|
||||
});
|
||||
eq(calls.send[1], "List the notes again", "a pasted skill invocation keeps its task as the visible goal");
|
||||
eq(calls.submit[1], "/ui-ux-pro-max List the notes again", "a pasted skill invocation keeps its slash display");
|
||||
eq(calls.structured[1]?.input, "List the notes again", "a pasted skill invocation uses structured input");
|
||||
eq(calls.structured[1]?.invocations[0]?.name, "ui-ux-pro-max", "a pasted skill invocation resolves the selected command");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
dom.window.close();
|
||||
}
|
||||
|
||||
{
|
||||
// Attachment-only first Goal: no text, no skill — attachment refs are valid task context.
|
||||
const dom = installDom();
|
||||
mockApp({
|
||||
SavePastedFile: 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");
|
||||
await act(async () => {
|
||||
dispatchPasteFile(textarea, new File(["hello"], "notes.txt", { type: "text/plain" }));
|
||||
await flushTimers();
|
||||
});
|
||||
await waitFor("attachment-only initial goal card", () => document.body.textContent?.includes("notes.txt") === true);
|
||||
|
||||
const sendButton = document.querySelector(".composer__btn--send") as HTMLButtonElement | null;
|
||||
if (!sendButton) throw new Error("send button missing for attachment-only initial goal");
|
||||
await act(async () => {
|
||||
sendButton.click();
|
||||
await flushTimers();
|
||||
});
|
||||
eq(calls.send.length, 1, "attachment-only input can become the initial Goal");
|
||||
ok(
|
||||
calls.submit[0]?.includes("@.reasonix/attachments/notes.txt") === true,
|
||||
"attachment-only initial Goal submits the attachment ref",
|
||||
);
|
||||
eq(calls.structured[0], undefined, "attachment-only initial Goal is not a structured skill submit");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
dom.window.close();
|
||||
}
|
||||
|
||||
{
|
||||
// Workspace-ref-only first Goal: no text, no skill — workspace refs remain valid task context.
|
||||
const dom = installDom();
|
||||
let droppedCallback: ((x: number, y: number, paths: string[]) => void) | undefined;
|
||||
window.runtime = {
|
||||
EventsOn: () => () => {},
|
||||
BrowserOpenURL: () => {},
|
||||
OnFileDrop: (cb) => {
|
||||
droppedCallback = cb;
|
||||
},
|
||||
OnFileDropOff: () => {},
|
||||
};
|
||||
mockApp({
|
||||
AttachDropped: async () => ({
|
||||
kind: "workspace",
|
||||
path: "src/App.tsx",
|
||||
isDir: false,
|
||||
displayPath: "src/App.tsx",
|
||||
}),
|
||||
});
|
||||
const { root, calls } = await renderComposer({ collaborationMode: "goal", goal: "" });
|
||||
if (!droppedCallback) throw new Error("native file drop handler did not register for workspace-ref goal");
|
||||
await act(async () => {
|
||||
droppedCallback?.(0, 0, ["/repo/src/App.tsx"]);
|
||||
await flushTimers();
|
||||
});
|
||||
await waitFor("workspace-ref-only initial goal card", () => document.body.textContent?.includes("App.tsx") === true);
|
||||
|
||||
const sendButton = document.querySelector(".composer__btn--send") as HTMLButtonElement | null;
|
||||
if (!sendButton) throw new Error("send button missing for workspace-ref-only initial goal");
|
||||
await act(async () => {
|
||||
sendButton.click();
|
||||
await flushTimers();
|
||||
});
|
||||
eq(calls.send.length, 1, "workspace-ref-only input can become the initial Goal");
|
||||
eq(calls.submit[0], "@src/App.tsx", "workspace-ref-only initial Goal submits the workspace ref");
|
||||
eq(calls.structured[0], undefined, "workspace-ref-only initial Goal is not a structured skill submit");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
dom.window.close();
|
||||
}
|
||||
|
||||
{
|
||||
const dom = installDom();
|
||||
mockApp({
|
||||
@@ -437,6 +574,126 @@ console.log("\ncomposer goal toggle");
|
||||
dom.window.close();
|
||||
}
|
||||
|
||||
{
|
||||
const dom = installDom();
|
||||
const command: CommandInfo = {
|
||||
name: "writing-plans",
|
||||
description: "Write a plan",
|
||||
kind: "skill",
|
||||
};
|
||||
mockApp({
|
||||
Commands: async () => [command],
|
||||
ListDirForTab: async () => [],
|
||||
SearchFileRefsForTab: async () => [],
|
||||
});
|
||||
const { root, rerender } = await renderComposer();
|
||||
await replaceComposerDraft(rerender, 4201, "/writing-plans");
|
||||
await waitFor("skill menu for paste undo selection", () => Boolean(document.querySelector(".slashmenu")));
|
||||
const initialTextarea = document.querySelector("textarea") as HTMLTextAreaElement | null;
|
||||
if (!initialTextarea) throw new Error("composer textarea did not render for paste undo selection");
|
||||
await act(async () => {
|
||||
initialTextarea.dispatchEvent(new window.KeyboardEvent("keydown", {
|
||||
key: "Enter",
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
}));
|
||||
await flushTimers();
|
||||
});
|
||||
|
||||
let richInput = document.querySelector(".composer__rich-input") as HTMLDivElement | null;
|
||||
let token = richInput?.querySelector<HTMLElement>(".composer-invocation-token");
|
||||
const invocationId = token?.dataset.invocationId;
|
||||
if (!richInput || !token || !invocationId) throw new Error("rich invocation did not render for paste undo selection");
|
||||
const afterToken = document.createRange();
|
||||
afterToken.setStartAfter(token);
|
||||
afterToken.collapse(true);
|
||||
document.getSelection()?.removeAllRanges();
|
||||
document.getSelection()?.addRange(afterToken);
|
||||
await act(async () => {
|
||||
dispatchPasteText(richInput!, "pasted");
|
||||
await flushTimers();
|
||||
});
|
||||
richInput = document.querySelector(".composer__rich-input") as HTMLDivElement | null;
|
||||
if (!richInput) throw new Error("rich input disappeared after paste");
|
||||
eq(richComposerTaskText(richInput), "pasted", "paste after an invocation inserts on the token's right side");
|
||||
|
||||
await act(async () => {
|
||||
richInput!.dispatchEvent(new MouseEvent("contextmenu", { bubbles: true, cancelable: true }));
|
||||
await flushTimers();
|
||||
});
|
||||
let richMenuItems = Array.from(document.querySelectorAll<HTMLButtonElement>(".context-menu__item"));
|
||||
eq(richMenuItems.length, 6, "rich composer exposes the shared edit context menu");
|
||||
ok(richMenuItems[0]?.disabled === false, "rich composer context-menu undo is enabled after paste");
|
||||
ok(richMenuItems[1]?.disabled === true, "rich composer context-menu redo is disabled before undo");
|
||||
await act(async () => {
|
||||
richMenuItems[0]?.click();
|
||||
await flushTimers();
|
||||
});
|
||||
richInput = document.querySelector(".composer__rich-input") as HTMLDivElement | null;
|
||||
if (!richInput) throw new Error("rich input disappeared after context-menu undo");
|
||||
eq(richComposerTaskText(richInput), "", "rich composer context-menu undo removes the pasted text");
|
||||
|
||||
await act(async () => {
|
||||
richInput!.dispatchEvent(new MouseEvent("contextmenu", { bubbles: true, cancelable: true }));
|
||||
await flushTimers();
|
||||
});
|
||||
richMenuItems = Array.from(document.querySelectorAll<HTMLButtonElement>(".context-menu__item"));
|
||||
ok(richMenuItems[1]?.disabled === false, "rich composer context-menu redo is enabled after undo");
|
||||
await act(async () => {
|
||||
richMenuItems[1]?.click();
|
||||
await flushTimers();
|
||||
});
|
||||
richInput = document.querySelector(".composer__rich-input") as HTMLDivElement | null;
|
||||
if (!richInput) throw new Error("rich input disappeared after context-menu redo");
|
||||
eq(richComposerTaskText(richInput), "pasted", "rich composer context-menu redo restores the pasted text");
|
||||
|
||||
const undoPaste = new window.KeyboardEvent("keydown", {
|
||||
key: "z",
|
||||
ctrlKey: true,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
});
|
||||
await act(async () => {
|
||||
richInput!.dispatchEvent(undoPaste);
|
||||
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
|
||||
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
|
||||
await flushTimers();
|
||||
});
|
||||
eq(undoPaste.defaultPrevented, true, "Ctrl+Z restores the rich-composer paste transaction");
|
||||
|
||||
richInput = document.querySelector(".composer__rich-input") as HTMLDivElement | null;
|
||||
token = richInput?.querySelector<HTMLElement>(".composer-invocation-token");
|
||||
if (!richInput || !token) throw new Error("rich invocation disappeared after paste undo");
|
||||
const restoredInvocation: ComposerInvocation = { id: invocationId, offset: 0, command };
|
||||
const restoredSelection = selectionFromDom(
|
||||
richInput,
|
||||
new Map([[invocationId, restoredInvocation]]),
|
||||
);
|
||||
eq(
|
||||
restoredSelection.ok ? restoredSelection.selection.afterInvocationId : undefined,
|
||||
invocationId,
|
||||
"paste undo restores the caret after the invocation token",
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
richInput!.dispatchEvent(new window.KeyboardEvent("keydown", {
|
||||
key: "Backspace",
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
}));
|
||||
await flushTimers();
|
||||
});
|
||||
ok(
|
||||
document.querySelector(".composer-invocation-token") === null,
|
||||
"Backspace after paste undo removes the invocation on the caret's left",
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
dom.window.close();
|
||||
}
|
||||
|
||||
{
|
||||
const dom = installDom();
|
||||
const { root, calls } = await renderComposer({
|
||||
@@ -1252,6 +1509,119 @@ console.log("\ncomposer goal toggle");
|
||||
dom.window.close();
|
||||
}
|
||||
|
||||
{
|
||||
const dom = installDom();
|
||||
mockApp({
|
||||
Commands: async () => [
|
||||
{ name: "writing-plans", description: "Write a plan", kind: "skill", color: "amber" },
|
||||
{ name: "review", description: "Review the result", kind: "skill" },
|
||||
{ name: "mcp", description: "Manage MCP servers", kind: "builtin", group: "integrations" },
|
||||
],
|
||||
ListDirForTab: async () => [],
|
||||
SearchFileRefsForTab: async () => [],
|
||||
});
|
||||
const { root, calls, rerender } = await renderComposer();
|
||||
|
||||
const initialText = "请用/writing-plans检查";
|
||||
await replaceComposerDraft(rerender, 1900, initialText);
|
||||
let textarea = document.querySelector("textarea") as HTMLTextAreaElement | null;
|
||||
if (!textarea) throw new Error("composer textarea did not render for middle slash completion");
|
||||
const slashCaret = "请用/writ".length;
|
||||
await act(async () => {
|
||||
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
|
||||
await flushTimers();
|
||||
});
|
||||
await act(async () => {
|
||||
textarea!.focus();
|
||||
textarea!.setSelectionRange(slashCaret, slashCaret);
|
||||
textarea!.dispatchEvent(new window.Event("select", { bubbles: true }));
|
||||
textarea!.dispatchEvent(new window.KeyboardEvent("keyup", { key: "/", bubbles: true }));
|
||||
await flushTimers();
|
||||
});
|
||||
await waitFor("middle slash command menu", () => Boolean(document.querySelector(".slashmenu")));
|
||||
|
||||
await act(async () => {
|
||||
textarea!.dispatchEvent(new window.KeyboardEvent("keydown", {
|
||||
key: "Enter",
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
}));
|
||||
await flushTimers();
|
||||
});
|
||||
|
||||
let richInput = document.querySelector(".composer__rich-input") as HTMLDivElement | null;
|
||||
let tokens = richInput?.querySelectorAll<HTMLElement>(".composer-invocation-token");
|
||||
if (!richInput || !tokens?.[0]) throw new Error("middle skill invocation did not render");
|
||||
eq(richComposerTaskText(richInput), "请用检查", "first middle skill selection preserves surrounding text");
|
||||
eq(
|
||||
document.querySelector<HTMLElement>(".invocation-display--composer")?.style.getPropertyValue("--invocation-color"),
|
||||
"#d59a2f",
|
||||
"middle skill selection keeps its configured color",
|
||||
);
|
||||
|
||||
const afterFirstToken = document.createRange();
|
||||
afterFirstToken.setStartAfter(tokens[0]);
|
||||
afterFirstToken.collapse(true);
|
||||
document.getSelection()?.removeAllRanges();
|
||||
document.getSelection()?.addRange(afterFirstToken);
|
||||
await act(async () => {
|
||||
dispatchPasteText(richInput!, "更多");
|
||||
await flushTimers();
|
||||
});
|
||||
richInput = document.querySelector(".composer__rich-input") as HTMLDivElement | null;
|
||||
if (!richInput) throw new Error("rich input disappeared after middle-skill paste");
|
||||
eq(richComposerTaskText(richInput), "请用更多检查", "paste after a middle skill preserves the entity and suffix");
|
||||
eq(
|
||||
richInput.querySelectorAll(".composer-invocation-token").length,
|
||||
1,
|
||||
"paste after a middle skill keeps the selected entity",
|
||||
);
|
||||
|
||||
await appendRichComposerInput(richInput, " /review");
|
||||
richInput = document.querySelector(".composer__rich-input") as HTMLDivElement | null;
|
||||
if (!richInput) throw new Error("rich input disappeared before the second skill selection");
|
||||
const queryAtEnd = document.createRange();
|
||||
queryAtEnd.selectNodeContents(richInput);
|
||||
queryAtEnd.collapse(false);
|
||||
document.getSelection()?.removeAllRanges();
|
||||
document.getSelection()?.addRange(queryAtEnd);
|
||||
await act(async () => {
|
||||
richInput!.dispatchEvent(new window.KeyboardEvent("keyup", { key: "w", bubbles: true }));
|
||||
await flushTimers();
|
||||
});
|
||||
await waitFor("second skill menu at the end", () => Boolean(document.querySelector(".slashmenu")));
|
||||
await act(async () => {
|
||||
richInput!.dispatchEvent(new window.KeyboardEvent("keydown", {
|
||||
key: "Enter",
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
}));
|
||||
await flushTimers();
|
||||
});
|
||||
|
||||
richInput = document.querySelector(".composer__rich-input") as HTMLDivElement | null;
|
||||
tokens = richInput?.querySelectorAll<HTMLElement>(".composer-invocation-token");
|
||||
eq(tokens?.length, 2, "a second skill can be inserted after existing text and an entity");
|
||||
const sendButton = document.querySelector(".composer__btn--send") as HTMLButtonElement | null;
|
||||
if (!sendButton) throw new Error("send button did not render for middle skill submission");
|
||||
await act(async () => {
|
||||
sendButton.click();
|
||||
await flushTimers();
|
||||
});
|
||||
eq(calls.structured[0]?.input, "请用更多检查", "middle skills submit the surrounding task text without slash tokens");
|
||||
eq(
|
||||
calls.structured[0]?.invocations.map((item) => item.name).join(","),
|
||||
"writing-plans,review",
|
||||
"multiple middle/end skills submit in visual order",
|
||||
);
|
||||
eq(calls.structured[0]?.invocations[0]?.offset, 2, "first middle skill keeps its text offset");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
dom.window.close();
|
||||
}
|
||||
|
||||
{
|
||||
const dom = installDom();
|
||||
let commandsCalls = 0;
|
||||
@@ -1364,8 +1734,42 @@ console.log("\ncomposer goal toggle");
|
||||
document.activeElement === textareaAfterEntityRemoval,
|
||||
"removing the last entity hands focus to the textarea that replaces the rich input",
|
||||
);
|
||||
const undoEntityRemoval = new window.KeyboardEvent("keydown", {
|
||||
key: "z",
|
||||
ctrlKey: true,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
});
|
||||
await act(async () => {
|
||||
textareaAfterEntityRemoval.dispatchEvent(undoEntityRemoval);
|
||||
await flushTimers();
|
||||
});
|
||||
eq(undoEntityRemoval.defaultPrevented, true, "Ctrl+Z restores a token removed by the rich composer");
|
||||
ok(
|
||||
document.querySelector(".invocation-display--composer") !== null,
|
||||
"undoing the programmatic Backspace restores the selected skill",
|
||||
);
|
||||
const restoredRichInput = document.querySelector(".composer__rich-input") as HTMLDivElement | null;
|
||||
if (!restoredRichInput) throw new Error("rich composer did not return after undoing token removal");
|
||||
const redoEntityRemoval = new window.KeyboardEvent("keydown", {
|
||||
key: "Z",
|
||||
ctrlKey: true,
|
||||
shiftKey: true,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
});
|
||||
await act(async () => {
|
||||
restoredRichInput.dispatchEvent(redoEntityRemoval);
|
||||
await flushTimers();
|
||||
});
|
||||
eq(redoEntityRemoval.defaultPrevented, true, "Ctrl+Shift+Z redoes rich token removal");
|
||||
ok(
|
||||
document.querySelector(".invocation-display--composer") === null,
|
||||
"redoing the programmatic Backspace removes the selected skill again",
|
||||
);
|
||||
|
||||
await replaceComposerDraft(rerender, 2002, "/writing-plans");
|
||||
await waitFor("plain composer after replacing the restored skill", () => Boolean(document.querySelector("textarea")));
|
||||
await waitFor("skill menu after removal", () => Boolean(document.querySelector(".slashmenu")));
|
||||
textarea = document.querySelector("textarea") as HTMLTextAreaElement | null;
|
||||
if (!textarea) throw new Error("composer textarea did not return after removing the skill");
|
||||
@@ -1802,5 +2206,153 @@ console.log("\ncomposer goal toggle");
|
||||
dom.window.close();
|
||||
}
|
||||
|
||||
{
|
||||
const dom = installDom();
|
||||
mockApp({
|
||||
Commands: async () => [
|
||||
{ name: "review", description: "Review the current task", kind: "skill" },
|
||||
],
|
||||
ListDirForTab: async () => [],
|
||||
SearchFileRefsForTab: async () => [],
|
||||
});
|
||||
const sessionA = "session:project:/repo:topic-a:session-a";
|
||||
const sessionB = "session:project:/repo:topic-b:session-b";
|
||||
const { root, rerender } = await renderComposer({ sessionKey: sessionA });
|
||||
|
||||
await replaceComposerDraft(rerender, 5000, "x/review");
|
||||
await waitFor("session A slash menu", () => Boolean(document.querySelector(".slashmenu")));
|
||||
|
||||
await rerender({ sessionKey: sessionB, insertRequest: null });
|
||||
await replaceComposerDraft(rerender, 5001, "b");
|
||||
const sessionBInput = document.querySelector("textarea") as HTMLTextAreaElement | null;
|
||||
if (!sessionBInput) throw new Error("session B textarea did not render");
|
||||
await act(async () => {
|
||||
sessionBInput.focus();
|
||||
sessionBInput.setSelectionRange(1, 1);
|
||||
sessionBInput.dispatchEvent(new window.KeyboardEvent("keyup", { key: "b", bubbles: true }));
|
||||
await flushTimers();
|
||||
});
|
||||
|
||||
await rerender({ sessionKey: sessionA, insertRequest: null });
|
||||
eq(
|
||||
(document.querySelector("textarea") as HTMLTextAreaElement | null)?.value,
|
||||
"x/review",
|
||||
"switching back restores session A slash draft",
|
||||
);
|
||||
await waitFor(
|
||||
"restored session A slash menu",
|
||||
() => Boolean(document.querySelector(".slashmenu")),
|
||||
);
|
||||
ok(
|
||||
document.querySelector(".slashmenu") !== null,
|
||||
"restoring a draft recomputes slash completion from its end caret",
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
dom.window.close();
|
||||
}
|
||||
|
||||
{
|
||||
const dom = installDom();
|
||||
mockApp({
|
||||
Commands: async () => [
|
||||
{ name: "review", description: "Review the current task", kind: "skill" },
|
||||
],
|
||||
ListDirForTab: async () => [],
|
||||
SearchFileRefsForTab: async () => [],
|
||||
});
|
||||
const sessionA = "session:project:/repo:rich-topic-a:rich-session-a";
|
||||
const sessionB = "session:project:/repo:rich-topic-b:rich-session-b";
|
||||
const realRequestAnimationFrame = globalThis.requestAnimationFrame;
|
||||
const queuedComposerFrames: FrameRequestCallback[] = [];
|
||||
globalThis.requestAnimationFrame = (callback) => {
|
||||
queuedComposerFrames.push(callback);
|
||||
return queuedComposerFrames.length;
|
||||
};
|
||||
const { root, rerender } = await renderComposer({ sessionKey: sessionA });
|
||||
|
||||
await replaceComposerDraft(rerender, 6000, "/review");
|
||||
await waitFor("session A first skill menu", () => Boolean(document.querySelector(".slashmenu")));
|
||||
const textarea = document.querySelector("textarea") as HTMLTextAreaElement | null;
|
||||
if (!textarea) throw new Error("session A textarea did not render");
|
||||
await act(async () => {
|
||||
textarea.dispatchEvent(new window.KeyboardEvent("keydown", {
|
||||
key: "Enter",
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
}));
|
||||
await flushTimers();
|
||||
});
|
||||
|
||||
let richInput = document.querySelector(".composer__rich-input") as HTMLDivElement | null;
|
||||
if (!richInput) throw new Error("session A rich input did not render");
|
||||
await appendRichComposerInput(richInput, " /review");
|
||||
richInput = document.querySelector(".composer__rich-input") as HTMLDivElement | null;
|
||||
if (!richInput) throw new Error("session A rich input disappeared");
|
||||
const queryAtEnd = document.createRange();
|
||||
queryAtEnd.selectNodeContents(richInput);
|
||||
queryAtEnd.collapse(false);
|
||||
document.getSelection()?.removeAllRanges();
|
||||
document.getSelection()?.addRange(queryAtEnd);
|
||||
await act(async () => {
|
||||
richInput.dispatchEvent(new window.KeyboardEvent("keyup", { key: "w", bubbles: true }));
|
||||
await flushTimers();
|
||||
});
|
||||
await waitFor("session A second skill menu", () => Boolean(document.querySelector(".slashmenu")));
|
||||
|
||||
await rerender({ sessionKey: sessionB, insertRequest: null });
|
||||
await replaceComposerDraft(rerender, 6001, "b");
|
||||
await rerender({ sessionKey: sessionA, insertRequest: null });
|
||||
await act(async () => {
|
||||
let frameTime = 0;
|
||||
while (queuedComposerFrames.length > 0) {
|
||||
queuedComposerFrames.shift()?.(frameTime += 16);
|
||||
}
|
||||
await flushTimers();
|
||||
});
|
||||
richInput = document.querySelector(".composer__rich-input") as HTMLDivElement | null;
|
||||
if (!richInput) throw new Error("session A rich input was not restored");
|
||||
eq(richComposerTaskText(richInput), " /review", "switching back restores the rich invocation draft");
|
||||
await waitFor(
|
||||
"restored session A rich slash menu",
|
||||
() => Boolean(document.querySelector(".slashmenu")),
|
||||
);
|
||||
ok(
|
||||
document.querySelector(".slashmenu") !== null,
|
||||
"restoring a rich invocation draft recomputes slash completion from its end caret",
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
richInput.dispatchEvent(new window.KeyboardEvent("keydown", {
|
||||
key: "Enter",
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
}));
|
||||
await flushTimers();
|
||||
});
|
||||
richInput = document.querySelector(".composer__rich-input") as HTMLDivElement | null;
|
||||
eq(
|
||||
richInput?.querySelectorAll(".composer-invocation-token").length,
|
||||
2,
|
||||
"the restored rich slash query can select a second skill",
|
||||
);
|
||||
eq(richInput ? richComposerTaskText(richInput) : "", " ", "selecting the restored query replaces its slash token");
|
||||
|
||||
await rerender({ sessionKey: sessionB, insertRequest: null });
|
||||
eq(
|
||||
(document.querySelector("textarea") as HTMLTextAreaElement | null)?.value,
|
||||
"b",
|
||||
"switching away again preserves the other session draft",
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
globalThis.requestAnimationFrame = realRequestAnimationFrame;
|
||||
dom.window.close();
|
||||
}
|
||||
|
||||
console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`);
|
||||
if (failed > 0) process.exit(1);
|
||||
@@ -56,6 +56,9 @@ eq(finalDeclaration(".composer-card--resized .composer__input", "height"), "100%
|
||||
eq(finalDeclaration(".composer-card--resized .composer__input", "overflow-y"), "auto", "resized textarea scrolls internally");
|
||||
eq(finalDeclaration(".composer-card--resized .composer__rich-input", "height"), "100%", "resized rich input fills the card's input area");
|
||||
eq(finalDeclaration(".composer-card--resized .composer__rich-input", "overflow-y"), "auto", "resized rich input scrolls internally");
|
||||
eq(finalDeclaration(".composer-invocation-caret-anchor", "min-width"), "1px", "invocation caret anchor keeps a compact hit target");
|
||||
eq(finalDeclaration(".composer-invocation-caret-anchor", "width"), undefined, "invocation caret anchor can expand around WebView2-hosted text");
|
||||
eq(finalDeclaration(".composer-invocation-caret-anchor", "overflow"), undefined, "invocation caret anchor does not clip WebView2-hosted text");
|
||||
|
||||
console.log(`\n${passed} passed, ${failed} failed`);
|
||||
if (failed > 0) process.exit(1);
|
||||
@@ -8,6 +8,7 @@ import { Composer } from "../components/Composer";
|
||||
import { invalidateCache } from "../lib/composerHistory";
|
||||
import { composerDraftKeyForTab } from "../lib/composerDraftKey";
|
||||
import { LocaleProvider } from "../lib/i18n";
|
||||
import { resetCustomShortcuts, saveCustomShortcut } from "../lib/keyboardShortcuts";
|
||||
import {
|
||||
SELECTED_TEXT_MAX_CHARS,
|
||||
formatSelectedTextContext,
|
||||
@@ -172,6 +173,24 @@ function textarea(): HTMLTextAreaElement {
|
||||
return node;
|
||||
}
|
||||
|
||||
function updateTextareaFromUser(
|
||||
value: string,
|
||||
inputType: "insertText" | "deleteContentBackward" | "historyUndo" | "historyRedo",
|
||||
) {
|
||||
const input = textarea();
|
||||
input.focus();
|
||||
const setter = Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, "value")?.set;
|
||||
if (!setter) throw new Error("textarea value setter is unavailable");
|
||||
setter.call(input, value);
|
||||
input.dispatchEvent(new window.InputEvent("input", {
|
||||
bubbles: true,
|
||||
data: inputType === "insertText" ? value.slice(-1) : null,
|
||||
inputType,
|
||||
}));
|
||||
input.dispatchEvent(new window.Event("change", { bubbles: true }));
|
||||
input.dispatchEvent(new window.KeyboardEvent("keyup", { key: "x", bubbles: true }));
|
||||
}
|
||||
|
||||
function sendButton(): HTMLButtonElement {
|
||||
const node = document.querySelector(".composer__btn--send") as HTMLButtonElement | null;
|
||||
if (!node) throw new Error("composer send button did not render");
|
||||
@@ -422,24 +441,93 @@ console.log("\ncomposer session draft");
|
||||
{
|
||||
const dom = installDom();
|
||||
const sent: Array<{ display: string; submit?: string }> = [];
|
||||
const { root } = await renderComposer({
|
||||
const { root, rerender } = await renderComposer({
|
||||
onSend: (display, submit) => {
|
||||
sent.push({ display, submit });
|
||||
},
|
||||
});
|
||||
const typedPrefix = "typed before paste: ";
|
||||
await rerender({ insertRequest: { id: 100, text: typedPrefix, mode: "replace" } });
|
||||
const rawPaste = "error: failed to compile\r\nat loader.ts:10\r\nat run.ts:22";
|
||||
const normalizedPaste = "error: failed to compile\nat loader.ts:10\nat run.ts:22";
|
||||
const event = textPasteEvent(rawPaste);
|
||||
|
||||
await act(async () => {
|
||||
const input = textarea();
|
||||
input.selectionStart = input.selectionEnd = 0;
|
||||
input.selectionStart = input.selectionEnd = typedPrefix.length;
|
||||
input.dispatchEvent(event);
|
||||
await flushTimers();
|
||||
});
|
||||
|
||||
eq(event.defaultPrevented, true, "short text paste is handled by React state");
|
||||
eq(textarea().value, normalizedPaste, "short multiline paste is visible in the composer");
|
||||
eq(textarea().value, typedPrefix + normalizedPaste, "short multiline paste is visible after existing text");
|
||||
|
||||
const undo = new window.KeyboardEvent("keydown", { key: "z", ctrlKey: true, bubbles: true, cancelable: true });
|
||||
await act(async () => {
|
||||
textarea().dispatchEvent(undo);
|
||||
await flushTimers();
|
||||
});
|
||||
eq(undo.defaultPrevented, true, "Ctrl+Z handles the programmatic paste transaction");
|
||||
eq(textarea().value, typedPrefix, "Ctrl+Z preserves the text typed before a short paste");
|
||||
|
||||
const redo = new window.KeyboardEvent("keydown", {
|
||||
key: "Z",
|
||||
ctrlKey: true,
|
||||
shiftKey: true,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
});
|
||||
await act(async () => {
|
||||
textarea().dispatchEvent(redo);
|
||||
await flushTimers();
|
||||
});
|
||||
eq(redo.defaultPrevented, true, "Ctrl+Shift+Z redoes the programmatic paste transaction");
|
||||
eq(textarea().value, typedPrefix + normalizedPaste, "redo restores the short paste after existing text");
|
||||
|
||||
const undoAgain = new window.KeyboardEvent("keydown", {
|
||||
key: "z",
|
||||
ctrlKey: true,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
});
|
||||
await act(async () => {
|
||||
textarea().dispatchEvent(undoAgain);
|
||||
await flushTimers();
|
||||
});
|
||||
eq(textarea().value, typedPrefix, "a second undo returns to the pre-paste boundary");
|
||||
|
||||
await act(async () => {
|
||||
updateTextareaFromUser(`${typedPrefix}X`, "insertText");
|
||||
await flushTimers();
|
||||
});
|
||||
await rerender();
|
||||
eq(textarea().value, `${typedPrefix}X`, "ordinary typing after paste undo updates the draft");
|
||||
await act(async () => {
|
||||
updateTextareaFromUser(typedPrefix, "deleteContentBackward");
|
||||
await flushTimers();
|
||||
});
|
||||
await rerender();
|
||||
|
||||
const staleRedo = new window.KeyboardEvent("keydown", {
|
||||
key: "Z",
|
||||
ctrlKey: true,
|
||||
shiftKey: true,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
});
|
||||
await act(async () => {
|
||||
textarea().dispatchEvent(staleRedo);
|
||||
await flushTimers();
|
||||
});
|
||||
eq(staleRedo.defaultPrevented, false, "a new native edit invalidates the programmatic redo transaction");
|
||||
eq(textarea().value, typedPrefix, "stale redo cannot resurrect an earlier paste");
|
||||
|
||||
await act(async () => {
|
||||
const input = textarea();
|
||||
input.selectionStart = input.selectionEnd = typedPrefix.length;
|
||||
input.dispatchEvent(textPasteEvent(rawPaste));
|
||||
await flushTimers();
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
sendButton().click();
|
||||
@@ -447,8 +535,8 @@ console.log("\ncomposer session draft");
|
||||
});
|
||||
|
||||
eq(sent.length, 1, "short multiline paste submits once");
|
||||
eq(sent[0]?.display, normalizedPaste, "short multiline paste is preserved in display text");
|
||||
eq(sent[0]?.submit, normalizedPaste, "short multiline paste is preserved in submit text");
|
||||
eq(sent[0]?.display, typedPrefix + normalizedPaste, "short multiline paste is preserved in display text");
|
||||
eq(sent[0]?.submit, typedPrefix + normalizedPaste, "short multiline paste is preserved in submit text");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
@@ -456,6 +544,460 @@ console.log("\ncomposer session draft");
|
||||
dom.window.close();
|
||||
}
|
||||
|
||||
{
|
||||
const dom = installDom();
|
||||
const { root, rerender } = await renderComposer();
|
||||
const base = "base:";
|
||||
const pasted = "paste";
|
||||
await rerender({ insertRequest: { id: 101, text: base, mode: "replace" } });
|
||||
await act(async () => {
|
||||
const input = textarea();
|
||||
input.selectionStart = input.selectionEnd = base.length;
|
||||
input.dispatchEvent(textPasteEvent(pasted));
|
||||
await flushTimers();
|
||||
});
|
||||
eq(textarea().value, base + pasted, "interleaved-history test starts at the paste boundary");
|
||||
|
||||
await act(async () => {
|
||||
updateTextareaFromUser(`${base}${pasted}X`, "insertText");
|
||||
await flushTimers();
|
||||
});
|
||||
await rerender();
|
||||
await act(async () => {
|
||||
updateTextareaFromUser(base + pasted, "deleteContentBackward");
|
||||
await flushTimers();
|
||||
});
|
||||
await rerender();
|
||||
|
||||
const undoNativeDelete = new window.KeyboardEvent("keydown", {
|
||||
key: "z",
|
||||
ctrlKey: true,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
});
|
||||
await act(async () => {
|
||||
textarea().dispatchEvent(undoNativeDelete);
|
||||
await flushTimers();
|
||||
});
|
||||
eq(
|
||||
undoNativeDelete.defaultPrevented,
|
||||
false,
|
||||
"Ctrl+Z leaves a native deletion above the paste transaction to the browser",
|
||||
);
|
||||
eq(
|
||||
textarea().value,
|
||||
base + pasted,
|
||||
"matching net text does not make the older paste transaction look newest",
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
updateTextareaFromUser(`${base}${pasted}X`, "historyUndo");
|
||||
await flushTimers();
|
||||
});
|
||||
await rerender();
|
||||
const undoNativeInsert = new window.KeyboardEvent("keydown", {
|
||||
key: "z",
|
||||
ctrlKey: true,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
});
|
||||
await act(async () => {
|
||||
textarea().dispatchEvent(undoNativeInsert);
|
||||
await flushTimers();
|
||||
});
|
||||
eq(
|
||||
undoNativeInsert.defaultPrevented,
|
||||
false,
|
||||
"Ctrl+Z leaves the remaining native insertion to the browser",
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
updateTextareaFromUser(base + pasted, "historyUndo");
|
||||
await flushTimers();
|
||||
});
|
||||
await rerender();
|
||||
const undoPasteAfterNative = new window.KeyboardEvent("keydown", {
|
||||
key: "z",
|
||||
ctrlKey: true,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
});
|
||||
await act(async () => {
|
||||
textarea().dispatchEvent(undoPasteAfterNative);
|
||||
await flushTimers();
|
||||
});
|
||||
eq(
|
||||
undoPasteAfterNative.defaultPrevented,
|
||||
true,
|
||||
"the paste transaction becomes undoable only after newer native edits are exhausted",
|
||||
);
|
||||
eq(textarea().value, base, "ordered undo eventually restores the pre-paste text");
|
||||
|
||||
const redoPasteBeforeNative = new window.KeyboardEvent("keydown", {
|
||||
key: "Z",
|
||||
ctrlKey: true,
|
||||
shiftKey: true,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
});
|
||||
await act(async () => {
|
||||
textarea().dispatchEvent(redoPasteBeforeNative);
|
||||
await flushTimers();
|
||||
});
|
||||
eq(
|
||||
redoPasteBeforeNative.defaultPrevented,
|
||||
true,
|
||||
"ordered redo restores the custom paste before its newer native edits",
|
||||
);
|
||||
eq(textarea().value, base + pasted, "custom redo returns to the browser redo boundary");
|
||||
|
||||
const redoNativeAfterPaste = new window.KeyboardEvent("keydown", {
|
||||
key: "Z",
|
||||
ctrlKey: true,
|
||||
shiftKey: true,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
});
|
||||
await act(async () => {
|
||||
textarea().dispatchEvent(redoNativeAfterPaste);
|
||||
await flushTimers();
|
||||
});
|
||||
eq(
|
||||
redoNativeAfterPaste.defaultPrevented,
|
||||
false,
|
||||
"newer native redo remains browser-owned after the paste redo",
|
||||
);
|
||||
|
||||
await act(async () => root.unmount());
|
||||
dom.window.close();
|
||||
}
|
||||
|
||||
{
|
||||
const dom = installDom();
|
||||
const { root, rerender } = await renderComposer();
|
||||
await act(async () => {
|
||||
textarea().dispatchEvent(textPasteEvent("first"));
|
||||
await flushTimers();
|
||||
});
|
||||
await act(async () => {
|
||||
updateTextareaFromUser("firstX", "insertText");
|
||||
await flushTimers();
|
||||
});
|
||||
await rerender();
|
||||
await act(async () => {
|
||||
const input = textarea();
|
||||
input.selectionStart = input.selectionEnd = input.value.length;
|
||||
input.dispatchEvent(textPasteEvent("second"));
|
||||
await flushTimers();
|
||||
});
|
||||
eq(textarea().value, "firstXsecond", "two custom pastes can surround a native edit");
|
||||
|
||||
const undoSecondPaste = new window.KeyboardEvent("keydown", {
|
||||
key: "z",
|
||||
ctrlKey: true,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
});
|
||||
await act(async () => {
|
||||
textarea().dispatchEvent(undoSecondPaste);
|
||||
await flushTimers();
|
||||
});
|
||||
eq(undoSecondPaste.defaultPrevented, true, "the newest custom paste undoes first");
|
||||
eq(textarea().value, "firstX", "undoing the second paste reaches its native boundary");
|
||||
|
||||
const undoInterleavedNative = new window.KeyboardEvent("keydown", {
|
||||
key: "z",
|
||||
ctrlKey: true,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
});
|
||||
await act(async () => {
|
||||
textarea().dispatchEvent(undoInterleavedNative);
|
||||
await flushTimers();
|
||||
});
|
||||
eq(
|
||||
undoInterleavedNative.defaultPrevented,
|
||||
false,
|
||||
"the native edit between two custom pastes remains browser-owned",
|
||||
);
|
||||
await act(async () => {
|
||||
updateTextareaFromUser("first", "historyUndo");
|
||||
await flushTimers();
|
||||
});
|
||||
await rerender();
|
||||
|
||||
const undoFirstPaste = new window.KeyboardEvent("keydown", {
|
||||
key: "z",
|
||||
ctrlKey: true,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
});
|
||||
await act(async () => {
|
||||
textarea().dispatchEvent(undoFirstPaste);
|
||||
await flushTimers();
|
||||
});
|
||||
eq(undoFirstPaste.defaultPrevented, true, "the older custom paste follows the native undo segment");
|
||||
eq(textarea().value, "", "ordered undo reaches the start of the mixed timeline");
|
||||
|
||||
const redoFirstPaste = new window.KeyboardEvent("keydown", {
|
||||
key: "Z",
|
||||
ctrlKey: true,
|
||||
shiftKey: true,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
});
|
||||
await act(async () => {
|
||||
textarea().dispatchEvent(redoFirstPaste);
|
||||
await flushTimers();
|
||||
});
|
||||
eq(redoFirstPaste.defaultPrevented, true, "ordered redo restores the first custom paste");
|
||||
eq(textarea().value, "first", "redo reaches the native segment after the first paste");
|
||||
|
||||
const redoInterleavedNative = new window.KeyboardEvent("keydown", {
|
||||
key: "Z",
|
||||
ctrlKey: true,
|
||||
shiftKey: true,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
});
|
||||
await act(async () => {
|
||||
textarea().dispatchEvent(redoInterleavedNative);
|
||||
await flushTimers();
|
||||
});
|
||||
eq(
|
||||
redoInterleavedNative.defaultPrevented,
|
||||
false,
|
||||
"ordered redo returns the interleaved native edit to the browser",
|
||||
);
|
||||
await act(async () => {
|
||||
updateTextareaFromUser("firstX", "historyRedo");
|
||||
await flushTimers();
|
||||
});
|
||||
await rerender();
|
||||
|
||||
const redoSecondPaste = new window.KeyboardEvent("keydown", {
|
||||
key: "Z",
|
||||
ctrlKey: true,
|
||||
shiftKey: true,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
});
|
||||
await act(async () => {
|
||||
textarea().dispatchEvent(redoSecondPaste);
|
||||
await flushTimers();
|
||||
});
|
||||
eq(redoSecondPaste.defaultPrevented, true, "the second custom paste redoes after the native segment");
|
||||
eq(textarea().value, "firstXsecond", "mixed custom/native redo restores the final state");
|
||||
|
||||
await act(async () => root.unmount());
|
||||
dom.window.close();
|
||||
}
|
||||
|
||||
{
|
||||
const dom = installDom();
|
||||
await act(async () => {
|
||||
resetCustomShortcuts();
|
||||
await flushTimers();
|
||||
});
|
||||
saveCustomShortcut("toolApproval.yolo", { key: "z", ctrl: true });
|
||||
let yoloToggles = 0;
|
||||
const { root } = await renderComposer({
|
||||
onToggleYoloApprovalMode: () => {
|
||||
yoloToggles += 1;
|
||||
},
|
||||
});
|
||||
await act(async () => {
|
||||
textarea().dispatchEvent(textPasteEvent("pasted"));
|
||||
await flushTimers();
|
||||
});
|
||||
const undoPaste = new window.KeyboardEvent("keydown", {
|
||||
key: "z",
|
||||
ctrlKey: true,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
});
|
||||
await act(async () => {
|
||||
textarea().dispatchEvent(undoPaste);
|
||||
await flushTimers();
|
||||
});
|
||||
eq(undoPaste.defaultPrevented, true, "legacy Ctrl+Z YOLO binding yields to composer undo while editing");
|
||||
eq(textarea().value, "", "legacy Ctrl+Z YOLO binding still undoes the paste");
|
||||
eq(yoloToggles, 0, "legacy Ctrl+Z YOLO binding does not toggle YOLO while editing");
|
||||
|
||||
await act(async () => {
|
||||
saveCustomShortcut("toolApproval.yolo", { key: "z", ctrl: true, shift: true });
|
||||
await flushTimers();
|
||||
});
|
||||
const legacyRedo = new window.KeyboardEvent("keydown", {
|
||||
key: "z",
|
||||
ctrlKey: true,
|
||||
shiftKey: true,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
});
|
||||
await act(async () => {
|
||||
textarea().dispatchEvent(legacyRedo);
|
||||
await flushTimers();
|
||||
});
|
||||
eq(legacyRedo.defaultPrevented, true, "legacy Ctrl+Shift+Z YOLO binding yields to composer redo while editing");
|
||||
eq(textarea().value, "pasted", "legacy Ctrl+Shift+Z YOLO binding still redoes the paste");
|
||||
eq(yoloToggles, 0, "legacy Ctrl+Shift+Z YOLO binding does not toggle YOLO while editing");
|
||||
|
||||
await act(async () => {
|
||||
saveCustomShortcut("toolApproval.yolo", { key: "u", ctrl: true });
|
||||
textarea().dispatchEvent(new window.KeyboardEvent("keydown", {
|
||||
key: "z",
|
||||
ctrlKey: true,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
}));
|
||||
await flushTimers();
|
||||
});
|
||||
eq(textarea().value, "", "Ctrl+Z prepares a custom redo transaction after YOLO is rebound");
|
||||
|
||||
const ctrlYRedo = new window.KeyboardEvent("keydown", {
|
||||
key: "y",
|
||||
ctrlKey: true,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
});
|
||||
await act(async () => {
|
||||
textarea().dispatchEvent(ctrlYRedo);
|
||||
await flushTimers();
|
||||
});
|
||||
eq(ctrlYRedo.defaultPrevented, true, "Ctrl+Y redoes the paste when the YOLO shortcut is rebound");
|
||||
eq(textarea().value, "pasted", "Ctrl+Y restores the programmatic paste");
|
||||
eq(yoloToggles, 0, "Ctrl+Y does not toggle YOLO after that action is rebound");
|
||||
|
||||
await act(async () => {
|
||||
resetCustomShortcuts();
|
||||
await flushTimers();
|
||||
});
|
||||
const defaultCtrlY = new window.KeyboardEvent("keydown", {
|
||||
key: "y",
|
||||
ctrlKey: true,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
});
|
||||
await act(async () => {
|
||||
textarea().dispatchEvent(defaultCtrlY);
|
||||
await flushTimers();
|
||||
});
|
||||
eq(defaultCtrlY.defaultPrevented, true, "default Ctrl+Y remains reserved for the YOLO shortcut");
|
||||
eq(yoloToggles, 1, "default Ctrl+Y still toggles YOLO exactly once");
|
||||
eq(textarea().value, "pasted", "the YOLO shortcut does not mutate composer history");
|
||||
|
||||
await act(async () => root.unmount());
|
||||
resetCustomShortcuts();
|
||||
dom.window.close();
|
||||
}
|
||||
|
||||
{
|
||||
const dom = installDom();
|
||||
const { root, rerender } = await renderComposer({
|
||||
tabId: "tab-a",
|
||||
sessionKey: "session:project:/repo:topic-a:session-a",
|
||||
});
|
||||
const longPaste = Array.from({ length: 20 }, (_, index) => `line ${index + 1}`).join("\n");
|
||||
await act(async () => {
|
||||
textarea().dispatchEvent(textPasteEvent(longPaste));
|
||||
await flushTimers();
|
||||
});
|
||||
ok(document.querySelector(".composer__pasted-label") !== null, "long paste creates a folded block");
|
||||
const previewButton = document.querySelector<HTMLButtonElement>(".composer__pasted-actions button");
|
||||
if (!previewButton) throw new Error("long paste preview button did not render");
|
||||
await act(async () => {
|
||||
previewButton.click();
|
||||
await flushTimers();
|
||||
});
|
||||
ok(document.querySelector(".composer__pasted-preview") !== null, "long paste preview opens without changing text history");
|
||||
|
||||
await rerender({
|
||||
tabId: "tab-b",
|
||||
sessionKey: "session:project:/repo:topic-b:session-b",
|
||||
insertRequest: { id: 101, text: "draft B", mode: "replace" },
|
||||
});
|
||||
const unrelatedUndo = new window.KeyboardEvent("keydown", {
|
||||
key: "z",
|
||||
ctrlKey: true,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
});
|
||||
await act(async () => {
|
||||
textarea().dispatchEvent(unrelatedUndo);
|
||||
await flushTimers();
|
||||
});
|
||||
eq(unrelatedUndo.defaultPrevented, false, "another draft does not inherit the paste undo transaction");
|
||||
eq(textarea().value, "draft B", "Ctrl+Z in another draft leaves its text unchanged");
|
||||
|
||||
await rerender({ tabId: "tab-a", sessionKey: "session:project:/repo:topic-a:session-a" });
|
||||
ok(document.querySelector(".composer__pasted-preview") !== null, "source draft restores its open long-paste preview");
|
||||
const foldedUndo = new window.KeyboardEvent("keydown", {
|
||||
key: "z",
|
||||
ctrlKey: true,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
});
|
||||
await act(async () => {
|
||||
textarea().dispatchEvent(foldedUndo);
|
||||
await flushTimers();
|
||||
});
|
||||
eq(foldedUndo.defaultPrevented, true, "source draft keeps its own folded-paste undo transaction");
|
||||
eq(textarea().value, "", "Ctrl+Z removes the folded-paste label");
|
||||
ok(document.querySelector(".composer__pasted-label") === null, "Ctrl+Z removes the folded block state");
|
||||
ok(document.querySelector(".composer__pasted-preview") === null, "Ctrl+Z also clears preview state with the folded block");
|
||||
|
||||
await act(async () => root.unmount());
|
||||
dom.window.close();
|
||||
}
|
||||
|
||||
{
|
||||
const dom = installDom();
|
||||
const { root } = await renderComposer();
|
||||
const longPaste = Array.from({ length: 20 }, (_, index) => `expanded ${index + 1}`).join("\n");
|
||||
await act(async () => {
|
||||
textarea().dispatchEvent(textPasteEvent(longPaste));
|
||||
await flushTimers();
|
||||
});
|
||||
const expandButton = document.querySelectorAll<HTMLButtonElement>(".composer__pasted-actions button")[1];
|
||||
if (!expandButton) throw new Error("long paste expand button did not render");
|
||||
await act(async () => {
|
||||
expandButton.click();
|
||||
await flushTimers();
|
||||
});
|
||||
eq(textarea().value, longPaste, "expanding a folded paste restores its original text");
|
||||
ok(document.querySelector(".composer__pasted-label") === null, "expanded paste no longer renders a folded label");
|
||||
|
||||
const undoExpand = new window.KeyboardEvent("keydown", {
|
||||
key: "z",
|
||||
ctrlKey: true,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
});
|
||||
await act(async () => {
|
||||
textarea().dispatchEvent(undoExpand);
|
||||
await flushTimers();
|
||||
});
|
||||
eq(undoExpand.defaultPrevented, true, "Ctrl+Z treats pasted-block expansion as a custom transaction");
|
||||
ok(document.querySelector(".composer__pasted-label") !== null, "undoing expansion restores the folded block");
|
||||
|
||||
const undoLongPaste = new window.KeyboardEvent("keydown", {
|
||||
key: "z",
|
||||
ctrlKey: true,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
});
|
||||
await act(async () => {
|
||||
textarea().dispatchEvent(undoLongPaste);
|
||||
await flushTimers();
|
||||
});
|
||||
eq(undoLongPaste.defaultPrevented, true, "a second Ctrl+Z undoes the original folded paste");
|
||||
eq(textarea().value, "", "undoing the original folded paste restores the empty draft");
|
||||
|
||||
await act(async () => root.unmount());
|
||||
dom.window.close();
|
||||
}
|
||||
|
||||
{
|
||||
const dom = installDom();
|
||||
const saveStarted = deferred<void>();
|
||||
@@ -577,7 +1119,7 @@ console.log("\ncomposer session draft");
|
||||
textarea().setSelectionRange(2, 2);
|
||||
const menuItems = await openComposerInputMenu();
|
||||
await act(async () => {
|
||||
menuItems[2]?.click();
|
||||
menuItems[4]?.click();
|
||||
await flushTimers();
|
||||
});
|
||||
await rerender({
|
||||
@@ -624,7 +1166,7 @@ console.log("\ncomposer session draft");
|
||||
textarea().setSelectionRange(1, 4);
|
||||
const menuItems = await openComposerInputMenu();
|
||||
await act(async () => {
|
||||
menuItems[0]?.click();
|
||||
menuItems[2]?.click();
|
||||
await clipboardWriteStarted.promise;
|
||||
});
|
||||
await rerender({
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
function ok(value: unknown, message: string) {
|
||||
if (!value) throw new Error(message);
|
||||
}
|
||||
|
||||
const source = readFileSync(new URL("../components/MemoryPanel.tsx", import.meta.url), "utf8");
|
||||
|
||||
ok(source.includes("function memoryFactKey"), "Context Center must use a stable fact identity helper");
|
||||
ok(source.includes("app.MemoryRevisionsForTab"), "Context Center must load revision history for the selected workspace");
|
||||
ok(source.includes("app.RestoreMemoryRevisionForTab"), "Context Center must restore a selected revision");
|
||||
ok(source.includes("view.lastRecall"), "Context Center must explain the last automatic recall");
|
||||
ok(source.includes("view.conflicts"), "Context Center must explain project-over-global memory resolution");
|
||||
ok(source.includes("view.instructionDiagnostics"), "Context Center must surface instruction diagnostics");
|
||||
ok(source.includes("d.precedence"), "Context Center must show resolved instruction precedence");
|
||||
|
||||
console.log("context-center-contract: ok");
|
||||
@@ -146,6 +146,31 @@ console.log("\ncontext window ring");
|
||||
dom.window.close();
|
||||
}
|
||||
|
||||
{
|
||||
const dom = installDom();
|
||||
installContextPanelMock(async () => contextPanelInfo(0));
|
||||
|
||||
const { root } = await renderRing({ turnCost: 0.125, currency: "$" });
|
||||
const button = document.querySelector(".context-ring") as HTMLButtonElement | null;
|
||||
if (!button) throw new Error("missing context ring button");
|
||||
await act(async () => {
|
||||
button.dispatchEvent(new MouseEvent("mouseover", { bubbles: true, relatedTarget: null }));
|
||||
await wait(220);
|
||||
});
|
||||
const turnCostRow = [...document.querySelectorAll(".context-ring-popover__row")]
|
||||
.find((row) => row.querySelector(".context-ring-popover__label")?.textContent === "turn cost");
|
||||
eq(
|
||||
turnCostRow?.querySelector(".context-ring-popover__value")?.textContent,
|
||||
"$0.1250",
|
||||
"turn cost uses the session currency before panel info is available",
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
dom.window.close();
|
||||
}
|
||||
|
||||
{
|
||||
const dom = installDom();
|
||||
const calls: string[] = [];
|
||||
|
||||
@@ -73,13 +73,14 @@ function installDom(language = "en-US") {
|
||||
|
||||
console.log("\ndecision surface");
|
||||
|
||||
// Plan keeps one explicit start decision, but both visible choices are direct
|
||||
// buttons. Exit/stop remain global controls rather than extra card branches.
|
||||
// Plan exposes start, revise, and leave-without-executing as direct buttons so
|
||||
// declining the current plan never traps the user in Plan mode.
|
||||
{
|
||||
const dom = installDom();
|
||||
const root = createRoot(document.getElementById("root")!);
|
||||
const answers: Array<[boolean, boolean, boolean]> = [];
|
||||
const revisions: string[] = [];
|
||||
let exits = 0;
|
||||
const approval: WireApproval = {
|
||||
id: "plan-1",
|
||||
tool: "exit_plan_mode",
|
||||
@@ -93,6 +94,7 @@ console.log("\ndecision surface");
|
||||
approval={approval}
|
||||
onAnswer={(a, s, p) => answers.push([a, s, p])}
|
||||
onRevisePlan={(text) => revisions.push(text)}
|
||||
onExitPlan={() => { exits += 1; }}
|
||||
onStop={() => undefined}
|
||||
/>
|
||||
</LocaleProvider>,
|
||||
@@ -101,11 +103,11 @@ console.log("\ndecision surface");
|
||||
});
|
||||
|
||||
const actions = [...document.querySelectorAll(".prompt-shelf__actions .prompt-action")] as HTMLButtonElement[];
|
||||
eq(actions.length, 2, "Plan has start and revise actions only");
|
||||
eq(actions.length, 3, "Plan has start, revise, and exit-without-executing actions");
|
||||
eq(document.querySelector(".prompt-shelf__actions")?.getAttribute("role"), "group", "Plan actions use button-group semantics");
|
||||
ok(actions.every((action) => action.getAttribute("role") === "button"), "Plan actions are announced as buttons");
|
||||
ok(!document.querySelector(".decision-confirm-bar__confirm"), "Plan has no redundant confirm button");
|
||||
ok(!document.body.textContent?.includes("Exit plan mode"), "Plan card hides the exit branch");
|
||||
ok(actions[2].textContent?.includes("Exit without executing"), "Plan card exposes a clear non-executing exit");
|
||||
ok(!document.body.textContent?.includes("Stop task"), "Plan card relies on the global Stop control");
|
||||
|
||||
await act(async () => {
|
||||
@@ -115,6 +117,46 @@ console.log("\ndecision surface");
|
||||
ok(document.querySelector(".plan-revision__input") != null, "Revise opens the inline editor in one click");
|
||||
eq(answers.length, 0, "Opening revision does not start execution");
|
||||
|
||||
await act(async () => {
|
||||
actions[2].click();
|
||||
actions[2].click();
|
||||
await flushTimers(220);
|
||||
});
|
||||
eq(exits, 1, "Exit without executing runs once and ignores a double click");
|
||||
eq(answers.length, 0, "Exit without executing never approves plan execution");
|
||||
eq(revisions.length, 0, "Exit without executing does not submit a plan revision");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
dom.window.close();
|
||||
}
|
||||
|
||||
{
|
||||
const dom = installDom();
|
||||
const root = createRoot(document.getElementById("root")!);
|
||||
let exits = 0;
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<LocaleProvider>
|
||||
<ApprovalModal
|
||||
approval={{ id: "plan-exit-key", tool: "exit_plan_mode", subject: "Plan ready" }}
|
||||
onAnswer={() => undefined}
|
||||
onExitPlan={() => { exits += 1; }}
|
||||
onStop={() => undefined}
|
||||
/>
|
||||
</LocaleProvider>,
|
||||
);
|
||||
await flushTimers();
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
document.dispatchEvent(new window.KeyboardEvent("keydown", { key: "3", bubbles: true }));
|
||||
await flushTimers(220);
|
||||
});
|
||||
eq(exits, 1, "number key 3 exits Plan without execution");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
|
||||
@@ -75,7 +75,7 @@ function baseReport(runtime: boolean): CapabilityDiagnosticsReport {
|
||||
plugins: 0,
|
||||
mcp_servers: 1,
|
||||
},
|
||||
instructions: { docs: [{ path: "<workspace>/AGENTS.md", scope: "project", order: 1 }] },
|
||||
instructions: { docs: [{ path: "<workspace>/AGENTS.md", scope: "project", directory: "<workspace>", depth: 0, order: 1 }] },
|
||||
skills: {
|
||||
roots: [{ path: "<workspace>/.reasonix/skills", scope: "project", status: "ok" }],
|
||||
entries: [{ name: "demo", path: "<workspace>/.reasonix/skills/demo/SKILL.md", status: "winner" }],
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
// Run: tsx src/__tests__/goal-action-errors.test.tsx
|
||||
|
||||
import { readFileSync } from "node:fs";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { JSDOM } from "jsdom";
|
||||
import React, { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { useGoalActionHandler } from "../lib/goalAction";
|
||||
import { ToastProvider } from "../lib/toast";
|
||||
|
||||
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 flushPromises(): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
|
||||
console.log("\ngoal action bridge errors");
|
||||
|
||||
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.Event = dom.window.Event;
|
||||
globalThis.MouseEvent = dom.window.MouseEvent;
|
||||
|
||||
const unhandled: unknown[] = [];
|
||||
const onUnhandledRejection = (reason: unknown) => unhandled.push(reason);
|
||||
const onWindowUnhandledRejection = (event: PromiseRejectionEvent) => unhandled.push(event.reason);
|
||||
process.on("unhandledRejection", onUnhandledRejection);
|
||||
window.addEventListener("unhandledrejection", onWindowUnhandledRejection);
|
||||
|
||||
function Probe() {
|
||||
const { runGoalAction } = useGoalActionHandler();
|
||||
const run = (label: string) => {
|
||||
runGoalAction(async () => {
|
||||
throw new Error(`${label} bridge failed`);
|
||||
});
|
||||
};
|
||||
return (
|
||||
<>
|
||||
<button type="button" data-action="stop" onClick={() => run("stop goal")}>stop</button>
|
||||
<button type="button" data-action="mode" onClick={() => run("switch mode")}>mode</button>
|
||||
<button type="button" data-action="resync" onClick={() => run("background goal resync")}>resync</button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const rootElement = document.getElementById("root");
|
||||
if (!rootElement) throw new Error("missing root");
|
||||
const root = createRoot(rootElement);
|
||||
await act(async () => {
|
||||
root.render(<ToastProvider><Probe /></ToastProvider>);
|
||||
});
|
||||
|
||||
for (const action of ["stop", "mode", "resync"]) {
|
||||
await act(async () => {
|
||||
document.querySelector<HTMLButtonElement>(`[data-action="${action}"]`)?.click();
|
||||
await flushPromises();
|
||||
});
|
||||
}
|
||||
|
||||
const errors = Array.from(document.querySelectorAll(".toast--error .toast__text")).map((node) => node.textContent);
|
||||
ok(errors.includes("stop goal bridge failed"), "rejected Stop Goal action shows an error toast");
|
||||
ok(errors.includes("switch mode bridge failed"), "rejected mode action shows an error toast");
|
||||
ok(errors.includes("background goal resync bridge failed"), "rejected background Goal resync shows an error toast");
|
||||
ok(unhandled.length === 0, "handled Goal action rejections do not emit unhandledrejection");
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const appSource = readFileSync(resolve(here, "../App.tsx"), "utf8");
|
||||
ok(
|
||||
/runGoalAction\(\(\) => applyCollaborationMode\(collaborationMode === "plan" \? "normal" : "plan"\)\)/.test(appSource),
|
||||
"mode shortcut routes through the rejection handler",
|
||||
);
|
||||
ok(
|
||||
/runGoalAction\(async \(\) => \{[\s\S]{0,260}setControllerComposerProfileForTab\([\s\S]{0,260}propagateError: true/.test(appSource),
|
||||
"background Goal resync routes through the rejection handler",
|
||||
);
|
||||
ok(appSource.includes("onClearGoal={clearGoalFromUi}"), "Composer Stop Goal routes through the rejection handler");
|
||||
ok(appSource.includes("onSetCollaborationMode={setCollaborationModeFromUi}"), "Composer mode changes route through the rejection handler");
|
||||
ok(/if \(model\) \{\s*await switchModel\(model\[1\]\);/.test(appSource), "/model awaits Goal restoration failures");
|
||||
ok(
|
||||
/await \(trimmed \? setControllerGoalForTab\(tabId, trimmed\) : clearControllerGoalForTab\(tabId\)\);\s*patchActivatedGoalForTab\(tabId, trimmed\)/.test(appSource),
|
||||
"failed Goal bridge calls cannot patch local Goal state or user intent",
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
process.off("unhandledRejection", onUnhandledRejection);
|
||||
window.removeEventListener("unhandledrejection", onWindowUnhandledRejection);
|
||||
dom.window.close();
|
||||
|
||||
console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`);
|
||||
if (failed > 0) process.exit(1);
|
||||
@@ -0,0 +1,301 @@
|
||||
// Run: tsx src/__tests__/goal-activation-tab-routing.test.tsx
|
||||
//
|
||||
// App/bridge harness for the first Goal + structured Skill path: hang the
|
||||
// combined backend call for A, switch active to B, then assert the source tab
|
||||
// and workbench target token stayed fixed.
|
||||
|
||||
import { JSDOM } from "jsdom";
|
||||
import React, { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import type { AppBindings } from "../lib/bridge";
|
||||
import { activateGoalAndSubmitOnTab } from "../lib/goalSubmit";
|
||||
import { useController } from "../lib/useController";
|
||||
import type { BalanceInfo, CheckpointMeta, ContextInfo, EffortInfo, HistoryMessage, JobView, Meta, TabMeta } from "../lib/types";
|
||||
|
||||
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) {
|
||||
ok(actual === expected, `${label}${actual === expected ? "" : `: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`}`);
|
||||
}
|
||||
|
||||
function flushPromises(): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
|
||||
function tabMeta(overrides: Partial<TabMeta> = {}): TabMeta {
|
||||
return {
|
||||
id: "tab-a",
|
||||
scope: "project",
|
||||
workspaceRoot: "/repo",
|
||||
workspaceName: "repo",
|
||||
workspacePath: "/repo",
|
||||
gitBranch: "main",
|
||||
topicId: "topic-a",
|
||||
topicTitle: "A",
|
||||
label: "model",
|
||||
ready: true,
|
||||
running: false,
|
||||
mode: "normal",
|
||||
toolApprovalMode: "ask",
|
||||
tokenMode: "full",
|
||||
active: true,
|
||||
cwd: "/repo",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function metaFor(tab: TabMeta): Meta {
|
||||
return {
|
||||
label: tab.label,
|
||||
ready: tab.ready,
|
||||
startupErr: tab.startupErr,
|
||||
eventChannel: "agent:event",
|
||||
cwd: tab.cwd || tab.workspaceRoot,
|
||||
workspaceRoot: tab.workspaceRoot,
|
||||
workspaceName: tab.workspaceName,
|
||||
workspacePath: tab.workspacePath,
|
||||
gitBranch: tab.gitBranch,
|
||||
autoApproveTools: false,
|
||||
bypass: false,
|
||||
collaborationMode: tab.collaborationMode ?? "normal",
|
||||
toolApprovalMode: tab.toolApprovalMode ?? "ask",
|
||||
tokenMode: tab.tokenMode ?? "full",
|
||||
goal: tab.goal ?? "",
|
||||
goalStatus: tab.goal ? "running" : "stopped",
|
||||
};
|
||||
}
|
||||
|
||||
console.log("\ngoal activation tab routing");
|
||||
|
||||
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.Event = dom.window.Event;
|
||||
globalThis.CustomEvent = dom.window.CustomEvent;
|
||||
globalThis.KeyboardEvent = dom.window.KeyboardEvent;
|
||||
globalThis.MouseEvent = dom.window.MouseEvent;
|
||||
globalThis.localStorage = dom.window.localStorage;
|
||||
globalThis.requestAnimationFrame = dom.window.requestAnimationFrame.bind(dom.window);
|
||||
globalThis.cancelAnimationFrame = dom.window.cancelAnimationFrame.bind(dom.window);
|
||||
|
||||
const tabA = tabMeta({ id: "tab-a", topicId: "topic-a", topicTitle: "A", active: true });
|
||||
const tabB = tabMeta({ id: "tab-b", topicId: "topic-b", topicTitle: "B", active: false, cwd: "/repo-b", workspaceRoot: "/repo-b", workspacePath: "/repo-b" });
|
||||
let tabs: TabMeta[] = [tabA, tabB];
|
||||
|
||||
const initialGoalCalls: string[] = [];
|
||||
let releaseGoal!: () => void;
|
||||
const goalGate = new Promise<void>((resolve) => {
|
||||
releaseGoal = resolve;
|
||||
});
|
||||
|
||||
const context: ContextInfo = { used: 0, window: 100, sessionTokens: 0 };
|
||||
const effort: EffortInfo = { supported: true, current: "auto", default: "auto", levels: ["auto"] };
|
||||
const balance: BalanceInfo = { available: false, display: "" };
|
||||
const jobs: JobView[] = [];
|
||||
const checkpoints: CheckpointMeta[] = [];
|
||||
|
||||
window.runtime = {
|
||||
EventsOn: () => () => {},
|
||||
BrowserOpenURL: () => {},
|
||||
};
|
||||
window.go = {
|
||||
main: {
|
||||
App: {
|
||||
ListTabs: async () => tabs.map((tab) => ({ ...tab })),
|
||||
SetActiveTab: async (tabId: string) => {
|
||||
tabs = tabs.map((tab) => ({ ...tab, active: tab.id === tabId }));
|
||||
},
|
||||
MetaForTab: async (tabId: string) => {
|
||||
const tab = tabs.find((entry) => entry.id === tabId) ?? tabA;
|
||||
return metaFor(tab);
|
||||
},
|
||||
ContextUsageForTab: async () => context,
|
||||
EffortForTab: async () => effort,
|
||||
BalanceForTab: async () => balance,
|
||||
JobsForTab: async () => jobs,
|
||||
CheckpointsForTab: async () => checkpoints,
|
||||
HistoryForTab: async (): Promise<HistoryMessage[]> => [],
|
||||
HistoryPageForTab: async () => ({ messages: [], startTurn: 0, endTurn: 0, totalTurns: 0, hasOlder: false }),
|
||||
HistoryCheckpointTurnsForTab: async () => [],
|
||||
ReplayPendingPrompts: async () => {},
|
||||
SetGoalForTab: async (tabID: string, goal: string) => {
|
||||
tabs = tabs.map((tab) =>
|
||||
tab.id === tabID
|
||||
? { ...tab, goal, collaborationMode: goal ? "goal" : "normal" }
|
||||
: tab,
|
||||
);
|
||||
},
|
||||
SubmitInitialGoalToTab: async (
|
||||
tabID: string,
|
||||
goal: string,
|
||||
display: string,
|
||||
_input: string,
|
||||
invocations: { name: string }[],
|
||||
collaborationMode: string,
|
||||
toolApprovalMode: string,
|
||||
targetKind: string,
|
||||
targetIdentityGen: number,
|
||||
targetRequestSeq: number,
|
||||
): Promise<string[]> => {
|
||||
await goalGate;
|
||||
initialGoalCalls.push(
|
||||
`${tabID}:${goal}:${display}:${invocations[0]?.name ?? ""}:${collaborationMode}:${toolApprovalMode}:${targetKind}:${targetIdentityGen}:${targetRequestSeq}`,
|
||||
);
|
||||
tabs = tabs.map((tab) =>
|
||||
tab.id === tabID
|
||||
? { ...tab, goal, collaborationMode: goal ? "goal" : "normal" }
|
||||
: tab,
|
||||
);
|
||||
return [];
|
||||
},
|
||||
SubmitInvocationsToTab: async () => {
|
||||
throw new Error("split SubmitInvocationsToTab must not be used for initial Goals");
|
||||
},
|
||||
SubmitToTab: async () => {
|
||||
throw new Error("plain SubmitToTab must not be used for structured first Goal turns");
|
||||
},
|
||||
} as Partial<AppBindings> as AppBindings,
|
||||
},
|
||||
};
|
||||
|
||||
type Controller = ReturnType<typeof useController>;
|
||||
let controller: Controller | undefined;
|
||||
|
||||
function Probe() {
|
||||
controller = useController();
|
||||
return null;
|
||||
}
|
||||
|
||||
const rootEl = document.getElementById("root");
|
||||
if (!rootEl) throw new Error("missing root");
|
||||
const root = createRoot(rootEl);
|
||||
|
||||
await act(async () => {
|
||||
root.render(<Probe />);
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
await waitForActive("tab-a");
|
||||
eq(controller?.activeTabId, "tab-a", "harness starts on tab A");
|
||||
|
||||
const sourceTabId = "tab-a";
|
||||
const pending = activateGoalAndSubmitOnTab({
|
||||
tabId: sourceTabId,
|
||||
target: { kind: "ssh", identityGen: 7, requestSeq: 11 },
|
||||
displayText: "Cross-tab safe goal",
|
||||
submitText: "/ui-ux-pro-max Cross-tab safe goal",
|
||||
structured: {
|
||||
display: "/ui-ux-pro-max Cross-tab safe goal",
|
||||
input: "Cross-tab safe goal",
|
||||
invocations: [{ name: "ui-ux-pro-max", kind: "skill", offset: 0 }],
|
||||
},
|
||||
sendToTab: (tabId, goal, display, submit, structured, target) => {
|
||||
if (!controller) throw new Error("controller missing");
|
||||
if (!target) throw new Error("target missing");
|
||||
return controller.sendToTab(tabId, display, submit, undefined, structured, {
|
||||
goal,
|
||||
target,
|
||||
collaborationMode: "normal",
|
||||
toolApprovalMode: "ask",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
// While the atomic bridge call for A is suspended, the UI switches to tab B.
|
||||
// The in-flight call must keep both its tab and target token.
|
||||
await act(async () => {
|
||||
await controller?.switchTab("tab-b", tabB);
|
||||
await flushPromises();
|
||||
});
|
||||
eq(controller?.activeTabId, "tab-b", "active tab switched to B during deferred Goal activation");
|
||||
|
||||
releaseGoal();
|
||||
await act(async () => {
|
||||
await pending;
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
eq(
|
||||
initialGoalCalls.join("|"),
|
||||
"tab-a:Cross-tab safe goal:/ui-ux-pro-max Cross-tab safe goal:ui-ux-pro-max:normal:ask:ssh:7:11",
|
||||
"atomic Goal submit kept source tab A and its Remote target token",
|
||||
);
|
||||
eq(initialGoalCalls.length, 1, "atomic Goal submit ran once");
|
||||
|
||||
// Bridge failure path: controller must reject without falling back to the split
|
||||
// structured submit.
|
||||
const failedInitialGoalCalls: string[] = [];
|
||||
const failInvokeCalls: string[] = [];
|
||||
(window.go.main.App as AppBindings).SubmitInitialGoalToTab = async (tabID: string) => {
|
||||
failedInitialGoalCalls.push(tabID);
|
||||
throw new Error("workbench target changed");
|
||||
};
|
||||
(window.go.main.App as AppBindings).SubmitInvocationsToTab = async (tabID: string) => {
|
||||
failInvokeCalls.push(tabID);
|
||||
};
|
||||
|
||||
let activationFailed = false;
|
||||
await act(async () => {
|
||||
try {
|
||||
await activateGoalAndSubmitOnTab({
|
||||
tabId: "tab-a",
|
||||
target: { kind: "ssh", identityGen: 7, requestSeq: 11 },
|
||||
displayText: "Must not run skill",
|
||||
submitText: "/ui-ux-pro-max Must not run skill",
|
||||
structured: {
|
||||
display: "/ui-ux-pro-max Must not run skill",
|
||||
input: "Must not run skill",
|
||||
invocations: [{ name: "ui-ux-pro-max", kind: "skill", offset: 0 }],
|
||||
},
|
||||
sendToTab: (tabId, goal, display, submit, structured, target) =>
|
||||
controller!.sendToTab(tabId, display, submit, undefined, structured, {
|
||||
goal,
|
||||
target: target!,
|
||||
collaborationMode: "normal",
|
||||
toolApprovalMode: "ask",
|
||||
}),
|
||||
});
|
||||
} catch (error) {
|
||||
activationFailed = error instanceof Error && error.message.includes("workbench target changed");
|
||||
}
|
||||
await flushPromises();
|
||||
});
|
||||
eq(activationFailed, true, "controller propagates atomic Goal bridge rejection");
|
||||
eq(failedInitialGoalCalls.join("|"), "tab-a", "failed atomic submit still targeted source tab A");
|
||||
eq(failInvokeCalls.length, 0, "failed atomic Goal submit does not call split SubmitInvocationsToTab");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
dom.window.close();
|
||||
|
||||
console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`);
|
||||
if (failed > 0) process.exit(1);
|
||||
|
||||
async function waitForActive(tabId: string) {
|
||||
for (let attempt = 0; attempt < 50; attempt += 1) {
|
||||
if (controller?.activeTabId === tabId) return;
|
||||
await act(async () => {
|
||||
await flushPromises();
|
||||
});
|
||||
}
|
||||
throw new Error(`timed out waiting for active tab ${tabId}`);
|
||||
}
|
||||
@@ -157,6 +157,34 @@ function printResult(r: BenchResult): void {
|
||||
}
|
||||
|
||||
console.log("\nhistory performance benchmark");
|
||||
for (const c of cases) {
|
||||
printResult(runCase(c));
|
||||
const results = cases.map(runCase);
|
||||
for (const result of results) {
|
||||
printResult(result);
|
||||
}
|
||||
|
||||
const failures: string[] = [];
|
||||
for (let index = 0; index < results.length; index += 1) {
|
||||
const result = results[index];
|
||||
const input = cases[index];
|
||||
const expectedMessages = input.turns * (2 + input.toolsPerTurn);
|
||||
if (result.messages !== expectedMessages) failures.push(`${result.name}: unexpected message count`);
|
||||
if (result.turnGroups !== input.turns) failures.push(`${result.name}: unexpected turn-group count`);
|
||||
if (result.convertMs > 1_000 || result.reducerMs > 1_000 || result.transcriptComputeMs > 1_000) {
|
||||
failures.push(`${result.name}: exceeded 1s responsiveness ceiling`);
|
||||
}
|
||||
}
|
||||
|
||||
const full10KB = results.find((result) => result.name === "200-turns-full-10KB");
|
||||
const archived10KB = results.find((result) => result.name === "200-turns-archived-10KB");
|
||||
if (!full10KB || full10KB.itemStringBytes * 10 >= full10KB.jsonBytes) {
|
||||
failures.push("restored full tool results retained too much source text");
|
||||
}
|
||||
if (!archived10KB || archived10KB.itemStringBytes * 5 >= archived10KB.jsonBytes) {
|
||||
failures.push("restored archived tool results retained too much source text");
|
||||
}
|
||||
|
||||
if (failures.length > 0) {
|
||||
for (const failure of failures) process.stderr.write(`FAIL ${failure}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
process.stdout.write("PASS long-history performance contracts\n");
|
||||
@@ -7,9 +7,11 @@ import {
|
||||
formatShortcutCombo,
|
||||
formatShortcutComboParts,
|
||||
isCloseTabShortcut,
|
||||
isReservedComposerHistoryShortcut,
|
||||
matchesShortcut,
|
||||
shortcutAcceptsCombo,
|
||||
shortcutConflict,
|
||||
shortcutDefinition,
|
||||
type ShortcutPlatform,
|
||||
} from "../lib/keyboardShortcuts";
|
||||
import { topicShortcutIndexFromEvent, topicShortcutLabel } from "../lib/topicShortcuts";
|
||||
@@ -117,6 +119,18 @@ eq(shortcutAcceptsCombo("composer.send", { key: "s", ctrl: true }), false, "comp
|
||||
eq(shortcutAcceptsCombo("app.newSession", { key: "s", ctrl: true }), true, "unrestricted shortcuts still accept other keys");
|
||||
eq(formatShortcutCombo(defaultShortcutCombo("composer.newline", "darwin"), "darwin"), "⇧Enter", "formats the mac newline chord");
|
||||
eq(formatShortcutCombo(defaultShortcutCombo("composer.newline", "windows"), "windows"), "Shift+Enter", "formats the Windows newline chord");
|
||||
eq(matchesShortcut(event("z", { metaKey: true }), "composer.undo", "darwin"), true, "Cmd+Z matches composer undo on macOS");
|
||||
eq(matchesShortcut(event("z", { ctrlKey: true }), "composer.undo", "windows"), true, "Ctrl+Z matches composer undo on Windows");
|
||||
eq(matchesShortcut(event("z", { metaKey: true, shiftKey: true }), "composer.redo", "darwin"), true, "Cmd+Shift+Z matches composer redo on macOS");
|
||||
eq(matchesShortcut(event("z", { ctrlKey: true, shiftKey: true }), "composer.redo", "linux"), true, "Ctrl+Shift+Z matches composer redo on Linux");
|
||||
eq(matchesShortcut(event("y", { ctrlKey: true }), "composer.redo", "windows"), false, "Ctrl+Y remains a Composer-level conditional fallback");
|
||||
eq(isReservedComposerHistoryShortcut(event("z", { metaKey: true }), "darwin"), true, "macOS undo is reserved while editing");
|
||||
eq(isReservedComposerHistoryShortcut(event("z", { ctrlKey: true, shiftKey: true }), "windows"), true, "Windows redo is reserved while editing");
|
||||
eq(isReservedComposerHistoryShortcut(event("y", { ctrlKey: true }), "windows"), false, "conditional Ctrl+Y redo does not reserve a global shortcut");
|
||||
eq(shortcutDefinition("composer.undo").configurable, false, "composer undo is visible but locked to the native editing chord");
|
||||
eq(shortcutDefinition("composer.redo").configurable, false, "composer redo is visible but locked to the native editing chord");
|
||||
eq(shortcutConflict("app.newSession", { key: "z", ctrl: true }, "windows")?.action, "composer.undo", "custom shortcuts cannot take the Windows undo chord");
|
||||
eq(shortcutConflict("app.newSession", { key: "z", ctrl: true, shift: true }, "windows")?.action, "composer.redo", "custom shortcuts cannot take the Windows redo chord");
|
||||
|
||||
console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`);
|
||||
if (failed > 0) process.exit(1);
|
||||
@@ -0,0 +1,71 @@
|
||||
// Run: tsx src/__tests__/language-highlighting.test.ts
|
||||
|
||||
import { resolveLang } from "../lib/highlight";
|
||||
import {
|
||||
FILE_LANGUAGE_BY_EXTENSION,
|
||||
FILE_LANGUAGE_BY_NAME,
|
||||
extToLang,
|
||||
pathToLang,
|
||||
} from "../lib/lang";
|
||||
import { formatSelectionReference, languageFor } from "../lib/selectedTextContext";
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
function eq(actual: unknown, expected: unknown, label: string) {
|
||||
if (actual === expected) {
|
||||
process.stdout.write(` PASS ${label}\n`);
|
||||
passed += 1;
|
||||
} else {
|
||||
process.stdout.write(` FAIL ${label}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}\n`);
|
||||
failed += 1;
|
||||
}
|
||||
}
|
||||
|
||||
console.log("\nlanguage highlighting");
|
||||
|
||||
const pathCases = [
|
||||
["src/App.cs", "csharp", "csharp"],
|
||||
["src/Component.tsx", "tsx", "typescript"],
|
||||
["src/view.xaml", "xaml", "xml"],
|
||||
["src/index.html", "html", "xml"],
|
||||
["config/app.toml", "toml", "ini"],
|
||||
["Dockerfile", "dockerfile", "dockerfile"],
|
||||
["containers/Dockerfile.dev", "dockerfile", "dockerfile"],
|
||||
["C:\\repo\\Containerfile.windows", "dockerfile", "dockerfile"],
|
||||
["Makefile", "makefile", "makefile"],
|
||||
["build/GNUmakefile.release", "makefile", "makefile"],
|
||||
["CMakeLists.txt", "cmake", "cmake"],
|
||||
["public/.htaccess", "apache", "apache"],
|
||||
["config/httpd.conf", "apache", "apache"],
|
||||
["config/nginx.conf", "nginx", "nginx"],
|
||||
["build/module.makefile", "makefile", "makefile"],
|
||||
] as const;
|
||||
|
||||
for (const [path, semantic, lexer] of pathCases) {
|
||||
eq(pathToLang(path), semantic, `${path} has one semantic path mapping`);
|
||||
eq(extToLang(path), semantic, `${path} keeps tool diffs consistent`);
|
||||
eq(languageFor(path), semantic, `${path} keeps file previews consistent`);
|
||||
eq(resolveLang(semantic), lexer, `${path} resolves to a registered lexer`);
|
||||
}
|
||||
|
||||
const mappedLanguages = new Set([
|
||||
...Object.values(FILE_LANGUAGE_BY_EXTENSION),
|
||||
...Object.values(FILE_LANGUAGE_BY_NAME),
|
||||
]);
|
||||
for (const language of mappedLanguages) {
|
||||
eq(resolveLang(language) !== "", true, `${language} mapping has a registered lexer`);
|
||||
}
|
||||
|
||||
for (const [path, fenceTag] of [
|
||||
["Component.tsx", "tsx"],
|
||||
["template.html", "html"],
|
||||
["settings.toml", "toml"],
|
||||
["View.xaml", "xaml"],
|
||||
] as const) {
|
||||
const fenceLine = formatSelectionReference(path, "sample").split("\n")[2];
|
||||
eq(fenceLine, `\`\`\`${fenceTag}`, `${path} preserves its provider-visible semantic fence tag`);
|
||||
}
|
||||
|
||||
console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`);
|
||||
if (failed > 0) process.exit(1);
|
||||
@@ -6,13 +6,27 @@ import { CodeViewer } from "../components/CodeViewer";
|
||||
import LineNumberCode, {
|
||||
findCodeMatches,
|
||||
highlightLineMatches,
|
||||
MAX_HIGHLIGHT_BYTES,
|
||||
MAX_HIGHLIGHT_LINES,
|
||||
MAX_SEARCH_MATCHES,
|
||||
shouldHighlightCode,
|
||||
splitHighlightedCodeLines,
|
||||
} from "../components/editors/LineNumberCode";
|
||||
import { highlightToHtml } from "../lib/highlight";
|
||||
import {
|
||||
findRegexCodeMatches,
|
||||
MAX_REGEX_PATTERN_LENGTH,
|
||||
MAX_REGEX_SOURCE_LENGTH,
|
||||
type RegexSearchRequest,
|
||||
type RegexSearchResponse,
|
||||
} from "../components/editors/codeSearch";
|
||||
import {
|
||||
startRegexSearch,
|
||||
type RegexSearchWorker,
|
||||
} from "../components/editors/regexSearchClient";
|
||||
import {
|
||||
highlightToHtml,
|
||||
MAX_HIGHLIGHT_BYTES,
|
||||
MAX_HIGHLIGHT_LINES,
|
||||
shouldHighlightCode,
|
||||
shouldHighlightSource,
|
||||
} from "../lib/highlight";
|
||||
import { LocaleProvider } from "../lib/i18n";
|
||||
|
||||
let passed = 0;
|
||||
@@ -59,6 +73,191 @@ ok(
|
||||
findCodeMatches("猫 猫咪 猫", "猫", false, true).matches.length === 2,
|
||||
"whole-word matching respects Unicode word characters",
|
||||
);
|
||||
ok(
|
||||
findCodeMatches("a+b aab a+b", "a+b").matches.length === 2,
|
||||
"treats regex metacharacters as literal search text",
|
||||
);
|
||||
|
||||
const regexRequest = (overrides: Partial<RegexSearchRequest> = {}): RegexSearchRequest => ({
|
||||
requestId: 1,
|
||||
source: "foo1 foo22\nbar3",
|
||||
pattern: String.raw`foo\d+`,
|
||||
caseSensitive: false,
|
||||
wholeWord: false,
|
||||
maxMatches: MAX_SEARCH_MATCHES,
|
||||
...overrides,
|
||||
});
|
||||
const regexMatches = findRegexCodeMatches(regexRequest());
|
||||
ok(regexMatches.ok && regexMatches.result.matches.length === 2, "regex mode evaluates explicit patterns");
|
||||
ok(
|
||||
regexMatches.ok && regexMatches.result.matches.map((match) => match.start).join(",") === "0,5",
|
||||
"regex mode returns JavaScript-compatible UTF-16 offsets",
|
||||
);
|
||||
const lineAnchoredRegex = findRegexCodeMatches(regexRequest({
|
||||
source: "foo\nfoo",
|
||||
pattern: "^foo",
|
||||
}));
|
||||
ok(
|
||||
lineAnchoredRegex.ok
|
||||
&& lineAnchoredRegex.result.matches.length === 2
|
||||
&& lineAnchoredRegex.result.matches.map((match) => match.lineIndex).join(",") === "0,1",
|
||||
"regex anchors continue to address individual source lines",
|
||||
);
|
||||
const multilineRegex = findRegexCodeMatches(regexRequest({
|
||||
source: "foo\nbar",
|
||||
pattern: String.raw`foo\nbar`,
|
||||
}));
|
||||
ok(!multilineRegex.ok && multilineRegex.error === "multiline_unsupported", "reports unsupported multiline regex matches");
|
||||
const regexLiteralDifference = findRegexCodeMatches(regexRequest({
|
||||
source: "a+b aab aaab",
|
||||
pattern: "a+b",
|
||||
}));
|
||||
ok(
|
||||
regexLiteralDifference.ok && regexLiteralDifference.result.matches.length === 2,
|
||||
"regex mode keeps metacharacter semantics separate from literal search",
|
||||
);
|
||||
const invalidRegex = findRegexCodeMatches(regexRequest({ pattern: "(" }));
|
||||
ok(!invalidRegex.ok && invalidRegex.error === "invalid_pattern", "reports invalid regular expressions");
|
||||
const zeroLengthRegex = findRegexCodeMatches(regexRequest({ pattern: "^" }));
|
||||
ok(
|
||||
!zeroLengthRegex.ok && zeroLengthRegex.error === "zero_length_unsupported",
|
||||
"rejects zero-length regex matches that cannot be highlighted safely",
|
||||
);
|
||||
const mixedLengthRegex = findRegexCodeMatches(regexRequest({
|
||||
source: "abc",
|
||||
pattern: ".*",
|
||||
}));
|
||||
ok(
|
||||
mixedLengthRegex.ok
|
||||
&& mixedLengthRegex.result.matches.length === 1
|
||||
&& mixedLengthRegex.result.matches[0].start === 0
|
||||
&& mixedLengthRegex.result.matches[0].end === 3,
|
||||
"keeps non-empty regex results when the expression also produces an empty match",
|
||||
);
|
||||
const longRegex = findRegexCodeMatches(regexRequest({ pattern: "x".repeat(MAX_REGEX_PATTERN_LENGTH + 1) }));
|
||||
ok(!longRegex.ok && longRegex.error === "pattern_too_long", "caps regular-expression length");
|
||||
const oversizedRegexSource = findRegexCodeMatches(regexRequest({
|
||||
source: "x".repeat(MAX_REGEX_SOURCE_LENGTH + 1),
|
||||
}));
|
||||
ok(
|
||||
!oversizedRegexSource.ok && oversizedRegexSource.error === "source_too_large",
|
||||
"caps source copied into the regex worker",
|
||||
);
|
||||
const cappedRegex = findRegexCodeMatches(regexRequest({
|
||||
source: "x x x",
|
||||
pattern: "x",
|
||||
maxMatches: 2,
|
||||
}));
|
||||
ok(
|
||||
cappedRegex.ok && cappedRegex.result.matches.length === 2 && cappedRegex.result.truncated,
|
||||
"caps regex result sets before returning them to the UI",
|
||||
);
|
||||
|
||||
class FakeRegexWorker implements RegexSearchWorker {
|
||||
onmessage: ((event: MessageEvent<RegexSearchResponse>) => void) | null = null;
|
||||
onerror: ((event: ErrorEvent) => void) | null = null;
|
||||
posted: RegexSearchRequest[] = [];
|
||||
terminated = false;
|
||||
|
||||
postMessage(request: RegexSearchRequest) {
|
||||
this.posted.push(request);
|
||||
}
|
||||
|
||||
terminate() {
|
||||
this.terminated = true;
|
||||
}
|
||||
|
||||
respond(response: RegexSearchResponse) {
|
||||
this.onmessage?.({ data: response } as MessageEvent<RegexSearchResponse>);
|
||||
}
|
||||
}
|
||||
|
||||
const timedOutWorker = new FakeRegexWorker();
|
||||
let timeoutCallback: (() => void) | null = null;
|
||||
let timeoutResponse: RegexSearchResponse | null = null;
|
||||
startRegexSearch(
|
||||
regexRequest({ requestId: 2 }),
|
||||
{ onResponse: (response) => { timeoutResponse = response; } },
|
||||
{
|
||||
createWorker: async () => timedOutWorker,
|
||||
setTimer: (callback) => {
|
||||
timeoutCallback = callback;
|
||||
return 1;
|
||||
},
|
||||
clearTimer: () => {},
|
||||
},
|
||||
);
|
||||
await Promise.resolve();
|
||||
timeoutCallback?.();
|
||||
ok(timedOutWorker.terminated, "hard timeout terminates a stuck regex worker");
|
||||
ok(
|
||||
timeoutResponse != null && !timeoutResponse.ok && timeoutResponse.error === "timeout",
|
||||
"hard timeout reports a bounded search failure",
|
||||
);
|
||||
|
||||
let creationTimeoutCallback: (() => void) | null = null;
|
||||
let creationTimeoutResponse: RegexSearchResponse | null = null;
|
||||
startRegexSearch(
|
||||
regexRequest({ requestId: 5 }),
|
||||
{ onResponse: (response) => { creationTimeoutResponse = response; } },
|
||||
{
|
||||
createWorker: () => new Promise<RegexSearchWorker>(() => {}),
|
||||
setTimer: (callback) => {
|
||||
creationTimeoutCallback = callback;
|
||||
return 5;
|
||||
},
|
||||
clearTimer: () => {},
|
||||
},
|
||||
);
|
||||
creationTimeoutCallback?.();
|
||||
ok(
|
||||
creationTimeoutResponse != null
|
||||
&& !creationTimeoutResponse.ok
|
||||
&& creationTimeoutResponse.error === "timeout",
|
||||
"hard timeout covers a worker that never finishes initializing",
|
||||
);
|
||||
|
||||
const staleWorker = new FakeRegexWorker();
|
||||
let staleResponseAccepted = false;
|
||||
const cancelStaleSearch = startRegexSearch(
|
||||
regexRequest({ requestId: 3 }),
|
||||
{ onResponse: () => { staleResponseAccepted = true; } },
|
||||
{
|
||||
createWorker: async () => staleWorker,
|
||||
setTimer: () => 2,
|
||||
clearTimer: () => {},
|
||||
},
|
||||
);
|
||||
await Promise.resolve();
|
||||
cancelStaleSearch();
|
||||
staleWorker.respond({
|
||||
requestId: 3,
|
||||
ok: true,
|
||||
result: { matches: [], truncated: false },
|
||||
});
|
||||
ok(staleWorker.terminated, "cancelling a stale request terminates its worker");
|
||||
ok(!staleResponseAccepted, "discarded requests cannot update search results");
|
||||
|
||||
const completedWorker = new FakeRegexWorker();
|
||||
let completedResponse: RegexSearchResponse | null = null;
|
||||
let completedTimerCleared = false;
|
||||
startRegexSearch(
|
||||
regexRequest({ requestId: 4 }),
|
||||
{ onResponse: (response) => { completedResponse = response; } },
|
||||
{
|
||||
createWorker: async () => completedWorker,
|
||||
setTimer: () => 3,
|
||||
clearTimer: () => { completedTimerCleared = true; },
|
||||
},
|
||||
);
|
||||
await Promise.resolve();
|
||||
completedWorker.respond({
|
||||
requestId: 4,
|
||||
ok: true,
|
||||
result: { matches: [], truncated: false },
|
||||
});
|
||||
ok(completedResponse?.requestId === 4, "accepts the current worker response");
|
||||
ok(completedWorker.terminated && completedTimerCleared, "successful searches release worker and timer resources");
|
||||
|
||||
const cappedMatches = findCodeMatches("x".repeat(MAX_SEARCH_MATCHES + 1), "x");
|
||||
ok(cappedMatches.matches.length === MAX_SEARCH_MATCHES, "caps pathological result sets");
|
||||
@@ -66,6 +265,15 @@ ok(cappedMatches.truncated, "reports capped result sets to the viewer");
|
||||
ok(shouldHighlightCode(MAX_HIGHLIGHT_BYTES, MAX_HIGHLIGHT_LINES), "keeps syntax highlighting within its budget");
|
||||
ok(!shouldHighlightCode(MAX_HIGHLIGHT_BYTES + 1, 1), "falls back to plain text above the byte budget");
|
||||
ok(!shouldHighlightCode(1, MAX_HIGHLIGHT_LINES + 1), "falls back to plain text above the line budget");
|
||||
ok(shouldHighlightSource("const value = 1;"), "keeps ordinary chat code within the shared budget");
|
||||
ok(
|
||||
!shouldHighlightSource("é".repeat(Math.floor(MAX_HIGHLIGHT_BYTES / 2) + 1)),
|
||||
"measures the shared byte budget as UTF-8 rather than UTF-16 code units",
|
||||
);
|
||||
ok(
|
||||
!shouldHighlightSource("const value = 1;\n".repeat(MAX_HIGHLIGHT_LINES)),
|
||||
"applies the shared line budget without requiring a workspace preview",
|
||||
);
|
||||
|
||||
const entitySource = "const x = \"<&\";";
|
||||
for (const query of ["<", "&"]) {
|
||||
@@ -147,6 +355,41 @@ ok(
|
||||
container.querySelectorAll(".code-block__wrap")[1].querySelector(".code-search") == null,
|
||||
"does not fan the shortcut out to sibling viewers",
|
||||
);
|
||||
const firstViewer = container.querySelectorAll<HTMLElement>(".code-block__wrap")[0];
|
||||
ok(
|
||||
firstViewer.querySelector(".code-block__copy") == null,
|
||||
"removes the covered floating copy control while search is open",
|
||||
);
|
||||
ok(
|
||||
firstViewer.querySelector('.code-search .code-search__copy[aria-label="Copy"]') != null,
|
||||
"keeps copy available as a visible search-toolbar action",
|
||||
);
|
||||
|
||||
const pendingRequestContainer = document.createElement("div");
|
||||
document.body.appendChild(pendingRequestContainer);
|
||||
const pendingRequestRoot = createRoot(pendingRequestContainer);
|
||||
let pendingRequestConsumed = false;
|
||||
await act(async () => {
|
||||
pendingRequestRoot.render(
|
||||
<LocaleProvider>
|
||||
<LineNumberCode
|
||||
value="pending search"
|
||||
showLineNumbers
|
||||
searchRequestPending
|
||||
onSearchRequestConsumed={() => {
|
||||
pendingRequestConsumed = true;
|
||||
}}
|
||||
/>
|
||||
</LocaleProvider>,
|
||||
);
|
||||
await flush();
|
||||
});
|
||||
ok(
|
||||
pendingRequestContainer.querySelector(".code-search") != null,
|
||||
"consumes a search request that arrived before the editor mounted",
|
||||
);
|
||||
ok(pendingRequestConsumed, "acknowledges a consumed search request");
|
||||
await act(async () => pendingRequestRoot.unmount());
|
||||
|
||||
const searchInput = container.querySelector<HTMLInputElement>(".code-search__input")!;
|
||||
await act(async () => {
|
||||
@@ -186,8 +429,6 @@ await act(async () => {
|
||||
searchInput.dispatchEvent(new dom.window.Event("input", { bubbles: true }));
|
||||
searchInput.dispatchEvent(new dom.window.Event("change", { bubbles: true }));
|
||||
await flush();
|
||||
});
|
||||
await act(async () => {
|
||||
searchInput.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true }));
|
||||
await flush();
|
||||
});
|
||||
@@ -203,6 +444,39 @@ await act(async () => {
|
||||
});
|
||||
ok(caseToggle.getAttribute("aria-pressed") === "true", "exposes the active match-case state");
|
||||
|
||||
await act(async () => {
|
||||
const setter = Object.getOwnPropertyDescriptor(dom.window.HTMLInputElement.prototype, "value")?.set;
|
||||
setter?.call(searchInput, "");
|
||||
searchInput.dispatchEvent(new dom.window.Event("input", { bubbles: true }));
|
||||
searchInput.dispatchEvent(new dom.window.Event("change", { bubbles: true }));
|
||||
await wait(130);
|
||||
});
|
||||
const regexToggle = container.querySelector<HTMLButtonElement>('[aria-label="Use regular expression"]')!;
|
||||
ok(regexToggle.getAttribute("aria-pressed") === "false", "keeps regex mode disabled by default");
|
||||
await act(async () => {
|
||||
regexToggle.click();
|
||||
await flush();
|
||||
});
|
||||
ok(regexToggle.getAttribute("aria-pressed") === "true", "exposes regex mode as an explicit advanced toggle");
|
||||
await act(async () => {
|
||||
const setter = Object.getOwnPropertyDescriptor(dom.window.HTMLInputElement.prototype, "value")?.set;
|
||||
setter?.call(searchInput, "x".repeat(MAX_REGEX_PATTERN_LENGTH + 1));
|
||||
searchInput.dispatchEvent(new dom.window.Event("input", { bubbles: true }));
|
||||
searchInput.dispatchEvent(new dom.window.Event("change", { bubbles: true }));
|
||||
await wait(130);
|
||||
});
|
||||
ok(
|
||||
container.querySelector(".code-search__count--error")?.textContent === "Expression too long",
|
||||
"shows a localized error before unsafe regex work starts",
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
container.querySelector<HTMLButtonElement>('[aria-label="Close search"]')?.click();
|
||||
await flush();
|
||||
});
|
||||
ok(firstViewer.querySelector(".code-search") == null, "closes the viewer-scoped search toolbar");
|
||||
ok(firstViewer.querySelector(".code-block__copy") != null, "restores the floating copy control after search closes");
|
||||
|
||||
await act(async () => root.unmount());
|
||||
|
||||
const largeContainer = document.createElement("div");
|
||||
@@ -243,5 +517,31 @@ ok(defaultContainer.querySelector(".code--lines") == null, "requires an explicit
|
||||
ok(defaultContainer.querySelector(".code-block__copy") != null, "keeps copy available on default code blocks");
|
||||
await act(async () => defaultRoot.unmount());
|
||||
|
||||
const largeChatContainer = document.createElement("div");
|
||||
document.body.appendChild(largeChatContainer);
|
||||
const largeChatRoot = createRoot(largeChatContainer);
|
||||
const largeChatValue = "const value = 1;\n".repeat(MAX_HIGHLIGHT_LINES);
|
||||
await act(async () => {
|
||||
largeChatRoot.render(
|
||||
<LocaleProvider>
|
||||
<CodeViewer value={largeChatValue} language="typescript" />
|
||||
</LocaleProvider>,
|
||||
);
|
||||
});
|
||||
await waitForSelector(largeChatContainer, 'pre[data-highlight-mode="plain"]');
|
||||
ok(
|
||||
largeChatContainer.querySelector('pre[data-highlight-mode="plain"]') != null,
|
||||
"ordinary chat blocks reuse the shared syntax budget",
|
||||
);
|
||||
ok(
|
||||
largeChatContainer.querySelector(".hljs-keyword") == null,
|
||||
"oversized chat blocks skip syntax token generation",
|
||||
);
|
||||
ok(
|
||||
largeChatContainer.querySelector("code")?.textContent === largeChatValue,
|
||||
"oversized chat blocks preserve their plain-text content",
|
||||
);
|
||||
await act(async () => largeChatRoot.unmount());
|
||||
|
||||
console.log(`\n${passed} passed, ${failed} failed`);
|
||||
if (failed > 0) process.exit(1);
|
||||
@@ -7,6 +7,9 @@ import { JSDOM } from "jsdom";
|
||||
import React from "react";
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import { splitStableMarkdownSections, streamingMarkdownCommitInterval, useRenderedMarkdownText } from "../components/Markdown";
|
||||
import MermaidDiagram from "../components/MermaidDiagram";
|
||||
import {
|
||||
__setMermaidPanZoomFactoryForTest,
|
||||
@@ -23,6 +26,8 @@ import { REMOTE_MARKDOWN_IMAGE_PATH } from "../lib/markdownImage";
|
||||
const testDir = dirname(fileURLToPath(import.meta.url));
|
||||
const styles = readFileSync(resolve(testDir, "../styles.css"), "utf8");
|
||||
const markdownRendererSource = readFileSync(resolve(testDir, "../components/MarkdownRenderer.tsx"), "utf8");
|
||||
const markdownSource = readFileSync(resolve(testDir, "../components/Markdown.tsx"), "utf8");
|
||||
const messageSource = readFileSync(resolve(testDir, "../components/Message.tsx"), "utf8");
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
@@ -133,6 +138,19 @@ function parseSvg(svg: string): Document {
|
||||
console.log("\nmermaid rendering");
|
||||
|
||||
{
|
||||
ok(markdownSource.includes("requestAnimationFrame"), "streaming markdown commits on an animation frame");
|
||||
ok(markdownSource.includes("streamingMarkdownCommitInterval"), "streaming markdown applies an adaptive parse budget");
|
||||
ok(markdownSource.includes('className="md md--stream-tail"'), "streaming markdown exposes an immediate lightweight tail");
|
||||
ok(markdownSource.includes("requestIdleCallback"), "large Markdown finalization waits for browser idle time");
|
||||
ok(markdownSource.includes("reasonix:markdown-finalize"), "large Markdown finalization emits a performance measure");
|
||||
ok(markdownSource.includes("splitStableMarkdownSections"), "large Markdown retains completed top-level sections");
|
||||
ok(markdownRendererSource.includes("bare = false"), "stable Markdown sections share one semantic container");
|
||||
ok(
|
||||
styles.includes(".md > :where(") && styles.includes("contain-intrinsic-size: auto 72px"),
|
||||
"Markdown blocks skip offscreen layout while preserving learned intrinsic sizes",
|
||||
);
|
||||
ok(markdownSource.includes("streaming?: boolean"), "Markdown exposes an explicit streaming state");
|
||||
ok(messageSource.includes("streaming={item.streaming}"), "assistant messages pass streaming state to Markdown");
|
||||
ok(
|
||||
markdownRendererSource.includes('lazy(() => import("./MermaidDiagram"))'),
|
||||
"MarkdownRenderer lazy-loads the Mermaid renderer",
|
||||
@@ -143,6 +161,152 @@ console.log("\nmermaid rendering");
|
||||
);
|
||||
}
|
||||
|
||||
{
|
||||
const section = (index: number) => `# Section ${index}\n\n${`paragraph-${index} `.repeat(700)}\n\n`;
|
||||
const document = Array.from({ length: 8 }, (_, index) => section(index)).join("");
|
||||
const chunks = splitStableMarkdownSections(document);
|
||||
ok(chunks.length >= 4, "large headed Markdown is divided into bounded stable chunks");
|
||||
eq(chunks.join(""), document, "stable Markdown chunking preserves every source byte");
|
||||
|
||||
const appended = splitStableMarkdownSections(document + section(8));
|
||||
eq(appended.slice(0, chunks.length).join(""), document, "appending a section leaves all completed chunks unchanged");
|
||||
|
||||
const fenced = `${section(0)}\`\`\`text\n# not a heading\n${"fenced content\n".repeat(900)}\`\`\`\n\n${section(1)}`;
|
||||
const fencedChunks = splitStableMarkdownSections(fenced);
|
||||
eq(fencedChunks.join(""), fenced, "fenced Markdown chunking preserves source bytes");
|
||||
ok(!fencedChunks.some((chunk) => chunk.startsWith("# not a heading")), "headings inside fenced code never become section boundaries");
|
||||
|
||||
const referenced = `${document}\n[shared]: https://example.com\n\nUse [shared].\n`;
|
||||
eq(splitStableMarkdownSections(referenced).length, 1, "cross-section references use one semantic Markdown renderer");
|
||||
|
||||
const nestedFence = `1. item\n\n ${"long paragraph ".repeat(1_000)}\n\n \`\`\`text\n code\n \`\`\`\n\n2. next\n`;
|
||||
const nestedChunks = splitStableMarkdownSections(nestedFence);
|
||||
eq(nestedChunks.length, 1, "list containers stay in one semantic Markdown renderer");
|
||||
const renderMarkdown = (source: string) => renderToStaticMarkup(<ReactMarkdown>{source}</ReactMarkdown>);
|
||||
eq(
|
||||
nestedChunks.map(renderMarkdown).join(""),
|
||||
renderMarkdown(nestedFence),
|
||||
"stable Markdown optimization preserves nested list and fence DOM semantics",
|
||||
);
|
||||
}
|
||||
|
||||
{
|
||||
eq(streamingMarkdownCommitInterval(1_000), 50, "short streaming Markdown uses the 50ms parse budget");
|
||||
eq(streamingMarkdownCommitInterval(8_000), 150, "medium streaming Markdown uses the 150ms parse budget");
|
||||
eq(streamingMarkdownCommitInterval(32_000), 300, "long streaming Markdown uses the 300ms parse budget");
|
||||
}
|
||||
|
||||
{
|
||||
const dom = installDom();
|
||||
const rootEl = document.getElementById("root");
|
||||
if (!rootEl) throw new Error("missing root");
|
||||
let nextFrameID = 1;
|
||||
let pendingFrame: FrameRequestCallback | undefined;
|
||||
globalThis.requestAnimationFrame = ((callback: FrameRequestCallback) => {
|
||||
pendingFrame = callback;
|
||||
return nextFrameID++;
|
||||
}) as typeof requestAnimationFrame;
|
||||
globalThis.cancelAnimationFrame = (() => {
|
||||
pendingFrame = undefined;
|
||||
}) as typeof cancelAnimationFrame;
|
||||
|
||||
const root = createRoot(rootEl);
|
||||
function MarkdownTextProbe({ text, streaming }: { text: string; streaming: boolean }) {
|
||||
return <div>{useRenderedMarkdownText(text, streaming)}</div>;
|
||||
}
|
||||
await act(async () => {
|
||||
root.render(<MarkdownTextProbe text="start" streaming />);
|
||||
await flushTimers();
|
||||
});
|
||||
eq(rootEl.textContent, "start", "streaming Markdown starts from the current text");
|
||||
|
||||
await act(async () => {
|
||||
root.render(<MarkdownTextProbe text="start middle" streaming />);
|
||||
await flushTimers();
|
||||
root.render(<MarkdownTextProbe text="start middle end" streaming />);
|
||||
await new Promise((resolve) => setTimeout(resolve, 60));
|
||||
});
|
||||
eq(rootEl.textContent, "start", "streaming Markdown holds intermediate text until the animation frame");
|
||||
const frame = pendingFrame;
|
||||
await act(async () => {
|
||||
frame?.(performance.now());
|
||||
await flushTimers();
|
||||
});
|
||||
eq(rootEl.textContent, "start middle end", "one animation frame commits the latest streamed text");
|
||||
|
||||
pendingFrame = undefined;
|
||||
await act(async () => {
|
||||
root.render(<MarkdownTextProbe text="start middle end later" streaming />);
|
||||
await flushTimers();
|
||||
});
|
||||
eq(pendingFrame, undefined, "streaming Markdown waits for a fresh budget after the previous DOM commit");
|
||||
|
||||
await act(async () => {
|
||||
root.render(<MarkdownTextProbe text="complete" streaming={false} />);
|
||||
});
|
||||
eq(rootEl.textContent, "complete", "short stream finalization still commits immediately");
|
||||
|
||||
await act(async () => root.unmount());
|
||||
dom.window.close();
|
||||
}
|
||||
|
||||
{
|
||||
const dom = installDom();
|
||||
const rootEl = document.getElementById("root");
|
||||
if (!rootEl) throw new Error("missing root");
|
||||
let pendingIdle: (() => void) | undefined;
|
||||
Object.defineProperty(dom.window, "requestIdleCallback", {
|
||||
configurable: true,
|
||||
value: (callback: () => void) => {
|
||||
pendingIdle = callback;
|
||||
return 1;
|
||||
},
|
||||
});
|
||||
Object.defineProperty(dom.window, "cancelIdleCallback", {
|
||||
configurable: true,
|
||||
value: () => {
|
||||
pendingIdle = undefined;
|
||||
},
|
||||
});
|
||||
Object.defineProperty(dom.window, "setTimeout", {
|
||||
configurable: true,
|
||||
value: (callback: TimerHandler) => {
|
||||
if (typeof callback === "function") callback();
|
||||
return 1;
|
||||
},
|
||||
});
|
||||
Object.defineProperty(dom.window, "clearTimeout", {
|
||||
configurable: true,
|
||||
value: () => undefined,
|
||||
});
|
||||
|
||||
const root = createRoot(rootEl);
|
||||
const streamed = "a".repeat(8_100);
|
||||
const finalText = `${streamed} final`;
|
||||
function MarkdownTextProbe({ text, streaming }: { text: string; streaming: boolean }) {
|
||||
return <div>{useRenderedMarkdownText(text, streaming)}</div>;
|
||||
}
|
||||
await act(async () => {
|
||||
root.render(<MarkdownTextProbe text={streamed} streaming />);
|
||||
await flushTimers();
|
||||
});
|
||||
await act(async () => {
|
||||
root.render(<MarkdownTextProbe text={finalText} streaming={false} />);
|
||||
await flushTimers();
|
||||
});
|
||||
eq(rootEl.textContent, streamed, "large Markdown keeps its committed content while finalization waits for idle");
|
||||
ok(Boolean(pendingIdle), "large Markdown schedules one idle finalization callback");
|
||||
|
||||
await act(async () => {
|
||||
pendingIdle?.();
|
||||
await flushTimers();
|
||||
});
|
||||
eq(rootEl.textContent, finalText, "idle finalization commits the complete large Markdown text");
|
||||
|
||||
await act(async () => root.unmount());
|
||||
dom.window.close();
|
||||
}
|
||||
|
||||
{
|
||||
const dom = installDom();
|
||||
Object.defineProperty(dom.window, "runtime", { configurable: true, value: {} });
|
||||
|
||||
@@ -0,0 +1,316 @@
|
||||
// Run: tsx src/__tests__/model-switcher-refresh.test.tsx
|
||||
|
||||
import { JSDOM } from "jsdom";
|
||||
import React, { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { ModelSwitcher } from "../components/ModelSwitcher";
|
||||
import { LocaleProvider } from "../lib/i18n";
|
||||
import type { ModelInfo } from "../lib/types";
|
||||
|
||||
class TestResizeObserver {
|
||||
observe() {}
|
||||
disconnect() {}
|
||||
}
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
const promise = new Promise<T>((done) => { resolve = done; });
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
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.Event = dom.window.Event;
|
||||
globalThis.MouseEvent = dom.window.MouseEvent;
|
||||
globalThis.HTMLElement = dom.window.HTMLElement;
|
||||
globalThis.ResizeObserver = TestResizeObserver as unknown as typeof ResizeObserver;
|
||||
globalThis.requestAnimationFrame = dom.window.requestAnimationFrame.bind(dom.window);
|
||||
globalThis.cancelAnimationFrame = dom.window.cancelAnimationFrame.bind(dom.window);
|
||||
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,
|
||||
}),
|
||||
});
|
||||
|
||||
const stale = deferred<ModelInfo[]>();
|
||||
const fresh = deferred<ModelInfo[]>();
|
||||
let calls = 0;
|
||||
const picked: string[] = [];
|
||||
const pickGates: Array<ReturnType<typeof deferred<boolean>>> = [];
|
||||
let catalogLoader: (() => Promise<ModelInfo[]>) | undefined;
|
||||
let currentCatalog: ModelInfo[] = [
|
||||
{ ref: "glm-cn/glm-5.2", provider: "glm-cn", model: "glm-5.2", current: true },
|
||||
];
|
||||
(window as unknown as { go: { main: { App: Record<string, unknown> } } }).go = {
|
||||
main: {
|
||||
App: {
|
||||
ModelsForTab: async () => {
|
||||
calls += 1;
|
||||
if (calls === 1) return stale.promise;
|
||||
if (calls === 2) return fresh.promise;
|
||||
if (catalogLoader) return catalogLoader();
|
||||
return currentCatalog;
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const root = createRoot(document.getElementById("root")!);
|
||||
const renderSwitcher = (label: string, tabId: string) => (
|
||||
<LocaleProvider>
|
||||
<ModelSwitcher
|
||||
label={label}
|
||||
tabId={tabId}
|
||||
onPick={(ref) => {
|
||||
picked.push(ref);
|
||||
return pickGates.shift()?.promise ?? Promise.resolve(true);
|
||||
}}
|
||||
/>
|
||||
</LocaleProvider>
|
||||
);
|
||||
await act(async () => {
|
||||
root.render(renderSwitcher("deepseek-v4-flash", "tab-a"));
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
window.dispatchEvent(new Event("reasonix:model-catalog-changed"));
|
||||
fresh.resolve([{ ref: "glm-cn/glm-5.2", provider: "glm-cn", model: "glm-5.2", current: true }]);
|
||||
await fresh.promise;
|
||||
});
|
||||
await act(async () => {
|
||||
stale.resolve([{ ref: "deepseek/deepseek-v4-flash", provider: "deepseek", model: "deepseek-v4-flash", current: true }]);
|
||||
await stale.promise;
|
||||
});
|
||||
await act(async () => {
|
||||
(document.querySelector(".modelsw__trigger") as HTMLButtonElement).click();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
});
|
||||
|
||||
const options = Array.from(document.querySelectorAll<HTMLElement>("[role='option']")).map((item) => item.textContent?.trim());
|
||||
if (JSON.stringify(options) !== JSON.stringify(["glm-5.2"])) {
|
||||
throw new Error(`model catalog did not keep the fresh result: ${JSON.stringify(options)}`);
|
||||
}
|
||||
if (calls < 3) throw new Error(`expected mount, settings refresh, and open loads; got ${calls}`);
|
||||
|
||||
await act(async () => {
|
||||
(document.querySelector("[role='option'][aria-selected='true']") as HTMLButtonElement).click();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
});
|
||||
if (picked.length !== 0) throw new Error(`current model should be a no-op, picked ${JSON.stringify(picked)}`);
|
||||
if ((document.querySelector(".modelsw__trigger") as HTMLButtonElement).getAttribute("aria-expanded") !== "false") {
|
||||
throw new Error("current-model no-op did not close the menu");
|
||||
}
|
||||
|
||||
currentCatalog = [
|
||||
{ ref: "glm-cn/glm-5.2", provider: "glm-cn", model: "glm-5.2", current: true },
|
||||
{ ref: "deepseek/deepseek-v4-flash", provider: "deepseek", model: "deepseek-v4-flash", current: false },
|
||||
];
|
||||
const pendingPickGate = deferred<boolean>();
|
||||
pickGates.push(pendingPickGate, pendingPickGate);
|
||||
await act(async () => {
|
||||
(document.querySelector(".modelsw__trigger") as HTMLButtonElement).click();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
});
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
});
|
||||
const next = Array.from(document.querySelectorAll<HTMLButtonElement>("[role='option']"))
|
||||
.find((option) => option.textContent?.includes("deepseek-v4-flash"));
|
||||
if (!next) throw new Error("pending-switch model option did not load");
|
||||
await act(async () => {
|
||||
next.click();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
});
|
||||
if (picked[0] !== "deepseek/deepseek-v4-flash") {
|
||||
throw new Error(`non-current model was not selected: ${JSON.stringify(picked)}`);
|
||||
}
|
||||
|
||||
// The backend catalog may still identify the outgoing GLM model as current
|
||||
// while the first switch is pending. Selecting it now must enqueue a rollback.
|
||||
await act(async () => {
|
||||
(document.querySelector(".modelsw__trigger") as HTMLButtonElement).click();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
});
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
});
|
||||
const rollback = document.querySelector<HTMLButtonElement>("[role='option'][aria-selected='true']");
|
||||
if (!rollback) throw new Error("pending-switch rollback option did not load");
|
||||
await act(async () => {
|
||||
rollback.click();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
});
|
||||
if (picked[1] !== "glm-cn/glm-5.2") {
|
||||
throw new Error(`pending current-model rollback was swallowed: ${JSON.stringify(picked)}`);
|
||||
}
|
||||
|
||||
// Pending work belongs to tab A. Reusing the mounted switcher for tab B must
|
||||
// not turn B's settled current model into another rollback request.
|
||||
currentCatalog = [
|
||||
{ ref: "provider-b/model-b", provider: "provider-b", model: "model-b", current: true },
|
||||
];
|
||||
await act(async () => {
|
||||
root.render(renderSwitcher("model-b", "tab-b"));
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
});
|
||||
await act(async () => {
|
||||
(document.querySelector(".modelsw__trigger") as HTMLButtonElement).click();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
});
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
});
|
||||
const tabBCurrent = document.querySelector<HTMLButtonElement>("[role='option'][aria-selected='true']");
|
||||
if (!tabBCurrent) throw new Error("tab B current model did not load");
|
||||
await act(async () => {
|
||||
tabBCurrent.click();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
});
|
||||
if (picked.length !== 2) {
|
||||
throw new Error(`tab A pending picks leaked into tab B: ${JSON.stringify(picked)}`);
|
||||
}
|
||||
|
||||
await act(async () => {
|
||||
pendingPickGate.resolve(true);
|
||||
await pendingPickGate.promise;
|
||||
});
|
||||
|
||||
// A failed latest pick must immediately roll back its optimistic selection,
|
||||
// then reconcile from the authoritative backend catalog. Retrying the same
|
||||
// target before that catalog request completes must remain possible.
|
||||
currentCatalog = [
|
||||
{ ref: "provider-a/model-a", provider: "provider-a", model: "model-a", current: true },
|
||||
{ ref: "provider-b/model-b", provider: "provider-b", model: "model-b", current: false },
|
||||
];
|
||||
await act(async () => {
|
||||
root.render(renderSwitcher("model-a", "tab-c"));
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
});
|
||||
await act(async () => {
|
||||
(document.querySelector(".modelsw__trigger") as HTMLButtonElement).click();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
});
|
||||
const failedTarget = Array.from(document.querySelectorAll<HTMLButtonElement>("[role='option']"))
|
||||
.find((option) => option.textContent?.includes("model-b"));
|
||||
if (!failedTarget) throw new Error("failed-switch target did not load");
|
||||
const failedPickGate = deferred<boolean>();
|
||||
pickGates.push(failedPickGate);
|
||||
const catalogReloadGate = deferred<ModelInfo[]>();
|
||||
catalogLoader = () => catalogReloadGate.promise;
|
||||
await act(async () => {
|
||||
failedTarget.click();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
});
|
||||
failedPickGate.resolve(false);
|
||||
await act(async () => {
|
||||
await failedPickGate.promise;
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
});
|
||||
await act(async () => {
|
||||
(document.querySelector(".modelsw__trigger") as HTMLButtonElement).click();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
});
|
||||
const rolledBackCurrent = document.querySelector<HTMLButtonElement>("[role='option'][aria-selected='true']");
|
||||
if (!rolledBackCurrent?.textContent?.includes("model-a")) {
|
||||
throw new Error(`failed pick did not immediately roll back: ${rolledBackCurrent?.textContent ?? "missing"}`);
|
||||
}
|
||||
const retryTarget = Array.from(document.querySelectorAll<HTMLButtonElement>("[role='option']"))
|
||||
.find((option) => option.textContent?.includes("model-b"));
|
||||
if (!retryTarget) throw new Error("failed-switch target was not available for immediate retry");
|
||||
const retryPickGate = deferred<boolean>();
|
||||
pickGates.push(retryPickGate);
|
||||
await act(async () => {
|
||||
retryTarget.click();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
});
|
||||
if (picked.filter((ref) => ref === "provider-b/model-b").length !== 2) {
|
||||
throw new Error(`failed target could not be retried: ${JSON.stringify(picked)}`);
|
||||
}
|
||||
|
||||
// The failed attempt's catalog request describes the pre-retry backend state.
|
||||
// Its late completion must not overwrite the newer optimistic retry.
|
||||
catalogReloadGate.resolve(currentCatalog);
|
||||
await act(async () => {
|
||||
await catalogReloadGate.promise;
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
});
|
||||
const retryTriggerLabel = (document.querySelector(".modelsw__trigger") as HTMLButtonElement)
|
||||
.getAttribute("aria-label") ?? "";
|
||||
if (!retryTriggerLabel.includes("provider-b")) {
|
||||
throw new Error(`stale failure reconciliation overwrote the retry: ${retryTriggerLabel}`);
|
||||
}
|
||||
retryPickGate.resolve(true);
|
||||
await act(async () => {
|
||||
await retryPickGate.promise;
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
});
|
||||
catalogLoader = undefined;
|
||||
|
||||
// An older superseded failure also must not roll back a newer pending pick.
|
||||
currentCatalog = [
|
||||
{ ref: "provider-a/model-a", provider: "provider-a", model: "model-a", current: true },
|
||||
{ ref: "provider-b/model-b", provider: "provider-b", model: "model-b", current: false },
|
||||
{ ref: "provider-c/model-c", provider: "provider-c", model: "model-c", current: false },
|
||||
];
|
||||
await act(async () => {
|
||||
root.render(renderSwitcher("model-a", "tab-d"));
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
});
|
||||
await act(async () => {
|
||||
(document.querySelector(".modelsw__trigger") as HTMLButtonElement).click();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
});
|
||||
const olderTarget = Array.from(document.querySelectorAll<HTMLButtonElement>("[role='option']"))
|
||||
.find((option) => option.textContent?.includes("model-b"));
|
||||
if (!olderTarget) throw new Error("older switch target did not load");
|
||||
const olderPickGate = deferred<boolean>();
|
||||
pickGates.push(olderPickGate);
|
||||
await act(async () => {
|
||||
olderTarget.click();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
});
|
||||
await act(async () => {
|
||||
(document.querySelector(".modelsw__trigger") as HTMLButtonElement).click();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
});
|
||||
const newerTarget = Array.from(document.querySelectorAll<HTMLButtonElement>("[role='option']"))
|
||||
.find((option) => option.textContent?.includes("model-c"));
|
||||
if (!newerTarget) throw new Error("newer switch target did not load");
|
||||
const newerPickGate = deferred<boolean>();
|
||||
pickGates.push(newerPickGate);
|
||||
await act(async () => {
|
||||
newerTarget.click();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
});
|
||||
olderPickGate.resolve(false);
|
||||
await act(async () => {
|
||||
await olderPickGate.promise;
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
});
|
||||
const newerTriggerLabel = (document.querySelector(".modelsw__trigger") as HTMLButtonElement)
|
||||
.getAttribute("aria-label") ?? "";
|
||||
if (!newerTriggerLabel.includes("provider-c")) {
|
||||
throw new Error(`superseded failure rolled back the newer pick: ${newerTriggerLabel}`);
|
||||
}
|
||||
newerPickGate.resolve(true);
|
||||
await act(async () => {
|
||||
await newerPickGate.promise;
|
||||
});
|
||||
|
||||
await act(async () => root.unmount());
|
||||
console.log("model switcher refresh: PASS");
|
||||
@@ -269,6 +269,146 @@ await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
|
||||
// Reusing a blank tab must invalidate the old hydration request. The backend
|
||||
// may return the same tab id, so the request sequence (not the tab id) is the
|
||||
// session boundary that prevents orphaned tool cards from coming back.
|
||||
const reusedOldHistory = deferred<{
|
||||
messages: HistoryMessage[];
|
||||
startTurn: number;
|
||||
endTurn: number;
|
||||
totalTurns: number;
|
||||
hasOlder: boolean;
|
||||
}>();
|
||||
const reusedHistoryCalls: string[] = [];
|
||||
const reusedTab = tabMeta({ id: "tab-reused", sessionPath: "/sessions/old.jsonl" });
|
||||
const reusedTabPage = {
|
||||
messages: [
|
||||
{ role: "assistant", content: "", toolCalls: [{ id: "old-call", name: "bash", arguments: "pwd" }] },
|
||||
{ role: "tool", toolCallId: "old-call", toolName: "bash", content: "/old" },
|
||||
] as HistoryMessage[],
|
||||
startTurn: 0,
|
||||
endTurn: 0,
|
||||
totalTurns: 0,
|
||||
hasOlder: false,
|
||||
};
|
||||
const reusedEmptyPage = { messages: [], startTurn: 0, endTurn: 0, totalTurns: 0, hasOlder: false };
|
||||
window.go.main.App = {
|
||||
ListTabs: async () => [reusedTab],
|
||||
MetaForTab: async () => meta({ sessionPath: "/sessions/new.jsonl" }),
|
||||
ContextUsageForTab: async () => context,
|
||||
EffortForTab: async () => effort,
|
||||
BalanceForTab: async () => balance,
|
||||
JobsForTab: async () => jobs,
|
||||
CheckpointsForTab: async () => checkpoints,
|
||||
HistoryPageForTab: async () => {
|
||||
reusedHistoryCalls.push("history");
|
||||
return reusedHistoryCalls.length === 1 ? reusedOldHistory.promise : reusedEmptyPage;
|
||||
},
|
||||
HistoryCheckpointTurnsForTab: async () => [],
|
||||
ReplayPendingPrompts: async () => {},
|
||||
EnsureBlankTab: async () => ({ ...reusedTab, sessionPath: "/sessions/new.jsonl", active: true }),
|
||||
} as Partial<AppBindings> as AppBindings;
|
||||
|
||||
controller = undefined;
|
||||
const reuseRoot = createRoot(rootEl);
|
||||
await act(async () => {
|
||||
reuseRoot.render(<Probe />);
|
||||
await flushPromises();
|
||||
});
|
||||
await waitFor("reused tab startup history", () => reusedHistoryCalls.length === 1);
|
||||
|
||||
await act(async () => {
|
||||
await controller?.ensureBlankTab("project", "/repo");
|
||||
await flushPromises();
|
||||
});
|
||||
eq(reusedHistoryCalls.length, 2, "reusing a blank tab forces a fresh history request");
|
||||
eq(controller?.state.items.some((item) => item.kind === "tool" && item.id === "old-call"), false, "fresh blank-tab hydration has no old tool card");
|
||||
|
||||
await act(async () => {
|
||||
reusedOldHistory.resolve(reusedTabPage);
|
||||
await reusedOldHistory.promise;
|
||||
await flushPromises();
|
||||
});
|
||||
eq(controller?.state.items.some((item) => item.kind === "tool" && item.id === "old-call"), false, "late old-session history cannot restore an orphaned tool card");
|
||||
|
||||
await act(async () => {
|
||||
reuseRoot.unmount();
|
||||
});
|
||||
|
||||
// A tab-bar click can overtake EnsureBlankTab while its backend call is still
|
||||
// in flight. Its intent must invalidate the older completion immediately, and
|
||||
// the stale backend activation must be repaired after it eventually returns.
|
||||
const queuedBlank = deferred<TabMeta>();
|
||||
const raceTabA = tabMeta({ id: "race-a", active: true, sessionPath: "/sessions/race-a.jsonl" });
|
||||
const raceTabB = tabMeta({ id: "race-b", active: false, sessionPath: "/sessions/race-b.jsonl" });
|
||||
const raceBlank = tabMeta({ id: "race-blank", active: false, sessionPath: "/sessions/race-blank.jsonl" });
|
||||
let raceBackendActiveId = raceTabA.id;
|
||||
const raceHistoryCalls: string[] = [];
|
||||
const raceSetActiveCalls: string[] = [];
|
||||
window.go.main.App = {
|
||||
ListTabs: async () => [raceTabA, raceTabB, raceBlank].map((tab) => ({ ...tab, active: tab.id === raceBackendActiveId })),
|
||||
MetaForTab: async (tabID: string) => meta({ sessionPath: `/sessions/${tabID}.jsonl` }),
|
||||
ContextUsageForTab: async () => context,
|
||||
EffortForTab: async () => effort,
|
||||
BalanceForTab: async () => balance,
|
||||
JobsForTab: async () => jobs,
|
||||
CheckpointsForTab: async () => checkpoints,
|
||||
HistoryPageForTab: async (tabID: string) => {
|
||||
raceHistoryCalls.push(tabID);
|
||||
return reusedEmptyPage;
|
||||
},
|
||||
HistoryCheckpointTurnsForTab: async () => [],
|
||||
ReplayPendingPrompts: async () => {},
|
||||
EnsureBlankTab: async () => {
|
||||
const tab = await queuedBlank.promise;
|
||||
raceBackendActiveId = tab.id;
|
||||
return tab;
|
||||
},
|
||||
SetActiveTab: async (tabID: string) => {
|
||||
raceSetActiveCalls.push(tabID);
|
||||
raceBackendActiveId = tabID;
|
||||
},
|
||||
} as Partial<AppBindings> as AppBindings;
|
||||
|
||||
controller = undefined;
|
||||
const queuedRaceRoot = createRoot(rootEl);
|
||||
await act(async () => {
|
||||
queuedRaceRoot.render(<Probe />);
|
||||
await flushPromises();
|
||||
});
|
||||
await waitFor("queued blank race startup", () => controller?.activeTabId === raceTabA.id);
|
||||
|
||||
let pendingBlank: Promise<TabMeta> | undefined;
|
||||
await act(async () => {
|
||||
pendingBlank = controller?.ensureBlankTab("project", "/repo");
|
||||
await flushPromises();
|
||||
});
|
||||
const tabClickIntent = controller?.noteNavigationIntent();
|
||||
if (tabClickIntent === undefined) throw new Error("missing queued tab intent");
|
||||
|
||||
let pendingTabSwitch: Promise<TabMeta[] | undefined> | undefined;
|
||||
await act(async () => {
|
||||
pendingTabSwitch = controller?.switchTab(raceTabB.id, raceTabB, tabClickIntent);
|
||||
await pendingTabSwitch;
|
||||
await flushPromises();
|
||||
});
|
||||
eq(controller?.activeTabId, raceTabB.id, "queued tab click becomes visible before the older blank completion");
|
||||
eq(raceBackendActiveId, raceTabB.id, "queued tab click becomes backend-active before the older blank completion");
|
||||
|
||||
await act(async () => {
|
||||
queuedBlank.resolve({ ...raceBlank, active: true });
|
||||
await pendingBlank;
|
||||
await flushPromises();
|
||||
});
|
||||
eq(controller?.activeTabId, raceTabB.id, "late blank completion cannot replace the newer visible tab");
|
||||
eq(raceHistoryCalls.includes(raceBlank.id), false, "stale blank completion does not hydrate the abandoned tab");
|
||||
eq(raceBackendActiveId, raceTabB.id, "late blank completion reasserts the newer backend-active tab");
|
||||
eq(raceSetActiveCalls.join(","), `${raceTabB.id},${raceTabB.id}`, "stale blank completion repairs backend focus exactly once");
|
||||
|
||||
await act(async () => {
|
||||
queuedRaceRoot.unmount();
|
||||
});
|
||||
|
||||
const guardedStartupTabs = deferred<TabMeta[]>();
|
||||
const staleProjectA = "/repo/project-a";
|
||||
const targetProjectB = "/repo/project-b";
|
||||
|
||||
@@ -391,6 +391,9 @@ const rebuiltHandlers: Array<(tabId?: string) => void> = [];
|
||||
let holdNextListTabs: Promise<void> | undefined;
|
||||
let modeDrain: ReturnType<typeof deferred<string[]>> | undefined;
|
||||
let toolApprovalModeDrain: ReturnType<typeof deferred<string[]>> | undefined;
|
||||
let composerProfileDrain: ReturnType<typeof deferred<string[]>> | undefined;
|
||||
let composerProfileCalls = 0;
|
||||
let rejectNextComposerProfile = false;
|
||||
|
||||
window.runtime = {
|
||||
EventsOn: (name: string, cb: (payload: unknown) => void) => {
|
||||
@@ -424,6 +427,14 @@ window.go = {
|
||||
SetActiveTab: async () => {},
|
||||
SetModeForTab: async () => modeDrain?.promise ?? [],
|
||||
SetToolApprovalModeForTab: async () => toolApprovalModeDrain?.promise ?? [],
|
||||
SetComposerProfileForTab: async () => {
|
||||
composerProfileCalls += 1;
|
||||
if (rejectNextComposerProfile) {
|
||||
rejectNextComposerProfile = false;
|
||||
throw new Error("profile transaction failed");
|
||||
}
|
||||
return composerProfileDrain?.promise ?? [];
|
||||
},
|
||||
} as Partial<AppBindings> as AppBindings,
|
||||
},
|
||||
};
|
||||
@@ -581,6 +592,100 @@ eq(controller?.state.running, false, "fresh idle snapshot releases the blocked s
|
||||
await flushPromises();
|
||||
});
|
||||
eq(controller?.state.approval?.subject, "new tool-approval prompt", "a late SetToolApprovalModeForTab drain cannot dismiss a new same-id prompt");
|
||||
|
||||
const profileApprovalID = "profile-drain-1";
|
||||
await act(async () => {
|
||||
for (const handler of eventHandlers) {
|
||||
handler({ kind: "approval_request", tabId: "tab-a", approval: { id: profileApprovalID, tool: "bash", subject: "old composer-profile prompt" } } as WireEvent);
|
||||
}
|
||||
await flushPromises();
|
||||
});
|
||||
composerProfileDrain = deferred<string[]>();
|
||||
const profileCallsBefore = composerProfileCalls;
|
||||
let profilePromise: Promise<boolean> | undefined;
|
||||
await act(async () => {
|
||||
profilePromise = controller?.setComposerProfileForTab("tab-a", "plan", "auto", "");
|
||||
await flushPromises();
|
||||
});
|
||||
eq(composerProfileCalls, profileCallsBefore + 1, "one composer-profile sync uses one atomic backend call");
|
||||
await act(async () => {
|
||||
for (const handler of rebuiltHandlers) handler("tab-a");
|
||||
for (const handler of eventHandlers) {
|
||||
handler({ kind: "approval_request", tabId: "tab-a", approval: { id: profileApprovalID, tool: "bash", subject: "new composer-profile prompt" } } as WireEvent);
|
||||
}
|
||||
await flushPromises();
|
||||
});
|
||||
await act(async () => {
|
||||
composerProfileDrain?.resolve([profileApprovalID]);
|
||||
await profilePromise;
|
||||
await flushPromises();
|
||||
});
|
||||
eq(controller?.state.approval?.subject, "new composer-profile prompt", "a late atomic profile drain cannot dismiss a new same-id prompt");
|
||||
|
||||
composerProfileDrain = undefined;
|
||||
const rebuiltProfileCallsBefore = composerProfileCalls;
|
||||
await act(async () => {
|
||||
await controller?.setComposerProfileForTab("tab-a", "plan", "auto", "");
|
||||
await controller?.setComposerProfileForTab("tab-a", "plan", "auto", "");
|
||||
await flushPromises();
|
||||
});
|
||||
eq(composerProfileCalls, rebuiltProfileCallsBefore + 1, "same profile applies once per rebuilt controller generation");
|
||||
|
||||
const concurrentProfileDrain = deferred<string[]>();
|
||||
composerProfileDrain = concurrentProfileDrain;
|
||||
const changedProfileCallsBefore = composerProfileCalls;
|
||||
let concurrentProfileA: Promise<boolean> | undefined;
|
||||
let concurrentProfileB: Promise<boolean> | undefined;
|
||||
await act(async () => {
|
||||
concurrentProfileA = controller?.setComposerProfileForTab("tab-a", "normal", "ask", "new goal");
|
||||
concurrentProfileB = controller?.setComposerProfileForTab("tab-a", "normal", "ask", "new goal");
|
||||
await flushPromises();
|
||||
});
|
||||
eq(composerProfileCalls, changedProfileCallsBefore + 1, "concurrent identical profile replays share one backend call");
|
||||
await act(async () => {
|
||||
concurrentProfileDrain.resolve([]);
|
||||
await Promise.all([concurrentProfileA, concurrentProfileB]);
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
const olderProfileDrain = deferred<string[]>();
|
||||
const latestProfileDrain = deferred<string[]>();
|
||||
composerProfileDrain = olderProfileDrain;
|
||||
const orderedProfileCallsBefore = composerProfileCalls;
|
||||
let olderProfile: Promise<boolean> | undefined;
|
||||
let latestProfile: Promise<boolean> | undefined;
|
||||
await act(async () => {
|
||||
olderProfile = controller?.setComposerProfileForTab("tab-a", "plan", "yolo", "older intent");
|
||||
latestProfile = controller?.setComposerProfileForTab("tab-a", "normal", "ask", "latest intent");
|
||||
await flushPromises();
|
||||
});
|
||||
eq(composerProfileCalls, orderedProfileCallsBefore + 1, "different composer profiles are serialized per tab");
|
||||
composerProfileDrain = latestProfileDrain;
|
||||
await act(async () => {
|
||||
olderProfileDrain.resolve([]);
|
||||
await olderProfile;
|
||||
await flushPromises();
|
||||
});
|
||||
eq(composerProfileCalls, orderedProfileCallsBefore + 2, "latest composer profile starts after the older transaction");
|
||||
await act(async () => {
|
||||
latestProfileDrain.resolve([]);
|
||||
await latestProfile;
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
composerProfileDrain = undefined;
|
||||
rejectNextComposerProfile = true;
|
||||
const retryCallsBefore = composerProfileCalls;
|
||||
let failedProfile = true;
|
||||
let retriedProfile = false;
|
||||
await act(async () => {
|
||||
failedProfile = await controller!.setComposerProfileForTab("tab-a", "plan", "ask", "retry goal");
|
||||
retriedProfile = await controller!.setComposerProfileForTab("tab-a", "plan", "ask", "retry goal");
|
||||
await flushPromises();
|
||||
});
|
||||
eq(failedProfile, false, "failed composer profile transaction blocks the caller");
|
||||
eq(retriedProfile, true, "failed composer profile transaction remains retryable");
|
||||
eq(composerProfileCalls, retryCallsBefore + 2, "failed profile key is not cached as applied");
|
||||
}
|
||||
|
||||
await act(async () => {
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -3,8 +3,13 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { initialState, reducer, replayPendingPromptsForActiveTab } from "../lib/useController";
|
||||
import { acceptsRuntimeEventEpoch, initialState, reducer, replayPendingPromptsForActiveTab, runtimeReadyForSubmit } from "../lib/useController";
|
||||
import { continueDelivery } from "../lib/deliveryContinue";
|
||||
import {
|
||||
activateGoalAndSubmit,
|
||||
activateGoalAndSubmitOnTab,
|
||||
workbenchTargetToken,
|
||||
} from "../lib/goalSubmit";
|
||||
import type { WireEvent } from "../lib/types";
|
||||
|
||||
let passed = 0;
|
||||
@@ -22,6 +27,111 @@ function eq(a: unknown, b: unknown, label: string) {
|
||||
|
||||
console.log("\nsend failure feedback");
|
||||
|
||||
eq(
|
||||
workbenchTargetToken({ kind: "local", identityGen: 1, requestSeq: 0 })?.requestSeq,
|
||||
0,
|
||||
"initial Local workbench target keeps its zero request sequence",
|
||||
);
|
||||
eq(
|
||||
workbenchTargetToken({ kind: "local", identityGen: 1 }),
|
||||
null,
|
||||
"workbench target without a request sequence fails closed",
|
||||
);
|
||||
|
||||
{
|
||||
const calls: string[] = [];
|
||||
await activateGoalAndSubmit({
|
||||
displayText: "List the existing notes",
|
||||
submitText: "/ui-ux-pro-max List the existing notes",
|
||||
structured: {
|
||||
display: "/ui-ux-pro-max List the existing notes",
|
||||
input: "List the existing notes",
|
||||
invocations: [{ name: "ui-ux-pro-max", kind: "skill", offset: 0 }],
|
||||
},
|
||||
applyGoal: async (goal) => {
|
||||
calls.push(`goal:${goal}`);
|
||||
},
|
||||
send: async (display, submit, structured) => {
|
||||
calls.push(`send:${display}:${submit}:${structured?.invocations[0]?.name ?? ""}`);
|
||||
},
|
||||
});
|
||||
eq(calls.join("|"), "goal:List the existing notes|send:List the existing notes:/ui-ux-pro-max List the existing notes:ui-ux-pro-max", "initial Goal activates before structured Skill submission");
|
||||
}
|
||||
|
||||
{
|
||||
// Bridge failure must abort structured Skill submit: there is no `/goal` fallback.
|
||||
const calls: string[] = [];
|
||||
let threw = false;
|
||||
try {
|
||||
await activateGoalAndSubmit({
|
||||
displayText: "Ship the feature",
|
||||
submitText: "/ui-ux-pro-max Ship the feature",
|
||||
structured: {
|
||||
display: "/ui-ux-pro-max Ship the feature",
|
||||
input: "Ship the feature",
|
||||
invocations: [{ name: "ui-ux-pro-max", kind: "skill", offset: 0 }],
|
||||
},
|
||||
applyGoal: async (goal) => {
|
||||
calls.push(`goal:${goal}`);
|
||||
throw new Error("SetGoalForTab: tab closed");
|
||||
},
|
||||
send: async (display, submit, structured) => {
|
||||
calls.push(`send:${display}:${submit}:${structured?.invocations[0]?.name ?? ""}`);
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
threw = error instanceof Error && error.message === "SetGoalForTab: tab closed";
|
||||
}
|
||||
eq(threw, true, "Goal activation bridge failure propagates");
|
||||
eq(calls.join("|"), "goal:Ship the feature", "failed Goal activation does not submit the structured Skill");
|
||||
}
|
||||
|
||||
{
|
||||
// Tab-scoped helper captures source tab and workbench target once; callbacks
|
||||
// receive both even if a surrounding "active tab" concept changes mid-flight.
|
||||
const calls: string[] = [];
|
||||
let releaseSubmit!: () => void;
|
||||
const submitGate = new Promise<void>((resolve) => {
|
||||
releaseSubmit = resolve;
|
||||
});
|
||||
let activeTab = "tab-a";
|
||||
const pending = activateGoalAndSubmitOnTab({
|
||||
tabId: "tab-a",
|
||||
target: { kind: "ssh", identityGen: 7, requestSeq: 11 },
|
||||
displayText: "Cross-tab safe goal",
|
||||
submitText: "/ui-ux-pro-max Cross-tab safe goal",
|
||||
structured: {
|
||||
display: "/ui-ux-pro-max Cross-tab safe goal",
|
||||
input: "Cross-tab safe goal",
|
||||
invocations: [{ name: "ui-ux-pro-max", kind: "skill", offset: 0 }],
|
||||
},
|
||||
sendToTab: async (tabId, goal, display, submit, structured, target) => {
|
||||
await submitGate;
|
||||
calls.push(
|
||||
`send:${tabId}:${goal}:${display}:${submit}:${structured?.invocations[0]?.name ?? ""}:${target?.kind}:${target?.identityGen}:${target?.requestSeq}:active=${activeTab}`,
|
||||
);
|
||||
},
|
||||
});
|
||||
activeTab = "tab-b";
|
||||
calls.push("switched-to-tab-b");
|
||||
releaseSubmit();
|
||||
await pending;
|
||||
eq(
|
||||
calls.join("|"),
|
||||
"switched-to-tab-b|send:tab-a:Cross-tab safe goal:Cross-tab safe goal:/ui-ux-pro-max Cross-tab safe goal:ui-ux-pro-max:ssh:7:11:active=tab-b",
|
||||
"activateGoalAndSubmitOnTab keeps Goal and Skill on the captured source tab and target",
|
||||
);
|
||||
}
|
||||
|
||||
eq(runtimeReadyForSubmit({ label: "", ready: false, eventChannel: "", cwd: "", runtime: { phase: "starting", epoch: "e1" } }), false, "starting runtime cannot submit");
|
||||
eq(runtimeReadyForSubmit({ label: "", ready: false, eventChannel: "", cwd: "", runtime: { phase: "lease_blocked", epoch: "e1" } }), false, "lease-blocked runtime cannot submit");
|
||||
eq(runtimeReadyForSubmit({ label: "", ready: false, eventChannel: "", cwd: "", runtime: { phase: "failed", epoch: "e1" } }), false, "failed runtime cannot submit");
|
||||
eq(runtimeReadyForSubmit({ label: "", ready: true, eventChannel: "", cwd: "", runtime: { phase: "ready", epoch: "e1" } }), true, "ready runtime can submit");
|
||||
eq(acceptsRuntimeEventEpoch("e2", "e1"), false, "old runtime epoch is rejected");
|
||||
eq(acceptsRuntimeEventEpoch("e2", "e2"), true, "current runtime epoch is accepted");
|
||||
eq(acceptsRuntimeEventEpoch(undefined, "e1"), true, "first runtime epoch can establish the fence");
|
||||
eq(acceptsRuntimeEventEpoch("e2", undefined), true, "legacy events remain compatible");
|
||||
|
||||
const sent = reducer({ ...initialState }, { type: "user", text: "hello", seq: 0 });
|
||||
eq(sent.items.length, 1, "submit appends the user bubble immediately");
|
||||
eq(sent.items[0].kind === "user" && sent.items[0].text, "hello", "bubble carries the submitted text");
|
||||
@@ -93,6 +203,31 @@ const ordinaryTurnNotice = ordinaryTurnError.items[ordinaryTurnError.items.lengt
|
||||
eq(ordinaryTurnNotice.kind === "notice" && ordinaryTurnNotice.level, "warn", "ordinary turn errors remain warnings");
|
||||
eq(ordinaryTurnNotice.kind === "notice" && ordinaryTurnNotice.text, "provider failed", "ordinary turn errors keep their diagnostic text");
|
||||
|
||||
const recoveryPaused = reducer(readinessStarted, {
|
||||
type: "event",
|
||||
e: {
|
||||
kind: "turn_done",
|
||||
outcome: "recovery_paused",
|
||||
err: "Automatic retries paused. Reasonix stopped repeated attempts and kept completed work. Send \"continue\" to start a fresh attempt, or add instructions to change direction.",
|
||||
} as WireEvent,
|
||||
});
|
||||
const recoveryNotice = recoveryPaused.items[recoveryPaused.items.length - 1];
|
||||
eq(recoveryNotice.kind === "notice" && recoveryNotice.level, "info", "recovery_paused uses informational severity");
|
||||
eq(recoveryNotice.kind === "notice" && Boolean(recoveryNotice.title), true, "recovery_paused shows a product title");
|
||||
eq(
|
||||
recoveryNotice.kind === "notice" && recoveryNotice.text,
|
||||
"Reasonix stopped repeated attempts and kept completed work. Send “Continue” to start a fresh attempt, or add instructions to change direction.",
|
||||
"recovery_paused uses the localized product copy",
|
||||
);
|
||||
eq(
|
||||
recoveryNotice.kind === "notice" && Boolean(recoveryNotice.detail),
|
||||
false,
|
||||
"recovery_paused does not repeat the backend English fallback as localized detail",
|
||||
);
|
||||
const recoveryUser = recoveryPaused.items.find((it) => it.kind === "user");
|
||||
eq(recoveryUser?.kind === "user" && Boolean(recoveryUser.failed), false, "recovery_paused does not mark the user message as failed");
|
||||
eq(recoveryPaused.running, false, "recovery_paused frees the composer");
|
||||
|
||||
const shellSent = reducer({ ...initialState }, { type: "user", text: "!ls", seq: 0 });
|
||||
const shellFailed = reducer(shellSent, { type: "send_failed", error: "Command failed: workspace is still starting" });
|
||||
const shellNotice = shellFailed.items[shellFailed.items.length - 1];
|
||||
@@ -126,6 +261,11 @@ eq(
|
||||
true,
|
||||
"plan approval clears the remembered plan restore intent before execution",
|
||||
);
|
||||
eq(
|
||||
/onExitPlan=\{async \(\) => \{\s*await applyCollaborationMode\("normal"\);\s*approve\(state\.approval!\.id, false, false, false\);\s*\}\}/.test(appSource),
|
||||
true,
|
||||
"exit-without-executing switches to Normal before rejecting the pending plan",
|
||||
);
|
||||
eq(
|
||||
!/exit_plan_mode[\s\S]{0,240}rememberUserIntent:\s*false/.test(appSource),
|
||||
true,
|
||||
@@ -147,19 +287,51 @@ eq(
|
||||
"failed runtime profile transitions roll back the optimistic token mode",
|
||||
);
|
||||
eq(
|
||||
appSource.includes("!state.backendActivationPending && !runtimeTransitioning"),
|
||||
appSource.includes("!state.backendActivationPending &&") && appSource.includes("!runtimeTransitioning"),
|
||||
true,
|
||||
"runtime profile transitions keep submit behind the controller-ready gate",
|
||||
);
|
||||
eq(
|
||||
appSource.includes("activateGoalAndSubmitOnTab({") &&
|
||||
appSource.includes("tabId: sourceTabId") &&
|
||||
appSource.includes("target: sourceTarget") &&
|
||||
appSource.includes("target ? {") &&
|
||||
appSource.includes("goal: nextGoal") &&
|
||||
appSource.includes("collaborationMode: controllerComposerProfileCollaborationMode(composerProfile)") &&
|
||||
appSource.includes("toolApprovalMode,"),
|
||||
true,
|
||||
"initial Goal activation captures the submission tab and workbench target",
|
||||
);
|
||||
eq(
|
||||
appSource.includes("setControllerGoalForTab(tabId, trimmed)") && appSource.includes("clearControllerGoalForTab(tabId)"),
|
||||
true,
|
||||
"tab-scoped Goal activation updates the matching controller",
|
||||
);
|
||||
eq(
|
||||
/await \(trimmed \? setControllerGoalForTab\(tabId, trimmed\) : clearControllerGoalForTab\(tabId\)\);\s*patchActivatedGoalForTab\(tabId, trimmed\)/.test(appSource),
|
||||
true,
|
||||
"local Goal profile is patched only after backend activation succeeds",
|
||||
);
|
||||
eq(
|
||||
controllerSource.includes("await app.SetGoalForTab(tabId, goal)") && !/SetGoalForTab\(tabId, goal\)\.catch\(\(\) => \{\}\)/.test(controllerSource),
|
||||
true,
|
||||
"SetGoalForTab activation failures propagate to callers",
|
||||
);
|
||||
eq(
|
||||
controllerSource.includes("await app.ClearGoalForTab(tabId)") && !/ClearGoalForTab\(tabId\)\.catch\(\(\) => \{\}\)/.test(controllerSource),
|
||||
true,
|
||||
"ClearGoalForTab failures also propagate to callers",
|
||||
);
|
||||
eq(
|
||||
/await continueDelivery\(\{[\s\S]{0,240}goal: state\.meta\?\.goal,[\s\S]{0,240}resumeGoal: resumeControllerGoalForTab,/.test(appSource),
|
||||
true,
|
||||
"delivery recovery routes through continueDelivery with the backend Goal state",
|
||||
);
|
||||
eq(
|
||||
/await applyGoal\(trimmed\);[\s\S]{0,120}await commitThenSendRef\.current\(sourceTabId/.test(appSource),
|
||||
controllerSource.includes("app.SubmitInitialGoalToTab(") &&
|
||||
appSource.includes("patchActivatedGoalForTab(sourceTabId, trimmed)"),
|
||||
true,
|
||||
"the first Goal turn waits for the controller Goal before submitting",
|
||||
"the first Goal turn uses the atomic target-scoped backend contract",
|
||||
);
|
||||
|
||||
const unsent = reducer(sent, { type: "unsend" });
|
||||
|
||||
@@ -16,6 +16,11 @@ import {
|
||||
import { LocaleProvider } from "../lib/i18n";
|
||||
import type { AppBindings } from "../lib/bridge";
|
||||
import type { SettingsView } from "../lib/types";
|
||||
import {
|
||||
applyTypographyPreferences,
|
||||
createDefaultTypographyPreferences,
|
||||
getTypographyPreferences,
|
||||
} from "../lib/typographyPreferences";
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
@@ -134,6 +139,7 @@ function baseSettings(displayMode: "standard" | "compact" = "standard"): Setting
|
||||
statusBarItems: ["model", "workspace", "git_branch", "cache", "balance"],
|
||||
defaultToolApprovalMode: "auto",
|
||||
checkUpdates: true,
|
||||
updateChannel: "stable",
|
||||
telemetry: true,
|
||||
metrics: true,
|
||||
configPath: "/tmp/reasonix/config.toml",
|
||||
@@ -201,6 +207,16 @@ globalThis.cancelAnimationFrame = dom.window.cancelAnimationFrame.bind(dom.windo
|
||||
window.scrollTo = () => {};
|
||||
localStorage.clear();
|
||||
|
||||
const regionalTypography = createDefaultTypographyPreferences();
|
||||
regionalTypography.code = {
|
||||
followGlobal: false,
|
||||
fontFamily: "jetbrains",
|
||||
customFontName: "",
|
||||
fontSize: 15,
|
||||
};
|
||||
applyTypographyPreferences(regionalTypography);
|
||||
const regionalCodeFont = document.documentElement.style.getPropertyValue("--typography-code-font");
|
||||
|
||||
const settingsSnapshots = [baseSettings("standard"), baseSettings("compact")];
|
||||
let settingsCalls = 0;
|
||||
let setDisplayModeCalls = 0;
|
||||
@@ -386,6 +402,23 @@ await act(async () => {
|
||||
});
|
||||
await waitFor("persisted display zoom sync", () => document.querySelector(".zoom-slider__value")?.textContent?.trim() === "50%");
|
||||
|
||||
const monoFontSelect = zoomRootEl.querySelector("select[aria-labelledby='appearance-mono-font-family-label']") as HTMLSelectElement | null;
|
||||
if (!monoFontSelect) throw new Error("monospace font selector did not render");
|
||||
await act(async () => {
|
||||
monoFontSelect.value = "custom";
|
||||
monoFontSelect.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
const preservedTypography = getTypographyPreferences();
|
||||
eq(preservedTypography.code.followGlobal, false, "global monospace changes preserve an explicit code-region override");
|
||||
eq(preservedTypography.code.fontFamily, "jetbrains", "global monospace changes preserve the regional code font choice");
|
||||
eq(
|
||||
document.documentElement.style.getPropertyValue("--typography-code-font"),
|
||||
regionalCodeFont,
|
||||
"global monospace changes keep the regional code font CSS variable",
|
||||
);
|
||||
|
||||
const resetZoomButton = document.querySelector("button[aria-label='Reset display zoom to 100%']") as HTMLButtonElement | null;
|
||||
if (!resetZoomButton) throw new Error("display zoom reset button did not render");
|
||||
await act(async () => {
|
||||
|
||||
@@ -86,6 +86,19 @@ async function main() {
|
||||
const keyButton = container.querySelector<HTMLButtonElement>(".shortcuts-settings__key");
|
||||
ok(Boolean(keyButton), "recorder button renders");
|
||||
if (!keyButton) throw new Error("no recorder button");
|
||||
const undoButton = container.querySelector<HTMLButtonElement>('[data-shortcut-action="composer.undo"]');
|
||||
const redoButton = container.querySelector<HTMLButtonElement>('[data-shortcut-action="composer.redo"]');
|
||||
ok(Boolean(undoButton), "composer undo appears in shortcut settings");
|
||||
ok(Boolean(redoButton), "composer redo appears in shortcut settings");
|
||||
ok(Boolean(undoButton?.disabled), "composer undo stays locked to the native editing chord");
|
||||
ok(Boolean(redoButton?.disabled), "composer redo stays locked to the native editing chord");
|
||||
ok(undoButton?.textContent?.includes("Ctrl") === true && undoButton.textContent.includes("Z"), "composer undo shows Ctrl+Z on non-mac platforms");
|
||||
ok(
|
||||
redoButton?.textContent?.includes("Ctrl") === true
|
||||
&& redoButton.textContent.includes("Shift")
|
||||
&& redoButton.textContent.includes("Z"),
|
||||
"composer redo shows Ctrl+Shift+Z on non-mac platforms",
|
||||
);
|
||||
|
||||
// Click WITHOUT focusing — JSDOM (like WebKit/WKWebView) does not focus
|
||||
// buttons on click, so this reproduces the desktop app's event flow.
|
||||
|
||||
@@ -145,8 +145,12 @@ const contextDGate = deferred<ContextInfo>();
|
||||
const setActiveBGate = deferred<void>();
|
||||
const setActiveEGate = deferred<void>();
|
||||
const setActiveFGate = deferred<void>();
|
||||
const staleSwitchFGate = deferred<void>();
|
||||
const staleSwitchReassertGGate = deferred<void>();
|
||||
const submitTabCGate = deferred<void>();
|
||||
const forkResultGate = deferred<void>();
|
||||
const staleForkResultGate = deferred<void>();
|
||||
const staleForkReassertGGate = deferred<void>();
|
||||
const historyCalls: string[] = [];
|
||||
const cancelCalls: string[] = [];
|
||||
let contextDCalls = 0;
|
||||
@@ -161,6 +165,13 @@ let replayPendingPromptCalls = 0;
|
||||
let failSetActiveFor = "";
|
||||
let holdNextForkResult = false;
|
||||
let forkStarted = false;
|
||||
let holdStaleSwitchF = false;
|
||||
let holdStaleSwitchReassertG = false;
|
||||
let staleSwitchReassertGStarted = false;
|
||||
let holdStaleForkResult = false;
|
||||
let staleForkStarted = false;
|
||||
let holdStaleForkReassertG = false;
|
||||
let staleForkReassertGStarted = false;
|
||||
const runningTabs = new Set<string>();
|
||||
const tabsById = new Map([tabA, tabB, tabC, tabD, tabE, tabF, tabG, tabH, tabI].map((tab) => [tab.id, tab]));
|
||||
const eventHandlers: Array<(e: WireEvent) => void> = [];
|
||||
@@ -250,7 +261,7 @@ window.go = {
|
||||
return { ...tabE, active: true, running: true };
|
||||
},
|
||||
ForkForTab: async () => {
|
||||
const fork = holdNextForkResult ? tabJ : tabE;
|
||||
const fork = holdNextForkResult || holdStaleForkResult ? tabJ : tabE;
|
||||
tabsById.set(fork.id, fork);
|
||||
backendActiveId = fork.id;
|
||||
runningTabs.add(fork.id);
|
||||
@@ -259,6 +270,11 @@ window.go = {
|
||||
forkStarted = true;
|
||||
await forkResultGate.promise;
|
||||
}
|
||||
if (holdStaleForkResult) {
|
||||
holdStaleForkResult = false;
|
||||
staleForkStarted = true;
|
||||
await staleForkResultGate.promise;
|
||||
}
|
||||
return { ...fork, active: true, running: true };
|
||||
},
|
||||
ReplayPendingPrompts: async () => {
|
||||
@@ -278,6 +294,20 @@ window.go = {
|
||||
if (tabID === "tab-b") await setActiveBGate.promise;
|
||||
if (tabID === "tab-e") await setActiveEGate.promise;
|
||||
if (tabID === "tab-f") await setActiveFGate.promise;
|
||||
if (tabID === "tab-f" && holdStaleSwitchF) {
|
||||
holdStaleSwitchF = false;
|
||||
await staleSwitchFGate.promise;
|
||||
}
|
||||
if (tabID === "tab-g" && holdStaleSwitchReassertG) {
|
||||
holdStaleSwitchReassertG = false;
|
||||
staleSwitchReassertGStarted = true;
|
||||
await staleSwitchReassertGGate.promise;
|
||||
}
|
||||
if (tabID === "tab-g" && holdStaleForkReassertG) {
|
||||
holdStaleForkReassertG = false;
|
||||
staleForkReassertGStarted = true;
|
||||
await staleForkReassertGGate.promise;
|
||||
}
|
||||
if (tabID === failSetActiveFor) throw new Error("persist failed");
|
||||
backendActiveId = tabID;
|
||||
},
|
||||
@@ -698,6 +728,52 @@ await waitFor("reopened tab-h finishes after stale meta discard", () =>
|
||||
(controller.state.items.some((item) => item.kind === "user" && item.text === "history H after stale meta") ?? false)
|
||||
);
|
||||
|
||||
// A stale repair is itself asynchronous. Force a third navigation to complete
|
||||
// while that repair is pending and verify the repair follows the newest tab.
|
||||
holdStaleSwitchF = true;
|
||||
holdStaleSwitchReassertG = true;
|
||||
let threeWaySwitchToF: Promise<TabMeta[] | undefined> | undefined;
|
||||
await act(async () => {
|
||||
threeWaySwitchToF = controller?.switchTab("tab-f", tabF);
|
||||
await flushPromises();
|
||||
await controller?.openProjectTab(tabG.workspaceRoot, tabG.topicId || "");
|
||||
staleSwitchFGate.resolve();
|
||||
await flushPromises();
|
||||
});
|
||||
await waitFor("stale switch reassert G starts", () => staleSwitchReassertGStarted);
|
||||
await act(async () => {
|
||||
await controller?.openProjectTab(tabH.workspaceRoot, tabH.topicId || "");
|
||||
staleSwitchReassertGGate.resolve();
|
||||
await threeWaySwitchToF;
|
||||
await flushPromises();
|
||||
});
|
||||
eq(controller?.activeTabId, "tab-h", "third navigation remains visible after stale switch repair");
|
||||
eq(backendActiveId, "tab-h", "third navigation remains backend-active after stale switch repair");
|
||||
|
||||
holdStaleForkResult = true;
|
||||
holdStaleForkReassertG = true;
|
||||
let threeWayFork: Promise<boolean> | undefined;
|
||||
await act(async () => {
|
||||
threeWayFork = controller?.rewindForTab("tab-h", 0, "fork");
|
||||
await flushPromises();
|
||||
});
|
||||
await waitFor("stale fork result", () => staleForkStarted && backendActiveId === "tab-j");
|
||||
await act(async () => {
|
||||
await controller?.openProjectTab(tabG.workspaceRoot, tabG.topicId || "");
|
||||
staleForkResultGate.resolve();
|
||||
await flushPromises();
|
||||
});
|
||||
await waitFor("stale fork reassert G starts", () => staleForkReassertGStarted);
|
||||
await act(async () => {
|
||||
await controller?.openProjectTab(tabH.workspaceRoot, tabH.topicId || "");
|
||||
staleForkReassertGGate.resolve();
|
||||
await threeWayFork;
|
||||
await flushPromises();
|
||||
});
|
||||
eq(controller?.activeTabId, "tab-h", "third navigation remains visible after stale fork repair");
|
||||
eq(backendActiveId, "tab-h", "third navigation remains backend-active after stale fork repair");
|
||||
runningTabs.delete("tab-j");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import { JSDOM } from "jsdom";
|
||||
|
||||
import { __emitMockTerminalExit, __emitMockTerminalOutput } from "../lib/bridge";
|
||||
import {
|
||||
__resetTerminalEventBus,
|
||||
forgetTerminalSession,
|
||||
registerTerminalSink,
|
||||
startTerminalEventBridge,
|
||||
terminalEventBufferLimit,
|
||||
} from "../lib/terminalEvents";
|
||||
|
||||
function base64(bytes: Uint8Array): string {
|
||||
return Buffer.from(bytes).toString("base64");
|
||||
}
|
||||
|
||||
function check(condition: unknown, message: string): void {
|
||||
if (!condition) throw new Error(message);
|
||||
process.stdout.write(`PASS ${message}\n`);
|
||||
}
|
||||
|
||||
const dom = new JSDOM("<!doctype html><html><body></body></html>");
|
||||
const previousWindow = globalThis.window;
|
||||
const previousAtob = globalThis.atob;
|
||||
const previousBtoa = globalThis.btoa;
|
||||
globalThis.window = dom.window as unknown as Window & typeof globalThis;
|
||||
globalThis.atob = (value: string) => Buffer.from(value, "base64").toString("binary");
|
||||
globalThis.btoa = (value: string) => Buffer.from(value, "binary").toString("base64");
|
||||
|
||||
try {
|
||||
__resetTerminalEventBus();
|
||||
startTerminalEventBridge();
|
||||
const first: number[] = [];
|
||||
__emitMockTerminalOutput({ id: "utf8", data: base64(new Uint8Array([0xe4, 0xbd])) });
|
||||
__emitMockTerminalOutput({ id: "utf8", data: base64(new Uint8Array([0xa0])) });
|
||||
const unregister = registerTerminalSink("utf8", (bytes) => first.push(...bytes));
|
||||
unregister();
|
||||
check(JSON.stringify(first) === JSON.stringify([0xe4, 0xbd, 0xa0]), "split UTF-8 output is delivered as original bytes");
|
||||
|
||||
const live: number[] = [];
|
||||
const unregisterLive = registerTerminalSink("replay", (bytes) => live.push(...bytes));
|
||||
__emitMockTerminalOutput({ id: "replay", data: base64(new Uint8Array([1, 2])) });
|
||||
unregisterLive();
|
||||
__emitMockTerminalOutput({ id: "replay", data: base64(new Uint8Array([3])) });
|
||||
const replayed: number[] = [];
|
||||
const unregisterReplay = registerTerminalSink("replay", (bytes) => replayed.push(...bytes));
|
||||
unregisterReplay();
|
||||
check(JSON.stringify(live) === JSON.stringify([1, 2]), "attached terminal receives live output");
|
||||
check(JSON.stringify(replayed) === JSON.stringify([1, 2, 3]), "remounted terminal replays complete output history");
|
||||
|
||||
const chunk = new Uint8Array(64 * 1024);
|
||||
chunk.fill(65);
|
||||
for (let index = 0; index < 24; index += 1) {
|
||||
__emitMockTerminalOutput({ id: "bounded", data: base64(chunk) });
|
||||
}
|
||||
const bounded: number[] = [];
|
||||
const unregisterBounded = registerTerminalSink("bounded", (bytes) => bounded.push(...bytes));
|
||||
unregisterBounded();
|
||||
check(bounded.length <= terminalEventBufferLimit, "terminal output history is bounded to one MiB per session");
|
||||
|
||||
__emitMockTerminalOutput({ id: "forgotten", data: base64(new Uint8Array([9])) });
|
||||
forgetTerminalSession("forgotten");
|
||||
const forgotten: number[] = [];
|
||||
const unregisterForgotten = registerTerminalSink("forgotten", (bytes) => forgotten.push(...bytes));
|
||||
unregisterForgotten();
|
||||
check(forgotten.length === 0, "explicitly closed terminal history is discarded");
|
||||
|
||||
__emitMockTerminalOutput({ id: "natural-exit", data: base64(new Uint8Array([7])) });
|
||||
__emitMockTerminalExit({ id: "natural-exit", exitCode: 0 });
|
||||
const naturalExit: number[] = [];
|
||||
const unregisterNaturalExit = registerTerminalSink("natural-exit", (bytes) => naturalExit.push(...bytes));
|
||||
unregisterNaturalExit();
|
||||
check(JSON.stringify(naturalExit) === JSON.stringify([7]), "natural exit preserves terminal history");
|
||||
|
||||
__emitMockTerminalOutput({ id: "removed-exit", data: base64(new Uint8Array([8])) });
|
||||
__emitMockTerminalExit({ id: "removed-exit", exitCode: 0, removed: true });
|
||||
const removedExit: number[] = [];
|
||||
const unregisterRemovedExit = registerTerminalSink("removed-exit", (bytes) => removedExit.push(...bytes));
|
||||
unregisterRemovedExit();
|
||||
check(removedExit.length === 0, "removed terminal exit discards terminal history");
|
||||
} finally {
|
||||
__resetTerminalEventBus();
|
||||
if (previousWindow) globalThis.window = previousWindow;
|
||||
else delete (globalThis as { window?: unknown }).window;
|
||||
if (previousAtob) globalThis.atob = previousAtob;
|
||||
if (previousBtoa) globalThis.btoa = previousBtoa;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { formatTerminalOutputForComposer, stripTerminalControlSequences } from "../lib/terminalOutput";
|
||||
|
||||
function eq<T>(actual: T, expected: T, message: string): void {
|
||||
if (actual !== expected) throw new Error(`${message}: got ${String(actual)}, want ${String(expected)}`);
|
||||
}
|
||||
|
||||
const ansi = "\u001b[31mred\u001b[0m \u001b]0;terminal title\u0007ok";
|
||||
eq(stripTerminalControlSequences(ansi), "red ok", "ANSI and OSC sequences are removed");
|
||||
eq(stripTerminalControlSequences("a\u009b31mb"), "ab", "C1 CSI sequences are removed");
|
||||
eq(stripTerminalControlSequences("a\u009d0;terminal title\u0007b"), "ab", "C1 OSC sequences terminated by BEL are removed");
|
||||
eq(stripTerminalControlSequences("a\u0090private payload\u009cb"), "ab", "C1 string sequences terminated by ST are removed");
|
||||
eq(stripTerminalControlSequences("a\u001bPprivate payload\u001b\\b"), "ab", "ESC string sequences terminated by ST are removed");
|
||||
eq(stripTerminalControlSequences("a\u001b(Bb"), "ab", "multi-byte ESC character-set sequences are removed");
|
||||
eq(stripTerminalControlSequences("a\u0000b\u0007c\tline\r\nnext"), "abc\tline\nnext", "unsafe controls are removed while layout whitespace remains");
|
||||
|
||||
const fenced = formatTerminalOutputForComposer("before ``` inside\nlast");
|
||||
eq(fenced.startsWith("````console\n"), true, "the outer fence grows beyond embedded backticks");
|
||||
eq(fenced.endsWith("\n````"), true, "the matching closing fence is emitted");
|
||||
eq(fenced.includes("before ``` inside"), true, "terminal text remains intact inside the fenced block");
|
||||
|
||||
const clipped = formatTerminalOutputForComposer("0123456789", 4);
|
||||
eq(clipped, "```console\n6789\n```", "large output keeps the newest bounded tail");
|
||||
eq(formatTerminalOutputForComposer("\u001b[2J\u001b[H\u0000"), "", "output with no printable content is not inserted");
|
||||
|
||||
console.log("terminal-output: ok");
|
||||
@@ -0,0 +1,231 @@
|
||||
import { JSDOM } from "jsdom";
|
||||
|
||||
const dom = new JSDOM("<!doctype html><html><body></body></html>");
|
||||
const previousWindow = globalThis.window;
|
||||
globalThis.window = dom.window as unknown as Window & typeof globalThis;
|
||||
|
||||
type Session = {
|
||||
id: string;
|
||||
title: string;
|
||||
shell: string;
|
||||
cwd: string;
|
||||
createdAt: number;
|
||||
running: boolean;
|
||||
};
|
||||
type Workspace = { available: boolean; readOnly: boolean; sessions: Session[]; shells: { id: string; label: string }[] };
|
||||
const pending = new Map<string, (value: Workspace) => void>();
|
||||
const pendingReject = new Map<string, (error: Error) => void>();
|
||||
const createPending: Array<(value: Session) => void> = [];
|
||||
const createReject: Array<(error: Error) => void> = [];
|
||||
let calls = 0;
|
||||
let closeError: Error | null = null;
|
||||
let writeError: Error | null = null;
|
||||
const fakeApp = {
|
||||
TerminalWorkspaceForTab(tabId: string) {
|
||||
calls += 1;
|
||||
return new Promise<Workspace>((resolve, reject) => {
|
||||
pending.set(tabId, resolve);
|
||||
pendingReject.set(tabId, reject);
|
||||
});
|
||||
},
|
||||
CreateTerminalForTab() {
|
||||
return new Promise<Session>((resolve, reject) => {
|
||||
createPending.push(resolve);
|
||||
createReject.push(reject);
|
||||
});
|
||||
},
|
||||
async CloseTerminalForTab() {
|
||||
if (closeError) throw closeError;
|
||||
},
|
||||
async WriteTerminalForTab() {
|
||||
if (writeError) throw writeError;
|
||||
},
|
||||
};
|
||||
(globalThis.window as unknown as { go: unknown }).go = { main: { App: fakeApp } };
|
||||
|
||||
const { resetTerminalStoreForTests, useTerminalStore } = await import("../store/terminal");
|
||||
resetTerminalStoreForTests();
|
||||
|
||||
const oldRequest = useTerminalStore.getState().syncWorkspace("old-tab");
|
||||
const newRequest = useTerminalStore.getState().syncWorkspace("new-tab");
|
||||
pending.get("new-tab")?.({ available: true, readOnly: false, sessions: [], shells: [] });
|
||||
await newRequest;
|
||||
pending.get("old-tab")?.({ available: true, readOnly: false, sessions: [], shells: [] });
|
||||
await oldRequest;
|
||||
if (useTerminalStore.getState().tabId !== "new-tab") throw new Error("stale workspace response replaced the selected tab");
|
||||
if (calls !== 2) throw new Error(`expected two workspace calls, got ${calls}`);
|
||||
|
||||
resetTerminalStoreForTests();
|
||||
calls = 0;
|
||||
const shared = useTerminalStore.getState().ensureReady("same-tab");
|
||||
const sharedAgain = useTerminalStore.getState().ensureReady("same-tab");
|
||||
pending.get("same-tab")?.({ available: true, readOnly: false, sessions: [], shells: [] });
|
||||
await Promise.all([shared, sharedAgain]);
|
||||
if (calls !== 1) throw new Error(`ensureReady duplicated the first request: ${calls}`);
|
||||
process.stdout.write("PASS stale responses and first-ready deduplication\n");
|
||||
|
||||
resetTerminalStoreForTests();
|
||||
calls = 0;
|
||||
createPending.length = 0;
|
||||
const firstOpenCreate = useTerminalStore.getState().createSession("first-open");
|
||||
await Promise.resolve();
|
||||
const panelReady = useTerminalStore.getState().syncWorkspace("first-open");
|
||||
await Promise.resolve();
|
||||
if (calls !== 1) throw new Error(`panel readiness duplicated the first-open workspace request: ${calls}`);
|
||||
pending.get("first-open")?.({
|
||||
available: true,
|
||||
readOnly: false,
|
||||
sessions: [],
|
||||
shells: [{ id: "default", label: "Default shell" }],
|
||||
});
|
||||
await panelReady;
|
||||
await Promise.resolve();
|
||||
if (createPending.length !== 1) throw new Error(`first-open create did not reach the backend: ${createPending.length}`);
|
||||
const firstOpenSession: Session = {
|
||||
id: "first-open-session",
|
||||
title: "default",
|
||||
shell: "default",
|
||||
cwd: ".",
|
||||
createdAt: 1,
|
||||
running: true,
|
||||
};
|
||||
createPending[0]?.(firstOpenSession);
|
||||
const createdOnFirstOpen = await firstOpenCreate;
|
||||
if (createdOnFirstOpen?.id !== firstOpenSession.id) throw new Error("first-open create was discarded");
|
||||
process.stdout.write("PASS first-open panel readiness shares the pending workspace request\n");
|
||||
|
||||
resetTerminalStoreForTests();
|
||||
calls = 0;
|
||||
const staleCapability = useTerminalStore.getState().syncWorkspace("capability-refresh");
|
||||
await Promise.resolve();
|
||||
const resolveStaleCapability = pending.get("capability-refresh");
|
||||
const latestCapability = useTerminalStore.getState().syncWorkspace("capability-refresh", true);
|
||||
await Promise.resolve();
|
||||
if (calls !== 2) throw new Error(`forced capability refresh did not start a new request: ${calls}`);
|
||||
pending.get("capability-refresh")?.({
|
||||
available: true,
|
||||
readOnly: false,
|
||||
sessions: [],
|
||||
shells: [{ id: "default", label: "Default shell" }],
|
||||
});
|
||||
await latestCapability;
|
||||
resolveStaleCapability?.({
|
||||
available: true,
|
||||
readOnly: true,
|
||||
sessions: [],
|
||||
shells: [{ id: "default", label: "Default shell" }],
|
||||
});
|
||||
await staleCapability;
|
||||
if (useTerminalStore.getState().workspace?.readOnly) {
|
||||
throw new Error("stale read-only capability replaced the forced writable refresh");
|
||||
}
|
||||
process.stdout.write("PASS forced capability refresh supersedes an in-flight same-tab request\n");
|
||||
|
||||
resetTerminalStoreForTests();
|
||||
createPending.length = 0;
|
||||
const readyWorkspace: Workspace = {
|
||||
available: true,
|
||||
readOnly: false,
|
||||
sessions: [],
|
||||
shells: [{ id: "default", label: "Default shell" }],
|
||||
};
|
||||
useTerminalStore.setState({
|
||||
tabId: "create-tab",
|
||||
workspace: readyWorkspace,
|
||||
loading: false,
|
||||
activeSessionId: null,
|
||||
});
|
||||
const firstCreate = useTerminalStore.getState().createSession("create-tab");
|
||||
const secondCreate = useTerminalStore.getState().createSession("create-tab");
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
if (createPending.length !== 2) throw new Error(`expected two create calls, got ${createPending.length}`);
|
||||
const firstSession: Session = {
|
||||
id: "first",
|
||||
title: "first",
|
||||
shell: "default",
|
||||
cwd: ".",
|
||||
createdAt: 1,
|
||||
running: true,
|
||||
};
|
||||
const secondSession: Session = {
|
||||
id: "second",
|
||||
title: "second",
|
||||
shell: "default",
|
||||
cwd: ".",
|
||||
createdAt: 2,
|
||||
running: true,
|
||||
};
|
||||
createPending[1]?.(secondSession);
|
||||
await secondCreate;
|
||||
createPending[0]?.(firstSession);
|
||||
await firstCreate;
|
||||
const sessionIDs = useTerminalStore.getState().workspace?.sessions.map((session) => session.id).sort();
|
||||
if (JSON.stringify(sessionIDs) !== JSON.stringify(["first", "second"])) {
|
||||
throw new Error(`concurrent creates lost a session: ${JSON.stringify(sessionIDs)}`);
|
||||
}
|
||||
process.stdout.write("PASS concurrent terminal creates merge into the latest workspace state\n");
|
||||
|
||||
resetTerminalStoreForTests();
|
||||
const failedSync = useTerminalStore.getState().syncWorkspace("sync-error");
|
||||
pendingReject.get("sync-error")?.(new Error("workspace unavailable"));
|
||||
await failedSync.catch(() => {});
|
||||
if (useTerminalStore.getState().error !== "workspace unavailable") {
|
||||
throw new Error(`workspace sync error was not exposed: ${useTerminalStore.getState().error}`);
|
||||
}
|
||||
if (useTerminalStore.getState().loading || useTerminalStore.getState().workspace !== null) {
|
||||
throw new Error("failed workspace sync left a loading or partial workspace state");
|
||||
}
|
||||
process.stdout.write("PASS workspace sync failures remain visible and retryable\n");
|
||||
|
||||
resetTerminalStoreForTests();
|
||||
createPending.length = 0;
|
||||
createReject.length = 0;
|
||||
useTerminalStore.setState({
|
||||
tabId: "create-error",
|
||||
workspace: readyWorkspace,
|
||||
loading: false,
|
||||
activeSessionId: null,
|
||||
});
|
||||
const failedCreate = useTerminalStore.getState().createSession("create-error");
|
||||
await Promise.resolve();
|
||||
createReject[0]?.(new Error("shell failed to start"));
|
||||
await failedCreate.catch(() => {});
|
||||
if (useTerminalStore.getState().error !== "shell failed to start") {
|
||||
throw new Error(`terminal create error was not exposed: ${useTerminalStore.getState().error}`);
|
||||
}
|
||||
if (useTerminalStore.getState().workspace?.sessions.length !== 0) {
|
||||
throw new Error("failed terminal create inserted a phantom session");
|
||||
}
|
||||
process.stdout.write("PASS terminal create failures do not insert phantom sessions\n");
|
||||
|
||||
resetTerminalStoreForTests();
|
||||
closeError = new Error("terminal did not close");
|
||||
useTerminalStore.setState({
|
||||
tabId: "close-error",
|
||||
workspace: { ...readyWorkspace, sessions: [firstSession] },
|
||||
loading: false,
|
||||
activeSessionId: firstSession.id,
|
||||
});
|
||||
await useTerminalStore.getState().closeSession("close-error", firstSession.id).catch(() => {});
|
||||
if (useTerminalStore.getState().error !== "terminal did not close") {
|
||||
throw new Error(`terminal close error was not exposed: ${useTerminalStore.getState().error}`);
|
||||
}
|
||||
if (useTerminalStore.getState().workspace?.sessions[0]?.id !== firstSession.id) {
|
||||
throw new Error("failed terminal close removed the live session from the workspace");
|
||||
}
|
||||
useTerminalStore.getState().clearError();
|
||||
if (useTerminalStore.getState().error !== null) throw new Error("terminal error dismissal did not clear the error");
|
||||
closeError = null;
|
||||
process.stdout.write("PASS terminal close failures preserve the session and can be dismissed\n");
|
||||
|
||||
writeError = new Error("terminal input failed");
|
||||
await useTerminalStore.getState().write("close-error", firstSession.id, "echo test").catch(() => {});
|
||||
if (useTerminalStore.getState().error !== "terminal input failed") {
|
||||
throw new Error(`terminal write error was not exposed: ${useTerminalStore.getState().error}`);
|
||||
}
|
||||
writeError = null;
|
||||
process.stdout.write("PASS terminal write failures remain visible to the user\n");
|
||||
|
||||
if (previousWindow) globalThis.window = previousWindow;
|
||||
else delete (globalThis as { window?: unknown }).window;
|
||||
@@ -1,6 +1,9 @@
|
||||
// Run: tsx src/__tests__/tool-subject.test.ts
|
||||
|
||||
import { subjectOf } from "../lib/tools";
|
||||
import { toolGroupKind } from "../components/ToolGroup";
|
||||
import { historyMessagesToItems, isReadOnlyTool } from "../lib/useController";
|
||||
import type { HistoryMessage } from "../lib/types";
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
@@ -19,6 +22,60 @@ console.log("\ntool subject contract");
|
||||
|
||||
eq(subjectOf("task", JSON.stringify({ description: "audit docs" })), "audit docs", "task subject uses description");
|
||||
eq(subjectOf("run_skill", JSON.stringify({ name: "code-reviewer", arguments: "review this branch" })), "code-reviewer", "run_skill subject uses skill name");
|
||||
eq(
|
||||
subjectOf("use_capability", JSON.stringify({ action: "call", capability_id: "mcp-tool:github/search_issues" })),
|
||||
"mcp-tool:github/search_issues",
|
||||
"use_capability subject uses capability_id",
|
||||
);
|
||||
eq(subjectOf("use_capability", JSON.stringify({ action: "list" })), "list", "use_capability list falls back to action");
|
||||
|
||||
const capabilityArgs = JSON.stringify({ action: "call", capability_id: "mcp-tool:db/write" });
|
||||
eq(
|
||||
toolGroupKind({ kind: "tool", id: "reader", name: "use_capability", args: capabilityArgs, readOnly: true, status: "done" }),
|
||||
"explore",
|
||||
"resolved read-only MCP groups as research",
|
||||
);
|
||||
eq(
|
||||
toolGroupKind({ kind: "tool", id: "writer", name: "use_capability", args: capabilityArgs, readOnly: false, status: "done" }),
|
||||
"modify",
|
||||
"resolved writer MCP stays in the modify group",
|
||||
);
|
||||
|
||||
const history = historyMessagesToItems([{
|
||||
role: "assistant",
|
||||
content: "",
|
||||
toolCalls: [
|
||||
{
|
||||
id: "old",
|
||||
name: "use_capability",
|
||||
arguments: capabilityArgs,
|
||||
},
|
||||
{
|
||||
id: "reader",
|
||||
name: "use_capability",
|
||||
arguments: capabilityArgs,
|
||||
resolvedName: "mcp__db__read",
|
||||
capabilityId: "mcp-tool:db/read",
|
||||
resolvedReadOnly: true,
|
||||
},
|
||||
{
|
||||
id: "writer",
|
||||
name: "use_capability",
|
||||
arguments: capabilityArgs,
|
||||
resolvedName: "mcp__db__write",
|
||||
capabilityId: "mcp-tool:db/write",
|
||||
resolvedReadOnly: false,
|
||||
},
|
||||
],
|
||||
}] as HistoryMessage[], "h-").items.filter((item) => item.kind === "tool");
|
||||
eq(history[0]?.kind === "tool" ? history[0].readOnly : true, false, "old proxy history fails closed as visible");
|
||||
eq(history[1]?.kind === "tool" ? history[1].readOnly : false, true, "resolved reader history restores read-only classification");
|
||||
eq(history[2]?.kind === "tool" ? history[2].readOnly : true, false, "resolved writer history restores writer classification");
|
||||
eq(history[2]?.kind === "tool" ? history[2].resolvedName : "", "mcp__db__write", "history keeps resolved MCP target");
|
||||
eq(isReadOnlyTool("read_file"), true, "legacy built-in reader remains read-only");
|
||||
eq(isReadOnlyTool("grep"), true, "legacy search reader remains read-only");
|
||||
eq(isReadOnlyTool("use_capability"), false, "legacy unresolved proxy remains fail-closed");
|
||||
eq(isReadOnlyTool("unknown_tool"), false, "unknown history tool remains fail-closed");
|
||||
|
||||
if (failed) {
|
||||
process.stdout.write(`\n${failed} failed, ${passed} passed\n`);
|
||||
|
||||
@@ -36,5 +36,115 @@ ok(
|
||||
"opaque backdrop preserves the custom Creation scrollbar contract",
|
||||
);
|
||||
|
||||
function ruleBody(selector: string): string {
|
||||
const ruleStart = styles.indexOf(`${selector} {`);
|
||||
if (ruleStart < 0) return "";
|
||||
const bodyStart = styles.indexOf("{", ruleStart) + 1;
|
||||
const bodyEnd = styles.indexOf("\n}", bodyStart);
|
||||
return bodyEnd < 0 ? "" : styles.slice(bodyStart, bodyEnd);
|
||||
}
|
||||
|
||||
const windowsPrimaryTranscriptSelector =
|
||||
':root[data-platform="windows"] .app:not(.app--creation) .chat-pane > .main > .transcript-shell > .transcript';
|
||||
const windowsThemeTranscriptSelector =
|
||||
':root[data-platform="windows"][data-theme-pack][data-theme-has-bg="true"] .app:not(.app--creation) .chat-pane > .main > .transcript-shell > .transcript';
|
||||
const windowsTaskTranscriptSelector =
|
||||
':root[data-platform="windows"][data-theme-pack][data-theme-has-bg="true"][data-theme-scene="task"] .app:not(.app--creation) .chat-pane > .main > .transcript-shell > .transcript';
|
||||
const windowsTaskLeftTranscriptSelector =
|
||||
':root[data-platform="windows"][data-theme-pack][data-theme-has-bg="true"][data-theme-scene="task"][data-theme-safe-area="left"] .app:not(.app--creation) .chat-pane > .main > .transcript-shell > .transcript';
|
||||
const windowsTaskRightTranscriptSelector =
|
||||
':root[data-platform="windows"][data-theme-pack][data-theme-has-bg="true"][data-theme-scene="task"][data-theme-safe-area="right"] .app:not(.app--creation) .chat-pane > .main > .transcript-shell > .transcript';
|
||||
const windowsTranscriptRule = ruleBody(windowsPrimaryTranscriptSelector);
|
||||
const windowsThemeTranscriptRule = ruleBody(windowsThemeTranscriptSelector);
|
||||
const windowsThemeImageRule = ruleBody(`${windowsThemeTranscriptSelector}::before`);
|
||||
const windowsThemeOverlayRule = ruleBody(`${windowsThemeTranscriptSelector}::after`);
|
||||
const windowsThemeContentRule = ruleBody(`${windowsThemeTranscriptSelector} > *`);
|
||||
const windowsTaskTranscriptRule = ruleBody(windowsTaskTranscriptSelector);
|
||||
const windowsTaskLeftTranscriptRule = ruleBody(windowsTaskLeftTranscriptSelector);
|
||||
const windowsTaskRightTranscriptRule = ruleBody(windowsTaskRightTranscriptSelector);
|
||||
const lastTransparentTranscriptRule = styles.lastIndexOf(
|
||||
'.app:not(.app--creation) .transcript-shell {\n background: transparent !important;',
|
||||
);
|
||||
const windowsTranscriptRuleIndex = styles.indexOf(`${windowsPrimaryTranscriptSelector} {`);
|
||||
|
||||
ok(
|
||||
/position:\s*relative;/.test(windowsTranscriptRule) &&
|
||||
/isolation:\s*isolate;/.test(windowsTranscriptRule) &&
|
||||
/background-color:\s*Canvas\s*!important;/.test(windowsTranscriptRule) &&
|
||||
/linear-gradient\(var\(--chat-bg,\s*var\(--bg\)\)/.test(windowsTranscriptRule) &&
|
||||
/linear-gradient\(var\(--bg\),\s*var\(--bg\)\)\s*!important;/.test(windowsTranscriptRule) &&
|
||||
/clip-path:\s*inset\(0\);/.test(windowsTranscriptRule),
|
||||
"Windows Classic and Workbench primary transcripts paint a clipped opaque repaint backing (#7011)",
|
||||
);
|
||||
|
||||
ok(
|
||||
!styles.includes(':root[data-platform="windows"] .app .transcript {') &&
|
||||
windowsPrimaryTranscriptSelector.includes(".chat-pane > .main > .transcript-shell > .transcript"),
|
||||
"Windows repaint backing does not leak into HistoryPanel transcripts or Creation",
|
||||
);
|
||||
|
||||
ok(
|
||||
windowsTranscriptRuleIndex > lastTransparentTranscriptRule,
|
||||
"Windows repaint backing wins over later theme-pack transparency",
|
||||
);
|
||||
|
||||
ok(
|
||||
/background-color:\s*Canvas\s*!important;/.test(windowsThemeTranscriptRule) &&
|
||||
/background-image:\s*linear-gradient\(var\(--bg\),\s*var\(--bg\)\)\s*!important;/.test(windowsThemeTranscriptRule) &&
|
||||
/position:\s*fixed;/.test(windowsThemeImageRule) &&
|
||||
/inset:\s*-4%;/.test(windowsThemeImageRule) &&
|
||||
/z-index:\s*var\(--z-transcript-scene-image\);/.test(windowsThemeImageRule) &&
|
||||
/pointer-events:\s*none;/.test(windowsThemeImageRule) &&
|
||||
/background-image:\s*var\(--windows-transcript-scene-image\);/.test(windowsThemeImageRule) &&
|
||||
/background-size:\s*cover;/.test(windowsThemeImageRule) &&
|
||||
/background-position:\s*var\(--windows-transcript-scene-focus-x\)\s+var\(--windows-transcript-scene-focus-y\);/.test(windowsThemeImageRule) &&
|
||||
/opacity:\s*var\(--windows-transcript-image-opacity\);/.test(windowsThemeImageRule),
|
||||
"Windows theme image preserves the fixed -4% overscan above an opaque transcript base",
|
||||
);
|
||||
|
||||
ok(
|
||||
/position:\s*fixed;/.test(windowsThemeOverlayRule) &&
|
||||
/inset:\s*0;/.test(windowsThemeOverlayRule) &&
|
||||
/z-index:\s*var\(--z-transcript-scene-overlay\);/.test(windowsThemeOverlayRule) &&
|
||||
/pointer-events:\s*none;/.test(windowsThemeOverlayRule) &&
|
||||
/var\(--windows-transcript-pane-alpha\)/.test(windowsThemeOverlayRule) &&
|
||||
/var\(--windows-transcript-scene-overlay\)/.test(windowsThemeOverlayRule) &&
|
||||
/background-size:\s*100%\s+100%,\s*100%\s+100%;/.test(windowsThemeOverlayRule),
|
||||
"Windows theme pane and safe-area wash preserve the viewport-sized overlay geometry",
|
||||
);
|
||||
|
||||
const transcriptImageZ = Number(styles.match(/--z-transcript-scene-image:\s*(-?\d+);/)?.[1]);
|
||||
const transcriptOverlayZ = Number(styles.match(/--z-transcript-scene-overlay:\s*(-?\d+);/)?.[1]);
|
||||
const transcriptContentZ = Number(styles.match(/--z-transcript-content:\s*(-?\d+);/)?.[1]);
|
||||
|
||||
ok(
|
||||
transcriptImageZ >= 0 &&
|
||||
transcriptOverlayZ > transcriptImageZ &&
|
||||
transcriptContentZ > transcriptOverlayZ &&
|
||||
/position:\s*relative;/.test(windowsThemeContentRule) &&
|
||||
/z-index:\s*var\(--z-transcript-content\);/.test(windowsThemeContentRule),
|
||||
"Windows theme layers paint above the opaque backing and below transcript content",
|
||||
);
|
||||
|
||||
ok(
|
||||
/--windows-transcript-scene-image:\s*var\(--theme-bg-home-image/.test(windowsThemeTranscriptRule) &&
|
||||
/--windows-transcript-image-opacity:\s*var\(--theme-bg-home-opacity/.test(windowsThemeTranscriptRule) &&
|
||||
/--windows-transcript-pane-alpha:\s*var\(--theme-pane-alpha/.test(windowsThemeTranscriptRule) &&
|
||||
/--windows-transcript-scene-image:\s*var\(--theme-bg-task-image/.test(windowsTaskTranscriptRule) &&
|
||||
/--windows-transcript-image-opacity:\s*var\(--theme-bg-task-opacity/.test(windowsTaskTranscriptRule) &&
|
||||
/--windows-transcript-pane-alpha:\s*var\(--theme-pane-task-alpha/.test(windowsTaskTranscriptRule),
|
||||
"Windows home and task scenes use their matching image, opacity, and pane tokens",
|
||||
);
|
||||
|
||||
ok(
|
||||
/radial-gradient\(/.test(windowsTaskTranscriptRule) &&
|
||||
/linear-gradient\(\s*90deg,/.test(windowsTaskLeftTranscriptRule) &&
|
||||
/linear-gradient\(\s*270deg,/.test(windowsTaskRightTranscriptRule) &&
|
||||
/--windows-transcript-scene-overlay:/.test(windowsTaskTranscriptRule) &&
|
||||
/--windows-transcript-scene-overlay:/.test(windowsTaskLeftTranscriptRule) &&
|
||||
/--windows-transcript-scene-overlay:/.test(windowsTaskRightTranscriptRule),
|
||||
"Windows task scene preserves center, left, and right safe-area washes",
|
||||
);
|
||||
|
||||
console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`);
|
||||
if (failed > 0) process.exit(1);
|
||||
@@ -244,6 +244,13 @@ eq(
|
||||
);
|
||||
eq(finalDeclaration(".code", "font-family"), "var(--font-code-family)", "code blocks keep the regional code font");
|
||||
eq(finalDeclaration(".md-code", "font-family"), "var(--font-code-family)", "inline code keeps the regional code font");
|
||||
eq(finalDeclaration(".code code", "font-family"), "inherit", "nested code text inherits the regional code font");
|
||||
eq(finalDeclaration(".code-line-text", "font-family"), "inherit", "line-numbered code text inherits its viewer font");
|
||||
eq(
|
||||
finalDeclaration(".code-lines-wrap", "font-family"),
|
||||
"var(--typography-code-font, var(--font-mono))",
|
||||
"line-numbered code viewers use the regional code font",
|
||||
);
|
||||
eq(
|
||||
finalDeclaration(".diff", "font-size"),
|
||||
"var(--typography-code-size, calc(12.5px * var(--global-font-scale)))",
|
||||
|
||||
@@ -4,7 +4,8 @@ import { JSDOM } from "jsdom";
|
||||
import React from "react";
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { UpdaterProvider, useUpdater } from "../lib/useUpdater";
|
||||
import { __emitMockUpdater, type AppBindings } from "../lib/bridge";
|
||||
import { switchUpdaterChannel, UpdaterProvider, useUpdater } from "../lib/useUpdater";
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
@@ -19,12 +20,26 @@ function ok(value: boolean, label: string) {
|
||||
}
|
||||
}
|
||||
|
||||
function Consumer({ id, checking = false }: { id: string; checking?: boolean }) {
|
||||
function Consumer({ id, checking = false, channel = "" }: { id: string; checking?: boolean; channel?: string }) {
|
||||
const updater = useUpdater();
|
||||
return (
|
||||
<section>
|
||||
<output id={`${id}-status`}>{updater.status.kind}</output>
|
||||
{checking && <button id="check-update" type="button" onClick={() => void updater.check()}>Check</button>}
|
||||
<output id={`${id}-manual`}>
|
||||
{updater.status.kind === "error" && updater.status.manualHint ? "manual" : ""}
|
||||
</output>
|
||||
<output id={`${id}-received`}>
|
||||
{updater.status.kind === "downloading" ? updater.status.received : ""}
|
||||
</output>
|
||||
{checking && <button id={`${id}-check-update`} type="button" onClick={() => void updater.check(channel)}>Check</button>}
|
||||
<button id={`${id}-reset-stable`} type="button" onClick={() => updater.reset("stable")}>Reset stable</button>
|
||||
<button id={`${id}-reset-preview`} type="button" onClick={() => updater.reset("preview")}>Reset preview</button>
|
||||
{updater.status.kind === "available" && (
|
||||
<button id={`${id}-download`} type="button" onClick={() => updater.download(updater.status.info)}>Download</button>
|
||||
)}
|
||||
{updater.status.kind === "downloaded" && (
|
||||
<button id={`${id}-install`} type="button" onClick={updater.install}>Install</button>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -47,7 +62,7 @@ await act(async () => {
|
||||
root.render(
|
||||
<UpdaterProvider>
|
||||
<Consumer id="banner" checking />
|
||||
<Consumer id="settings" />
|
||||
<Consumer id="settings" checking channel="preview" />
|
||||
</UpdaterProvider>,
|
||||
);
|
||||
});
|
||||
@@ -56,13 +71,316 @@ ok(document.getElementById("banner-status")?.textContent === "idle", "banner sta
|
||||
ok(document.getElementById("settings-status")?.textContent === "idle", "settings starts idle");
|
||||
|
||||
await act(async () => {
|
||||
(document.getElementById("check-update") as HTMLButtonElement).click();
|
||||
(document.getElementById("banner-check-update") as HTMLButtonElement).click();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
});
|
||||
|
||||
ok(document.getElementById("banner-status")?.textContent === "upToDate", "banner receives the check result");
|
||||
ok(document.getElementById("settings-status")?.textContent === "upToDate", "settings receives the same check result");
|
||||
|
||||
const debInfo = {
|
||||
available: true,
|
||||
current: "v1.0.0",
|
||||
latest: "v1.1.0",
|
||||
notes: "",
|
||||
channel: "stable",
|
||||
canSelfUpdate: true,
|
||||
manualOnly: false,
|
||||
installMode: "deb",
|
||||
requiresElevation: true,
|
||||
downloaded: false,
|
||||
downloadUrl: "https://example.invalid/download",
|
||||
assetSize: 42,
|
||||
};
|
||||
const installAttempts: Array<{
|
||||
requestId: string;
|
||||
version: string;
|
||||
channel: string;
|
||||
resolve: () => void;
|
||||
reject: (err: Error) => void;
|
||||
}> = [];
|
||||
const checkedChannels: string[] = [];
|
||||
window.go = {
|
||||
main: {
|
||||
App: {
|
||||
async CheckUpdate(channel: string) {
|
||||
checkedChannels.push(channel);
|
||||
return { ...debInfo, channel: channel === "preview" ? "preview" : "stable" };
|
||||
},
|
||||
async DownloadUpdateRequest(channel: string, expectedVersion: string, requestId: string) {
|
||||
return {
|
||||
requestId,
|
||||
version: expectedVersion,
|
||||
channel: channel === "preview" ? "preview" : "stable",
|
||||
path: "/tmp/update.deb",
|
||||
size: 42,
|
||||
sha256: "abc",
|
||||
};
|
||||
},
|
||||
InstallUpdateRequest(channel: string, expectedVersion: string, requestId: string) {
|
||||
return new Promise<void>((resolve, reject) => installAttempts.push({
|
||||
requestId,
|
||||
version: expectedVersion,
|
||||
channel,
|
||||
resolve,
|
||||
reject,
|
||||
}));
|
||||
},
|
||||
} as AppBindings,
|
||||
},
|
||||
};
|
||||
|
||||
await act(async () => {
|
||||
(document.getElementById("settings-check-update") as HTMLButtonElement).click();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
});
|
||||
ok(checkedChannels[0] === "preview", "settings check forwards the selected Preview channel");
|
||||
ok(document.getElementById("banner-status")?.textContent === "available", "deb update becomes available");
|
||||
ok(document.getElementById("settings-status")?.textContent === "available", "deb availability is shared");
|
||||
|
||||
await act(async () => {
|
||||
(document.getElementById("banner-download") as HTMLButtonElement).click();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
});
|
||||
ok(document.getElementById("banner-status")?.textContent === "downloaded", "deb download completes");
|
||||
|
||||
await act(async () => {
|
||||
(document.getElementById("banner-install") as HTMLButtonElement).click();
|
||||
});
|
||||
ok(document.getElementById("banner-status")?.textContent === "authorizing", "deb install starts authorizing");
|
||||
ok(document.getElementById("settings-status")?.textContent === "authorizing", "authorizing state is shared");
|
||||
|
||||
await act(async () => {
|
||||
__emitMockUpdater({
|
||||
requestId: installAttempts[0].requestId,
|
||||
version: installAttempts[0].version,
|
||||
channel: "preview",
|
||||
phase: "installing",
|
||||
received: 42,
|
||||
total: 42,
|
||||
});
|
||||
});
|
||||
ok(document.getElementById("banner-status")?.textContent === "installing", "helper phase advances to installing");
|
||||
|
||||
await act(async () => {
|
||||
__emitMockUpdater({
|
||||
requestId: installAttempts[0].requestId,
|
||||
version: installAttempts[0].version,
|
||||
channel: "preview",
|
||||
phase: "downloaded",
|
||||
received: 42,
|
||||
total: 42,
|
||||
});
|
||||
installAttempts[0]?.resolve();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
});
|
||||
ok(document.getElementById("banner-status")?.textContent === "downloaded", "authorization cancellation returns to downloaded");
|
||||
|
||||
await act(async () => {
|
||||
(document.getElementById("banner-install") as HTMLButtonElement).click();
|
||||
});
|
||||
ok(document.getElementById("banner-status")?.textContent === "authorizing", "retry re-enters authorizing");
|
||||
|
||||
await act(async () => {
|
||||
installAttempts[1]?.reject(new Error("update: manual update required: system update helper is unavailable"));
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
});
|
||||
ok(document.getElementById("banner-status")?.textContent === "error", "manual reclassification leaves busy state");
|
||||
ok(document.getElementById("banner-manual")?.textContent === "manual", "manual reclassification offers download fallback");
|
||||
ok(document.getElementById("settings-status")?.textContent === "error", "manual fallback error is shared");
|
||||
|
||||
let resolveSavedCheck!: (value: typeof debInfo) => void;
|
||||
let resolvePreviewCheck!: (value: typeof debInfo) => void;
|
||||
window.go.main.App.CheckUpdate = (selected: string) =>
|
||||
new Promise<typeof debInfo>((resolve) => {
|
||||
if (selected === "preview") resolvePreviewCheck = resolve;
|
||||
else resolveSavedCheck = resolve;
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
(document.getElementById("banner-check-update") as HTMLButtonElement).click();
|
||||
(document.getElementById("settings-check-update") as HTMLButtonElement).click();
|
||||
});
|
||||
await act(async () => {
|
||||
resolvePreviewCheck({ ...debInfo, channel: "preview", latest: "v1.2.0" });
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
});
|
||||
ok(document.getElementById("banner-status")?.textContent === "available", "newest channel check publishes its result");
|
||||
await act(async () => {
|
||||
resolveSavedCheck({ ...debInfo, available: false, channel: "stable", latest: "v1.0.0" });
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
});
|
||||
ok(document.getElementById("banner-status")?.textContent === "available", "stale channel check cannot overwrite the newest result");
|
||||
|
||||
let oldDownloadRequestId = "";
|
||||
let oldDownloadVersion = "";
|
||||
let resolveOldDownload!: (value: { requestId: string; version: string; channel: string; path: string; size: number; sha256: string }) => void;
|
||||
window.go.main.App.DownloadUpdateRequest = (_channel: string, expectedVersion: string, requestId: string) =>
|
||||
new Promise((resolve) => {
|
||||
oldDownloadRequestId = requestId;
|
||||
oldDownloadVersion = expectedVersion;
|
||||
resolveOldDownload = resolve;
|
||||
});
|
||||
await act(async () => {
|
||||
(document.getElementById("banner-download") as HTMLButtonElement).click();
|
||||
});
|
||||
ok(document.getElementById("banner-status")?.textContent === "downloading", "Preview download starts");
|
||||
await act(async () => {
|
||||
__emitMockUpdater({
|
||||
requestId: oldDownloadRequestId,
|
||||
version: `${oldDownloadVersion}-wrong`,
|
||||
channel: "preview",
|
||||
phase: "downloaded",
|
||||
received: 42,
|
||||
total: 42,
|
||||
});
|
||||
});
|
||||
ok(
|
||||
document.getElementById("banner-status")?.textContent === "downloading",
|
||||
"same-request wrong-version progress cannot complete the active Preview download",
|
||||
);
|
||||
await act(async () => {
|
||||
__emitMockUpdater({
|
||||
requestId: oldDownloadRequestId,
|
||||
version: oldDownloadVersion,
|
||||
channel: "stable",
|
||||
phase: "downloading",
|
||||
received: 41,
|
||||
total: 42,
|
||||
});
|
||||
});
|
||||
ok(document.getElementById("banner-received")?.textContent === "0", "wrong-channel progress cannot update the active Preview download");
|
||||
await act(async () => {
|
||||
(document.getElementById("banner-reset-preview") as HTMLButtonElement).click();
|
||||
__emitMockUpdater({
|
||||
requestId: oldDownloadRequestId,
|
||||
version: oldDownloadVersion,
|
||||
channel: "preview",
|
||||
phase: "downloaded",
|
||||
received: 42,
|
||||
total: 42,
|
||||
});
|
||||
resolveOldDownload({
|
||||
requestId: oldDownloadRequestId,
|
||||
version: oldDownloadVersion,
|
||||
channel: "preview",
|
||||
path: "/tmp/old.deb",
|
||||
size: 42,
|
||||
sha256: "abc",
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
});
|
||||
ok(document.getElementById("banner-status")?.textContent === "idle", "same-channel superseded progress and Promise completion stay ignored");
|
||||
|
||||
window.go.main.App.CheckUpdate = async (selected: string) => ({
|
||||
...debInfo,
|
||||
channel: selected === "preview" ? "preview" : "stable",
|
||||
latest: selected === "preview" ? "v1.2.0" : "v1.1.0",
|
||||
});
|
||||
window.go.main.App.DownloadUpdateRequest = async (selected: string, _expectedVersion: string, requestId: string) => ({
|
||||
requestId,
|
||||
version: "v1.2.0-preview.99",
|
||||
channel: selected === "preview" ? "preview" : "stable",
|
||||
path: "/tmp/new.deb",
|
||||
size: 42,
|
||||
sha256: "abc",
|
||||
});
|
||||
await act(async () => {
|
||||
(document.getElementById("settings-check-update") as HTMLButtonElement).click();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
});
|
||||
await act(async () => {
|
||||
(document.getElementById("banner-download") as HTMLButtonElement).click();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
});
|
||||
ok(document.getElementById("banner-status")?.textContent === "available", "download result for a different version is rejected");
|
||||
|
||||
window.go.main.App.DownloadUpdateRequest = async (selected: string, expectedVersion: string, requestId: string) => ({
|
||||
requestId,
|
||||
version: expectedVersion,
|
||||
channel: selected === "preview" ? "preview" : "stable",
|
||||
path: "/tmp/new.deb",
|
||||
size: 42,
|
||||
sha256: "abc",
|
||||
});
|
||||
await act(async () => {
|
||||
(document.getElementById("banner-download") as HTMLButtonElement).click();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
});
|
||||
ok(document.getElementById("banner-status")?.textContent === "downloaded", "Preview update is ready for install");
|
||||
await act(async () => {
|
||||
(document.getElementById("banner-install") as HTMLButtonElement).click();
|
||||
});
|
||||
const staleInstall = installAttempts[installAttempts.length - 1];
|
||||
ok(document.getElementById("banner-status")?.textContent === "authorizing", "Preview install starts");
|
||||
await act(async () => {
|
||||
(document.getElementById("banner-reset-stable") as HTMLButtonElement).click();
|
||||
__emitMockUpdater({
|
||||
requestId: staleInstall.requestId,
|
||||
version: staleInstall.version,
|
||||
channel: "preview",
|
||||
phase: "error",
|
||||
received: 0,
|
||||
total: 0,
|
||||
err: "old install failed",
|
||||
});
|
||||
staleInstall.reject(new Error("old Preview install failed"));
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
});
|
||||
ok(document.getElementById("banner-status")?.textContent === "idle", "superseded install progress and rejection stay ignored");
|
||||
|
||||
window.go.main.App.CheckUpdate = async () => ({ ...debInfo, channel: "stable" });
|
||||
await act(async () => {
|
||||
(document.getElementById("settings-check-update") as HTMLButtonElement).click();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
});
|
||||
ok(document.getElementById("banner-status")?.textContent === "error", "wrong-channel check response leaves the checking state");
|
||||
|
||||
window.go.main.App.CheckUpdate = async () => ({ ...debInfo, channel: "preview", latest: "v1.3.0" });
|
||||
await act(async () => {
|
||||
(document.getElementById("settings-check-update") as HTMLButtonElement).click();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
});
|
||||
ok(document.getElementById("banner-status")?.textContent === "available", "check can retry after a wrong-channel response");
|
||||
|
||||
delete window.go;
|
||||
|
||||
const channelTransitions: string[] = [];
|
||||
let releaseSave!: (saved: boolean) => void;
|
||||
const pendingSwitch = switchUpdaterChannel(
|
||||
"preview",
|
||||
(channel) => {
|
||||
channelTransitions.push(`invalidate:${channel}`);
|
||||
},
|
||||
(channel) =>
|
||||
new Promise<boolean>((resolve) => {
|
||||
channelTransitions.push(`save:${channel}`);
|
||||
releaseSave = resolve;
|
||||
}),
|
||||
async (channel) => {
|
||||
channelTransitions.push(`check:${channel}`);
|
||||
},
|
||||
);
|
||||
ok(channelTransitions.join(",") === "invalidate:preview,save:preview", "channel switch invalidates old updater state before persistence resolves");
|
||||
releaseSave(true);
|
||||
await pendingSwitch;
|
||||
ok(channelTransitions.join(",") === "invalidate:preview,save:preview,check:preview", "successful channel persistence checks the target pointer");
|
||||
|
||||
await switchUpdaterChannel(
|
||||
"stable",
|
||||
(channel) => {
|
||||
channelTransitions.push(`invalidate:${channel}`);
|
||||
},
|
||||
async (channel) => {
|
||||
channelTransitions.push(`failed:${channel}`);
|
||||
return false;
|
||||
},
|
||||
async (channel) => {
|
||||
channelTransitions.push(`unexpected:${channel}`);
|
||||
},
|
||||
);
|
||||
ok(!channelTransitions.includes("unexpected:stable"), "failed channel persistence does not check an unselected channel");
|
||||
|
||||
await act(async () => root.unmount());
|
||||
|
||||
console.log(`\n${passed} passed, ${failed} failed`);
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
// Run: tsx src/__tests__/use-controller-live-context.test.tsx
|
||||
|
||||
import { JSDOM } from "jsdom";
|
||||
import React, { act } from "react";
|
||||
import React, { act, useCallback, useSyncExternalStore } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { ContextPanel } from "../components/ContextPanel";
|
||||
import { StatusBar } from "../components/StatusBar";
|
||||
import type { AppBindings } from "../lib/bridge";
|
||||
import { LocaleProvider } from "../lib/i18n";
|
||||
import type { ContextInfo, ContextPanelInfo, EffortInfo, Meta, TabMeta, WireEvent } from "../lib/types";
|
||||
import type { BalanceInfo, ContextInfo, ContextPanelInfo, EffortInfo, Meta, TabMeta, WireEvent } from "../lib/types";
|
||||
import { useController } from "../lib/useController";
|
||||
|
||||
let passed = 0;
|
||||
@@ -72,7 +72,7 @@ function tabMeta(): TabMeta {
|
||||
|
||||
function meta(): Meta {
|
||||
return {
|
||||
label: "model",
|
||||
label: backendModel,
|
||||
ready: true,
|
||||
eventChannel: "agent:event",
|
||||
cwd: "/repo",
|
||||
@@ -137,6 +137,17 @@ let backendContext: ContextInfo = {
|
||||
};
|
||||
let contextCalls = 0;
|
||||
let contextLoader: (() => Promise<ContextInfo>) | undefined;
|
||||
let backendBalance: BalanceInfo = { available: true, display: "¥88.00" };
|
||||
let backendModel = "model";
|
||||
let balanceCalls = 0;
|
||||
let balanceLoader: (() => Promise<BalanceInfo>) | undefined;
|
||||
type ModelSwitchStep = {
|
||||
gate: ReturnType<typeof deferred<void>>;
|
||||
balance?: BalanceInfo;
|
||||
error?: Error;
|
||||
};
|
||||
const modelSwitchSteps: ModelSwitchStep[] = [];
|
||||
let modelSwitchCalls = 0;
|
||||
const stalePanelInfo: ContextPanelInfo = {
|
||||
usedTokens: 100,
|
||||
windowTokens: 1_000,
|
||||
@@ -175,7 +186,23 @@ window.go = {
|
||||
// must still keep the panel average aligned with StatusBar during bursts.
|
||||
ContextPanel: async () => stalePanelInfo,
|
||||
EffortForTab: async () => effort,
|
||||
BalanceForTab: async () => ({ available: false, display: "" }),
|
||||
BalanceForTab: async () => {
|
||||
balanceCalls += 1;
|
||||
return balanceLoader ? balanceLoader() : backendBalance;
|
||||
},
|
||||
SetModelForTab: async (_tabId, name) => {
|
||||
modelSwitchCalls += 1;
|
||||
const step = modelSwitchSteps.shift();
|
||||
if (!step) throw new Error("missing model switch step");
|
||||
await step.gate.promise;
|
||||
if (step.error) throw step.error;
|
||||
backendModel = name;
|
||||
backendBalance = step.balance ?? { available: false, display: "" };
|
||||
balanceLoader = undefined;
|
||||
},
|
||||
CloseTab: async () => {
|
||||
throw new Error("cannot close tab");
|
||||
},
|
||||
JobsForTab: async () => [],
|
||||
CheckpointsForTab: async () => [],
|
||||
HistoryForTab: async () => [],
|
||||
@@ -188,17 +215,33 @@ window.go = {
|
||||
|
||||
type Controller = ReturnType<typeof useController>;
|
||||
let controller: Controller | undefined;
|
||||
let controllerProbeRenders = 0;
|
||||
|
||||
function LiveProbe({ value }: { value: Controller }) {
|
||||
const subscribe = useCallback(
|
||||
(listener: () => void) => value.liveStore.subscribe(value.activeTabId, listener),
|
||||
[value.activeTabId, value.liveStore],
|
||||
);
|
||||
const getSnapshot = useCallback(
|
||||
() => value.liveStore.getSnapshot(value.activeTabId)?.text ?? "",
|
||||
[value.activeTabId, value.liveStore],
|
||||
);
|
||||
const text = useSyncExternalStore(subscribe, getSnapshot);
|
||||
return <span data-live-text>{text}</span>;
|
||||
}
|
||||
|
||||
function Probe() {
|
||||
controller = useController();
|
||||
controllerProbeRenders += 1;
|
||||
return (
|
||||
<LocaleProvider>
|
||||
<>
|
||||
<StatusBar
|
||||
context={controller.state.context}
|
||||
usage={controller.state.usage}
|
||||
balance={controller.state.balance}
|
||||
running={controller.state.running}
|
||||
items={["cache_avg"]}
|
||||
items={["cache_avg", "balance"]}
|
||||
/>
|
||||
<ContextPanel
|
||||
tabId={controller.activeTabId}
|
||||
@@ -212,6 +255,7 @@ function Probe() {
|
||||
sessionGen={controller.state.sessionGen}
|
||||
usageSeq={controller.state.usageSeq}
|
||||
/>
|
||||
<LiveProbe value={controller} />
|
||||
</>
|
||||
</LocaleProvider>
|
||||
);
|
||||
@@ -225,6 +269,10 @@ function renderedPanelAverage(): string {
|
||||
return document.querySelector(".context-panel__summary-rows .context-panel__mini-stat strong")?.textContent ?? "";
|
||||
}
|
||||
|
||||
function renderedBalance(): string {
|
||||
return document.querySelector('[data-statusbar-item="balance"] b')?.textContent ?? "";
|
||||
}
|
||||
|
||||
const rootEl = document.getElementById("root");
|
||||
if (!rootEl) throw new Error("missing root");
|
||||
const root = createRoot(rootEl);
|
||||
@@ -238,6 +286,7 @@ ok(
|
||||
await settleUntil(() => controller?.activeTabId === "tab-live-context" && controller.state.context.cacheMissTokens === 100),
|
||||
"initial completed-turn context loads",
|
||||
);
|
||||
ok(await settleUntil(() => renderedBalance() === "¥88.00"), "initial DeepSeek balance loads");
|
||||
const initialContextCalls = contextCalls;
|
||||
|
||||
backendContext = {
|
||||
@@ -263,6 +312,18 @@ eq(renderedAverage(), "90.00%", "status bar renders the live executor-era sessio
|
||||
eq(renderedPanelAverage(), "90.00%", "panel ignores its stale private snapshot and matches the status bar");
|
||||
ok(contextCalls > initialContextCalls, "usage triggers a new ContextUsageForTab snapshot");
|
||||
|
||||
const rendersBeforeTextBurst = controllerProbeRenders;
|
||||
await act(async () => {
|
||||
for (const handler of eventHandlers) {
|
||||
handler({ kind: "text", tabId: "tab-live-context", text: "one " });
|
||||
handler({ kind: "text", tabId: "tab-live-context", text: "two " });
|
||||
handler({ kind: "text", tabId: "tab-live-context", text: "three" });
|
||||
}
|
||||
await flushPromises(20);
|
||||
});
|
||||
eq(document.querySelector("[data-live-text]")?.textContent, "one two three", "live subscriber receives the coalesced text burst");
|
||||
eq(controllerProbeRenders, rendersBeforeTextBurst, "pure stream deltas do not re-render the controller owner");
|
||||
|
||||
backendContext = {
|
||||
used: 960,
|
||||
window: 1_000,
|
||||
@@ -330,6 +391,225 @@ eq(renderedAverage(), "99.00%", "late stale snapshot cannot regress the rendered
|
||||
eq(renderedPanelAverage(), "99.00%", "late stale snapshot cannot regress the panel average");
|
||||
contextLoader = undefined;
|
||||
|
||||
const staleBalance = deferred<BalanceInfo>();
|
||||
balanceLoader = () => staleBalance.promise;
|
||||
const balanceRaceStartCalls = balanceCalls;
|
||||
await act(async () => {
|
||||
for (const handler of eventHandlers) handler({ kind: "turn_done", tabId: "tab-live-context" });
|
||||
await flushPromises();
|
||||
});
|
||||
ok(await settleUntil(() => balanceCalls === balanceRaceStartCalls + 1), "pre-switch balance refresh starts");
|
||||
|
||||
const firstSwitchGate = deferred<void>();
|
||||
const latestSwitchGate = deferred<void>();
|
||||
modelSwitchSteps.push(
|
||||
{ gate: firstSwitchGate, balance: { available: true, display: "A 40.00" } },
|
||||
{ gate: latestSwitchGate, balance: { available: true, display: "B 25.00" } },
|
||||
);
|
||||
const modelSwitchStartCalls = modelSwitchCalls;
|
||||
let firstSwitchPromise: Promise<boolean> | undefined;
|
||||
let latestSwitchPromise: Promise<boolean> | undefined;
|
||||
await act(async () => {
|
||||
firstSwitchPromise = controller?.setModel("provider-a/model-a");
|
||||
latestSwitchPromise = controller?.setModel("provider-b/model-b");
|
||||
await flushPromises();
|
||||
});
|
||||
eq(renderedBalance(), "-", "starting a hot model switch immediately hides the DeepSeek balance");
|
||||
eq(modelSwitchCalls, modelSwitchStartCalls + 1, "rapid model switches enter the backend in click order");
|
||||
|
||||
firstSwitchGate.resolve();
|
||||
let firstSwitchResult: boolean | undefined;
|
||||
await act(async () => {
|
||||
firstSwitchResult = await firstSwitchPromise;
|
||||
await flushPromises();
|
||||
});
|
||||
eq(firstSwitchResult, false, "superseded model switch reports that it no longer owns the UI");
|
||||
ok(await settleUntil(() => modelSwitchCalls === modelSwitchStartCalls + 2), "latest model switch starts after the older backend call");
|
||||
eq(renderedBalance(), "-", "superseded switch cannot restore its provider balance");
|
||||
|
||||
latestSwitchGate.resolve();
|
||||
let latestSwitchResult: boolean | undefined;
|
||||
await act(async () => {
|
||||
latestSwitchResult = await latestSwitchPromise;
|
||||
await flushPromises();
|
||||
});
|
||||
eq(latestSwitchResult, true, "latest model switch owns the completed UI refresh");
|
||||
ok(await settleUntil(() => renderedBalance() === "B 25.00"), "latest model switch balance wins");
|
||||
|
||||
const coalescedFirstGate = deferred<void>();
|
||||
const coalescedLatestGate = deferred<void>();
|
||||
modelSwitchSteps.push(
|
||||
{ gate: coalescedFirstGate, balance: { available: true, display: "G 20.00" } },
|
||||
{ gate: coalescedLatestGate, balance: { available: true, display: "I 15.00" } },
|
||||
);
|
||||
const coalescedStartCalls = modelSwitchCalls;
|
||||
let coalescedFirst: Promise<boolean> | undefined;
|
||||
let coalescedMiddle: Promise<boolean> | undefined;
|
||||
let coalescedLatest: Promise<boolean> | undefined;
|
||||
await act(async () => {
|
||||
coalescedFirst = controller?.setModel("provider-g/model-g");
|
||||
coalescedMiddle = controller?.setModel("provider-h/model-h");
|
||||
coalescedLatest = controller?.setModel("provider-i/model-i");
|
||||
await flushPromises();
|
||||
});
|
||||
eq(modelSwitchCalls, coalescedStartCalls + 1, "only the active model switch enters the backend immediately");
|
||||
eq(await coalescedMiddle, false, "an unstarted intermediate model switch is coalesced");
|
||||
|
||||
coalescedFirstGate.resolve();
|
||||
await act(async () => {
|
||||
await coalescedFirst;
|
||||
await flushPromises();
|
||||
});
|
||||
ok(
|
||||
await settleUntil(() => modelSwitchCalls === coalescedStartCalls + 2),
|
||||
"the latest coalesced model switch starts after the active call",
|
||||
);
|
||||
coalescedLatestGate.resolve();
|
||||
let coalescedLatestResult: boolean | undefined;
|
||||
await act(async () => {
|
||||
coalescedLatestResult = await coalescedLatest;
|
||||
await flushPromises();
|
||||
});
|
||||
eq(coalescedLatestResult, true, "the latest coalesced model switch owns reconciliation");
|
||||
eq(backendModel, "provider-i/model-i", "the skipped intermediate model never reaches the backend");
|
||||
eq(modelSwitchCalls, coalescedStartCalls + 2, "three rapid picks perform only two controller rebuilds");
|
||||
|
||||
staleBalance.resolve({ available: true, display: "¥88.00" });
|
||||
await act(async () => {
|
||||
await flushPromises();
|
||||
});
|
||||
eq(renderedBalance(), "I 15.00", "late DeepSeek balance response cannot overwrite the latest switched provider");
|
||||
|
||||
const overlappingSuccessGate = deferred<void>();
|
||||
const overlappingFailureGate = deferred<void>();
|
||||
modelSwitchSteps.push(
|
||||
{ gate: overlappingSuccessGate, balance: { available: true, display: "A 40.00" } },
|
||||
{ gate: overlappingFailureGate, error: new Error("provider B failed") },
|
||||
);
|
||||
let overlappingSuccess: Promise<boolean> | undefined;
|
||||
let overlappingFailure: Promise<boolean> | undefined;
|
||||
await act(async () => {
|
||||
overlappingSuccess = controller?.setModel("provider-a/model-a");
|
||||
overlappingFailure = controller?.setModel("provider-b/model-b");
|
||||
await flushPromises();
|
||||
});
|
||||
overlappingSuccessGate.resolve();
|
||||
await act(async () => {
|
||||
await overlappingSuccess;
|
||||
await flushPromises();
|
||||
});
|
||||
eq(renderedBalance(), "-", "older successful switch stays hidden while the latest switch is pending");
|
||||
overlappingFailureGate.resolve();
|
||||
let overlappingFailureResult: boolean | undefined;
|
||||
await act(async () => {
|
||||
overlappingFailureResult = await overlappingFailure;
|
||||
await flushPromises();
|
||||
});
|
||||
eq(overlappingFailureResult, false, "latest failed switch reports failure");
|
||||
ok(
|
||||
await settleUntil(() => renderedBalance() === "A 40.00"),
|
||||
"failed latest switch refreshes the provider established by the older queued success",
|
||||
);
|
||||
eq(
|
||||
controller?.state.meta?.label,
|
||||
"provider-a/model-a",
|
||||
"failed latest switch reconciles metadata from the older queued success",
|
||||
);
|
||||
|
||||
const failedSwitchGate = deferred<void>();
|
||||
modelSwitchSteps.push({ gate: failedSwitchGate, error: new Error("session is busy") });
|
||||
balanceLoader = async () => ({ available: false, display: "", err: "balance fetch failed" });
|
||||
let failedSwitch: Promise<boolean> | undefined;
|
||||
await act(async () => {
|
||||
failedSwitch = controller?.setModel("provider-c/model-c");
|
||||
await flushPromises();
|
||||
});
|
||||
eq(renderedBalance(), "-", "a failing switch hides the outgoing balance while pending");
|
||||
failedSwitchGate.resolve();
|
||||
let failedSwitchResult: boolean | undefined;
|
||||
await act(async () => {
|
||||
failedSwitchResult = await failedSwitch;
|
||||
await flushPromises();
|
||||
});
|
||||
eq(failedSwitchResult, false, "failed model switch reports failure to its caller");
|
||||
eq(renderedBalance(), "A 40.00", "failed switch restores the known balance when its confirmation refresh fails");
|
||||
|
||||
const firstFailedQueueGate = deferred<void>();
|
||||
const latestFailedQueueGate = deferred<void>();
|
||||
modelSwitchSteps.push(
|
||||
{ gate: firstFailedQueueGate, error: new Error("provider E failed") },
|
||||
{ gate: latestFailedQueueGate, error: new Error("provider F failed") },
|
||||
);
|
||||
const failedQueueStartCalls = modelSwitchCalls;
|
||||
let firstFailedQueueSwitch: Promise<boolean> | undefined;
|
||||
let latestFailedQueueSwitch: Promise<boolean> | undefined;
|
||||
await act(async () => {
|
||||
firstFailedQueueSwitch = controller?.setModel("provider-e/model-e");
|
||||
latestFailedQueueSwitch = controller?.setModel("provider-f/model-f");
|
||||
await flushPromises();
|
||||
});
|
||||
eq(renderedBalance(), "-", "queued failing switches keep the outgoing balance hidden while pending");
|
||||
eq(modelSwitchCalls, failedQueueStartCalls + 1, "queued failing switches enter the backend in click order");
|
||||
|
||||
firstFailedQueueGate.resolve();
|
||||
let firstFailedQueueResult: boolean | undefined;
|
||||
await act(async () => {
|
||||
firstFailedQueueResult = await firstFailedQueueSwitch;
|
||||
await flushPromises();
|
||||
});
|
||||
eq(firstFailedQueueResult, false, "superseded queued failure does not own balance reconciliation");
|
||||
ok(
|
||||
await settleUntil(() => modelSwitchCalls === failedQueueStartCalls + 2),
|
||||
"latest queued failing switch starts after the older failure",
|
||||
);
|
||||
eq(renderedBalance(), "-", "superseded queued failure cannot restore the outgoing balance");
|
||||
|
||||
latestFailedQueueGate.resolve();
|
||||
let latestFailedQueueResult: boolean | undefined;
|
||||
await act(async () => {
|
||||
latestFailedQueueResult = await latestFailedQueueSwitch;
|
||||
await flushPromises();
|
||||
});
|
||||
eq(latestFailedQueueResult, false, "latest queued failure reports failure");
|
||||
eq(
|
||||
renderedBalance(),
|
||||
"A 40.00",
|
||||
"consecutive queued failures restore the pre-queue balance when confirmation fails",
|
||||
);
|
||||
balanceLoader = undefined;
|
||||
|
||||
const switchDuringFailedCloseGate = deferred<void>();
|
||||
modelSwitchSteps.push({
|
||||
gate: switchDuringFailedCloseGate,
|
||||
balance: { available: true, display: "D 10.00" },
|
||||
});
|
||||
let switchDuringFailedClose: Promise<boolean> | undefined;
|
||||
await act(async () => {
|
||||
switchDuringFailedClose = controller?.setModel("provider-d/model-d");
|
||||
await flushPromises();
|
||||
});
|
||||
await act(async () => {
|
||||
await controller?.closeTab("tab-live-context");
|
||||
await flushPromises();
|
||||
});
|
||||
switchDuringFailedCloseGate.resolve();
|
||||
let switchDuringFailedCloseResult: boolean | undefined;
|
||||
await act(async () => {
|
||||
switchDuringFailedCloseResult = await switchDuringFailedClose;
|
||||
await flushPromises();
|
||||
});
|
||||
eq(switchDuringFailedCloseResult, true, "failed close keeps the in-flight model switch current");
|
||||
eq(controller?.activeTabId, "tab-live-context", "failed close keeps the tab mounted");
|
||||
eq(
|
||||
controller?.state.meta?.label,
|
||||
"provider-d/model-d",
|
||||
"failed close preserves model metadata reconciliation",
|
||||
);
|
||||
ok(
|
||||
await settleUntil(() => renderedBalance() === "D 10.00"),
|
||||
"failed close preserves model balance reconciliation",
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
|
||||
@@ -7,8 +7,10 @@
|
||||
// panel keeps refreshing during sub-agent runs.
|
||||
// 3. A mid-turn context snapshot reporting used=0 does not collapse a gauge
|
||||
// that already shows real usage.
|
||||
// 4. A retry event repairs stale idle snapshots in either delivery order so
|
||||
// the turn remains stoppable.
|
||||
|
||||
import { initialState, reducer } from "../lib/useController";
|
||||
import { initialState, promptEventClock, reducer } from "../lib/useController";
|
||||
import type { WireEvent } from "../lib/types";
|
||||
|
||||
let passed = 0;
|
||||
@@ -57,6 +59,34 @@ function ev(s: typeof initialState, e: WireEvent) {
|
||||
eq(refreshed?.kind === "tool" ? refreshed.fileDiff?.diff : "", "@@ -1 +1 @@\n-old\n+new\n", "same-ID refresh replaces the live preview");
|
||||
eq(s.items.filter((it) => it.kind === "tool" && it.id === "c1").length, 1, "preview refresh never duplicates the card");
|
||||
|
||||
s = ev(s, { kind: "tool_dispatch", tool: {
|
||||
id: "c1",
|
||||
name: "write_file",
|
||||
args: '{"path":"a"}',
|
||||
readOnly: false,
|
||||
refreshed: true,
|
||||
resolvedName: "mcp__db__write",
|
||||
capabilityId: "mcp-tool:db/write",
|
||||
} } as WireEvent);
|
||||
const resolved = s.items.find((it) => it.kind === "tool" && it.id === "c1");
|
||||
eq(resolved?.kind === "tool" ? resolved.resolvedName : "", "mcp__db__write", "same-ID refresh stores resolved target");
|
||||
eq(resolved?.kind === "tool" ? resolved.capabilityId : "", "mcp-tool:db/write", "same-ID refresh stores capability id");
|
||||
eq(resolved?.kind === "tool" ? resolved.readOnly : true, false, "same-ID refresh replaces proxy read-only classification");
|
||||
|
||||
s = ev(s, { kind: "tool_result", tool: {
|
||||
id: "c1",
|
||||
name: "use_capability",
|
||||
args: '{"action":"call","capability_id":"mcp-tool:db/write-v2"}',
|
||||
readOnly: false,
|
||||
resolvedName: "mcp__db__write_v2",
|
||||
capabilityId: "mcp-tool:db/write-v2",
|
||||
output: "done",
|
||||
} } as WireEvent);
|
||||
const completed = s.items.find((it) => it.kind === "tool" && it.id === "c1");
|
||||
eq(completed?.kind === "tool" ? completed.resolvedName : "", "mcp__db__write_v2", "tool result also refreshes resolved target");
|
||||
eq(completed?.kind === "tool" ? completed.capabilityId : "", "mcp-tool:db/write-v2", "tool result also refreshes capability id");
|
||||
eq(completed?.kind === "tool" ? completed.readOnly : true, false, "tool result preserves resolved writer classification");
|
||||
|
||||
s = ev(s, { kind: "usage", usage: { promptTokens: 100, completionTokens: 50, totalTokens: 150, cacheHitTokens: 0, cacheMissTokens: 0 } } as WireEvent);
|
||||
eq(s.turnArgChars, 0, "usage event resets the streaming estimate");
|
||||
}
|
||||
@@ -97,5 +127,60 @@ function ev(s: typeof initialState, e: WireEvent) {
|
||||
eq(idle.context.used, 0, "idle used=0 snapshot applies (genuine reset)");
|
||||
}
|
||||
|
||||
// --- 4. retrying is authoritative foreground activity ---
|
||||
{
|
||||
let s = ev({ ...initialState }, { kind: "turn_started" } as WireEvent);
|
||||
s = reducer(s, {
|
||||
type: "backend_status",
|
||||
running: false,
|
||||
pendingPrompt: false,
|
||||
backgroundJobs: 0,
|
||||
cancelRequested: false,
|
||||
cancellable: false,
|
||||
});
|
||||
eq(s.running, false, "stale idle snapshot reproduces the hidden-stop state");
|
||||
|
||||
s = ev(s, { kind: "retrying", retryAttempt: 3, retryMax: 10 } as WireEvent);
|
||||
eq(s.retry?.attempt, 3, "retry status keeps the current attempt");
|
||||
eq(s.retry?.max, 10, "retry status keeps the retry budget");
|
||||
eq(s.running, true, "retry event restores the active turn");
|
||||
eq(s.turnActive, true, "retry event restores the turn epoch");
|
||||
eq(s.cancellable, true, "retry event keeps Stop and Escape cancellation available");
|
||||
eq(s.turnStartAt > 0, true, "retry event restores timing for a reattached turn");
|
||||
|
||||
const repaired = s;
|
||||
const completed = ev(repaired, { kind: "turn_done" } as WireEvent);
|
||||
eq(completed.running, false, "turn_done still ends the repaired turn");
|
||||
eq(completed.retry, undefined, "turn_done clears the retry indicator");
|
||||
|
||||
const staleSnapshotAt = promptEventClock();
|
||||
s = ev(repaired, { kind: "retrying", retryAttempt: 4, retryMax: 10 } as WireEvent);
|
||||
s = reducer(s, {
|
||||
type: "backend_status",
|
||||
running: false,
|
||||
pendingPrompt: false,
|
||||
backgroundJobs: 0,
|
||||
cancelRequested: false,
|
||||
cancellable: false,
|
||||
snapshotAt: staleSnapshotAt,
|
||||
});
|
||||
eq(s.running, true, "idle snapshot fetched before retry cannot hide Stop when it returns later");
|
||||
eq(s.turnActive, true, "idle snapshot fetched before retry cannot end the active turn");
|
||||
eq(s.cancellable, true, "idle snapshot fetched before retry preserves cancellation");
|
||||
eq(s.retry?.attempt, 4, "stale idle snapshot preserves the newer retry status");
|
||||
|
||||
s = reducer(s, {
|
||||
type: "backend_status",
|
||||
running: false,
|
||||
pendingPrompt: false,
|
||||
backgroundJobs: 0,
|
||||
cancelRequested: false,
|
||||
cancellable: false,
|
||||
snapshotAt: Number.MAX_SAFE_INTEGER,
|
||||
});
|
||||
eq(s.running, false, "fresh idle snapshot can reconcile a missed turn_done");
|
||||
eq(s.retry, undefined, "fresh idle snapshot clears the retry indicator");
|
||||
}
|
||||
|
||||
process.stdout.write(`\n${passed} passed, ${failed} failed\n`);
|
||||
if (failed > 0) process.exit(1);
|
||||
@@ -67,6 +67,8 @@ function installDom() {
|
||||
globalThis.localStorage = dom.window.localStorage;
|
||||
globalThis.requestAnimationFrame = dom.window.requestAnimationFrame.bind(dom.window);
|
||||
globalThis.cancelAnimationFrame = dom.window.cancelAnimationFrame.bind(dom.window);
|
||||
(dom.window.HTMLElement.prototype as unknown as { attachEvent: () => void }).attachEvent = () => {};
|
||||
(dom.window.HTMLElement.prototype as unknown as { detachEvent: () => void }).detachEvent = () => {};
|
||||
Object.defineProperty(dom.window.HTMLElement.prototype, "scrollIntoView", { configurable: true, value: () => {} });
|
||||
Object.defineProperty(dom.window.HTMLElement.prototype, "offsetWidth", {
|
||||
configurable: true,
|
||||
@@ -769,6 +771,98 @@ console.log("\nworkspace changes git errors");
|
||||
dom.window.close();
|
||||
}
|
||||
|
||||
{
|
||||
const { dom, root } = await renderFilesWorkspace({
|
||||
ListDirForTab: async (_tabId, dir) => dir === ""
|
||||
? [
|
||||
{ name: "code.ts", isDir: false },
|
||||
{ name: "README.md", isDir: false },
|
||||
]
|
||||
: [],
|
||||
ReadFileForTab: async (_tabId, path) => ({
|
||||
path,
|
||||
body: path === "README.md" ? "# Documentation" : "const value = 42;",
|
||||
size: 17,
|
||||
truncated: false,
|
||||
binary: false,
|
||||
}),
|
||||
});
|
||||
|
||||
await waitFor("searchable code file", () => document.querySelector('[data-workspace-path="code.ts"]') != null);
|
||||
await act(async () => {
|
||||
document
|
||||
.querySelector<HTMLButtonElement>('[data-workspace-path="code.ts"]')
|
||||
?.dispatchEvent(new window.MouseEvent("click", { bubbles: true }));
|
||||
await flushPromises();
|
||||
});
|
||||
await waitFor("code preview search action", () => document.querySelector('button[aria-label="Find"]') != null);
|
||||
ok(
|
||||
document.querySelector('button[aria-label="Find"]') != null,
|
||||
"searchable code previews expose a visible search action",
|
||||
);
|
||||
|
||||
const filterInput = document.querySelector<HTMLInputElement>('input[placeholder="Filter files…"]');
|
||||
await act(async () => {
|
||||
filterInput?.focus();
|
||||
filterInput?.dispatchEvent(new window.KeyboardEvent("keydown", {
|
||||
key: "f",
|
||||
ctrlKey: true,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
}));
|
||||
await flushPromises();
|
||||
});
|
||||
await waitFor("panel-scoped code search", () => document.querySelector(".code-search__input") != null);
|
||||
ok(
|
||||
document.activeElement === document.querySelector(".code-search__input"),
|
||||
"workspace find shortcut opens and focuses code search from the file filter",
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
document
|
||||
.querySelector<HTMLButtonElement>(".code-search__close")
|
||||
?.dispatchEvent(new window.MouseEvent("click", { bubbles: true }));
|
||||
await flushPromises();
|
||||
});
|
||||
await waitFor("closed code search", () => document.querySelector(".code-search") == null);
|
||||
await act(async () => {
|
||||
document
|
||||
.querySelector<HTMLButtonElement>('button[aria-label="Find"]')
|
||||
?.dispatchEvent(new window.MouseEvent("click", { bubbles: true }));
|
||||
await flushPromises();
|
||||
});
|
||||
await waitFor("button-opened code search", () => document.querySelector(".code-search__input") != null);
|
||||
ok(
|
||||
document.activeElement === document.querySelector(".code-search__input"),
|
||||
"visible search action opens and focuses the same search UI",
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
document
|
||||
.querySelector<HTMLButtonElement>('[data-workspace-path="README.md"]')
|
||||
?.dispatchEvent(new window.MouseEvent("click", { bubbles: true }));
|
||||
await flushPromises();
|
||||
});
|
||||
await waitFor("markdown preview", () => document.body.textContent?.includes("Documentation") === true);
|
||||
ok(
|
||||
document.querySelector('button[aria-label="Find"]') == null,
|
||||
"Markdown previews do not expose the code-search action",
|
||||
);
|
||||
const markdownFindEvent = new window.KeyboardEvent("keydown", {
|
||||
key: "f",
|
||||
ctrlKey: true,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
});
|
||||
filterInput?.dispatchEvent(markdownFindEvent);
|
||||
ok(!markdownFindEvent.defaultPrevented, "Markdown previews preserve the host find shortcut");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
dom.window.close();
|
||||
}
|
||||
|
||||
{
|
||||
const javaIcon = workspaceFileIcon("App.java");
|
||||
const markdownIcon = workspaceFileIcon("README.md");
|
||||
|
||||
@@ -14,6 +14,9 @@ let passed = 0;
|
||||
let failed = 0;
|
||||
const testDir = dirname(fileURLToPath(import.meta.url));
|
||||
const appSource = readFileSync(resolve(testDir, "../App.tsx"), "utf8");
|
||||
const stylesSource = readFileSync(resolve(testDir, "../styles.css"), "utf8");
|
||||
const terminalPanelSource = readFileSync(resolve(testDir, "../components/TerminalPanel.tsx"), "utf8");
|
||||
const terminalRailSource = readFileSync(resolve(testDir, "../components/TerminalSessionRail.tsx"), "utf8");
|
||||
|
||||
function eq(a: unknown, b: unknown, label: string) {
|
||||
if (a === b) {
|
||||
@@ -156,6 +159,78 @@ eq(
|
||||
true,
|
||||
"opening the dock persists the expanded preference for the next launch",
|
||||
);
|
||||
eq(
|
||||
/rightDockMode === "terminal"[\s\S]*?workspacePanelRenderWidth >= rightDockMinRenderWidth/.test(appSource),
|
||||
true,
|
||||
"terminal remains renderable when a narrow viewport cannot fit the side dock",
|
||||
);
|
||||
eq(
|
||||
/const addTerminalOutputToComposer = useCallback\(async \(sessionId: string\) => \{[\s\S]*?app\.TerminalOutputForTab\(activeTabId, sessionId\)[\s\S]*?addWorkspaceTextToComposer\(/.test(appSource),
|
||||
true,
|
||||
"terminal output reaches chat only through the explicit add-output action",
|
||||
);
|
||||
eq(
|
||||
/@media \(max-width: 820px\) \{[\s\S]*?\.layout--terminal-open \.workbench-dock[\s\S]*?display: flex !important/.test(stylesSource),
|
||||
true,
|
||||
"terminal drawer stays visible on narrow viewports",
|
||||
);
|
||||
eq(
|
||||
/\.layout--terminal-open \.workbench-dock \{[\s\S]*?padding-bottom: var\(--statusbar-height\)/.test(stylesSource),
|
||||
true,
|
||||
"terminal drawer reserves the fixed status bar safe area",
|
||||
);
|
||||
eq(
|
||||
/\.layout--terminal-open \.workbench-dock__tools \{\s*display: none;\s*\}/.test(stylesSource),
|
||||
true,
|
||||
"terminal drawer hides the redundant workspace tab strip",
|
||||
);
|
||||
eq(
|
||||
/\.app--creation \.layout--creation-chrome-hidden\.layout--terminal-open \{[\s\S]*?grid-template-rows: minmax\(0, 1fr\) minmax\(220px, min\(42vh, 440px\)\)/.test(stylesSource)
|
||||
&& /\.app--creation \.layout--creation-chrome-hidden\.layout--terminal-open \.workbench-dock \{[\s\S]*?grid-row: 2/.test(stylesSource),
|
||||
true,
|
||||
"creation style keeps the terminal drawer below the chat pane",
|
||||
);
|
||||
eq(
|
||||
/sessions\.length > 0 && \([\s\S]*?<TerminalSessionRail/.test(terminalPanelSource),
|
||||
true,
|
||||
"the single terminal session keeps a visible close control",
|
||||
);
|
||||
eq(
|
||||
/const syncWorkspace = useTerminalStore[\s\S]*?const capabilityChanged = previous\.tabId === tabId && previous\.readOnly !== readOnly[\s\S]*?void syncWorkspace\(tabId, capabilityChanged\)/.test(terminalPanelSource),
|
||||
true,
|
||||
"terminal panel refreshes changed capability while reusing an in-flight first-open request",
|
||||
);
|
||||
eq(
|
||||
/readOnly=\{Boolean\(activeTab\?\.readOnly\)\}/.test(appSource)
|
||||
&& /const terminalReadOnly = readOnly \|\| Boolean\(workspace\?\.readOnly\)/.test(terminalPanelSource),
|
||||
true,
|
||||
"terminal controls follow the active tab read-only boundary",
|
||||
);
|
||||
eq(
|
||||
/terminal-session-rail__new|onNew/.test(terminalRailSource),
|
||||
false,
|
||||
"terminal tab strip does not duplicate the header's new-session action",
|
||||
);
|
||||
eq(
|
||||
/className="terminal-shell-select"[\s\S]*?shellOptions\.map/.test(terminalPanelSource),
|
||||
true,
|
||||
"terminal header renders the backend-approved shell options",
|
||||
);
|
||||
eq(
|
||||
/createSession\(tabId, "\.", selectedShellId\)/.test(terminalPanelSource),
|
||||
true,
|
||||
"new terminal sessions use the selected shell",
|
||||
);
|
||||
eq(
|
||||
/createSession\(tabId, "\.", "default"\)/.test(terminalPanelSource),
|
||||
false,
|
||||
"new terminal sessions are not hard-coded to the default shell",
|
||||
);
|
||||
eq(
|
||||
/const TerminalPanel = lazy\(\(\) => import\("\.\/components\/TerminalPanel"\)/.test(appSource),
|
||||
true,
|
||||
"terminal and xterm load only when the terminal drawer opens",
|
||||
);
|
||||
|
||||
console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`);
|
||||
if (failed > 0) process.exit(1);
|
||||
Loaded 100 of 643 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user