mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 10:45:59 -10:00
Merge remote-tracking branch 'origin/studio' into own/11761
# Conflicts: # internal/model/anthropic/anthropic.go
This commit is contained in:
commit
8931d3e612
1071 files changed
+43410
-7736
No files matched your search
Binary file not shown.
|
After Width: | Height: | Size: 2.4 KiB |
+20
-13
@@ -26,7 +26,9 @@ jobs:
|
||||
# merges. Gated jobs skip only on an explicit `false` output: wrapped in
|
||||
# `always()`, a failed `changes` job (or a missing output) makes them run
|
||||
# the full matrix instead of silently passing required checks as skipped.
|
||||
# Pushes to main-v2 always run everything.
|
||||
# Pushes to main-v2 always run everything. Pull requests run the macOS and
|
||||
# Windows legs only when `platform` is not 'false'; the rule lives in
|
||||
# scripts/ci-platform-scope.sh and fails toward running them.
|
||||
changes:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
@@ -34,13 +36,14 @@ jobs:
|
||||
desktop: ${{ steps.filter.outputs.desktop }}
|
||||
sdk: ${{ steps.filter.outputs.sdk }}
|
||||
scripts: ${{ steps.filter.outputs.scripts }}
|
||||
platform: ${{ steps.filter.outputs.platform }}
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- id: filter
|
||||
run: |
|
||||
code=true; desktop=true; sdk=true; scripts=true
|
||||
code=true; desktop=true; sdk=true; scripts=true; platform=true
|
||||
base=""
|
||||
if [ "${{ github.event_name }}" = "pull_request" ]; then
|
||||
base="${{ github.event.pull_request.base.sha }}"
|
||||
@@ -48,7 +51,7 @@ jobs:
|
||||
base="${{ github.event.before }}"
|
||||
fi
|
||||
if [ -n "$base" ] && git cat-file -e "$base^{commit}" 2>/dev/null; then
|
||||
files=$(git diff --name-only "$base" HEAD)
|
||||
files=$(git diff --name-only --no-renames "$base" HEAD)
|
||||
if [ -n "$files" ]; then
|
||||
code=false; desktop=false; sdk=false; scripts=false
|
||||
# Root-module CI: desktop/ is a separate module, so only the
|
||||
@@ -63,9 +66,10 @@ jobs:
|
||||
if echo "$files" | grep -qE '^(sdk/|internal/ext/extension/)'; then sdk=true; fi
|
||||
# The release-note tests render every committed Studio note; npm/ holds the publish tests.
|
||||
if echo "$files" | grep -qE '^(scripts/|npm/|release-notes/studio/|\.github/workflows/release-studio\.yml$)'; then scripts=true; fi
|
||||
platform=$(git diff -z --name-only --no-renames "$base" HEAD | bash scripts/ci-platform-scope.sh)
|
||||
fi
|
||||
fi
|
||||
{ echo "code=$code"; echo "desktop=$desktop"; echo "sdk=$sdk"; echo "scripts=$scripts"; } >> "$GITHUB_OUTPUT"
|
||||
{ echo "code=$code"; echo "desktop=$desktop"; echo "sdk=$sdk"; echo "scripts=$scripts"; echo "platform=$platform"; } >> "$GITHUB_OUTPUT"
|
||||
|
||||
release-notes-scripts:
|
||||
needs: changes
|
||||
@@ -77,14 +81,14 @@ jobs:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7
|
||||
with:
|
||||
node-version: "22"
|
||||
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
cache: true
|
||||
- run: node --test scripts/cli-archive-build.test.mjs scripts/release-credits.test.mjs scripts/update-contributors.test.mjs scripts/update-acknowledgments.test.mjs scripts/markdown-refs.test.mjs scripts/studio-release-notes.test.mjs scripts/publish-cli-pointer.test.mjs scripts/studio-release-flags.test.mjs scripts/studio-notes-publish.test.mjs scripts/brew-cask-guard.test.mjs scripts/feedback-sync.test.mjs npm/publish.test.mjs
|
||||
- run: node --test scripts/ci-platform-scope.test.mjs scripts/cli-archive-build.test.mjs scripts/release-credits.test.mjs scripts/update-contributors.test.mjs scripts/update-acknowledgments.test.mjs scripts/markdown-refs.test.mjs scripts/studio-release-notes.test.mjs scripts/publish-cli-pointer.test.mjs scripts/studio-release-flags.test.mjs scripts/studio-notes-publish.test.mjs scripts/withdraw-version.test.mjs scripts/brew-cask-guard.test.mjs scripts/feedback-sync.test.mjs npm/publish.test.mjs
|
||||
|
||||
# The ruleset requires the per-OS check names (test (ubuntu-latest) etc.).
|
||||
# A matrix job skipped at job level reports no per-leg checks at all, so
|
||||
@@ -93,6 +97,9 @@ jobs:
|
||||
# detector reports the diff is unrelated, every step skips and each leg
|
||||
# reports success in seconds. `always()` also keeps the legs alive when
|
||||
# the changes job itself fails (fail-open: an empty output != 'false').
|
||||
# A pull request with no platform-relevant change moves the macOS and
|
||||
# Windows legs onto an ubuntu runner with every step skipped: the check
|
||||
# names the ruleset requires still report, without a scarce runner.
|
||||
test:
|
||||
needs: changes
|
||||
if: always()
|
||||
@@ -100,9 +107,9 @@ jobs:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [ubuntu-latest, macos-latest, windows-latest]
|
||||
runs-on: ${{ matrix.os }}
|
||||
runs-on: ${{ github.event_name == 'pull_request' && needs.changes.outputs.platform == 'false' && 'ubuntu-latest' || matrix.os }}
|
||||
env:
|
||||
RUN_STEPS: ${{ github.event_name != 'pull_request' || needs.changes.outputs.code != 'false' }}
|
||||
RUN_STEPS: ${{ github.event_name != 'pull_request' || (needs.changes.outputs.code != 'false' && (matrix.os == 'ubuntu-latest' || needs.changes.outputs.platform != 'false')) }}
|
||||
steps:
|
||||
- if: env.RUN_STEPS == 'true'
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
@@ -302,9 +309,9 @@ jobs:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [ubuntu-latest, macos-latest, windows-latest]
|
||||
runs-on: ${{ matrix.os }}
|
||||
runs-on: ${{ github.event_name == 'pull_request' && needs.changes.outputs.platform == 'false' && 'ubuntu-latest' || matrix.os }}
|
||||
env:
|
||||
RUN_STEPS: ${{ github.event_name != 'pull_request' || needs.changes.outputs.sdk != 'false' }}
|
||||
RUN_STEPS: ${{ github.event_name != 'pull_request' || (needs.changes.outputs.sdk != 'false' && (matrix.os == 'ubuntu-latest' || needs.changes.outputs.platform != 'false')) }}
|
||||
defaults:
|
||||
run:
|
||||
working-directory: sdk/go
|
||||
@@ -409,7 +416,7 @@ jobs:
|
||||
# compile it against a real macOS toolchain.
|
||||
desktop-macos:
|
||||
needs: changes
|
||||
if: always() && (github.event_name != 'pull_request' || needs.changes.outputs.desktop != 'false')
|
||||
if: always() && (github.event_name != 'pull_request' || (needs.changes.outputs.desktop != 'false' && needs.changes.outputs.platform != 'false'))
|
||||
runs-on: macos-latest
|
||||
defaults:
|
||||
run:
|
||||
@@ -437,7 +444,7 @@ jobs:
|
||||
# (//go:build windows), so the ubuntu leg above never compiles them.
|
||||
desktop-windows:
|
||||
needs: changes
|
||||
if: always() && (github.event_name != 'pull_request' || needs.changes.outputs.desktop != 'false')
|
||||
if: always() && (github.event_name != 'pull_request' || (needs.changes.outputs.desktop != 'false' && needs.changes.outputs.platform != 'false'))
|
||||
runs-on: windows-latest
|
||||
defaults:
|
||||
run:
|
||||
@@ -547,7 +554,7 @@ jobs:
|
||||
run: go test -coverprofile=coverage.out -covermode=atomic ./...
|
||||
|
||||
- name: upload coverage
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7
|
||||
with:
|
||||
name: coverage-report
|
||||
path: coverage.out
|
||||
|
||||
@@ -43,20 +43,20 @@ jobs:
|
||||
if: matrix.branch != ''
|
||||
run: echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2
|
||||
uses: github/codeql-action/init@24c54180a607b1449ed407dd24f251e4e9147c8d # v4.38.3
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
build-mode: ${{ matrix.language == 'go' && 'autobuild' || 'none' }}
|
||||
- name: Perform CodeQL Analysis
|
||||
if: matrix.branch == ''
|
||||
uses: github/codeql-action/analyze@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2
|
||||
uses: github/codeql-action/analyze@24c54180a607b1449ed407dd24f251e4e9147c8d # v4.38.3
|
||||
with:
|
||||
category: "/language:${{ matrix.language }}"
|
||||
# Results from a checked-out line are filed under that line's ref, not the
|
||||
# default branch the schedule happened to run from.
|
||||
- name: Perform CodeQL Analysis (${{ matrix.branch }})
|
||||
if: matrix.branch != ''
|
||||
uses: github/codeql-action/analyze@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2
|
||||
uses: github/codeql-action/analyze@24c54180a607b1449ed407dd24f251e4e9147c8d # v4.38.3
|
||||
with:
|
||||
category: "/language:${{ matrix.language }}"
|
||||
ref: refs/heads/${{ matrix.branch }}
|
||||
|
||||
@@ -24,7 +24,7 @@ jobs:
|
||||
with:
|
||||
version: 10
|
||||
run_install: false
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7
|
||||
with:
|
||||
node-version: "24"
|
||||
cache: pnpm
|
||||
|
||||
@@ -20,7 +20,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: npm
|
||||
|
||||
@@ -24,7 +24,7 @@ jobs:
|
||||
with:
|
||||
version: 10
|
||||
run_install: false
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7
|
||||
with:
|
||||
node-version: "24"
|
||||
cache: pnpm
|
||||
|
||||
@@ -17,7 +17,7 @@ jobs:
|
||||
environment: production
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: npm
|
||||
|
||||
@@ -28,7 +28,7 @@ jobs:
|
||||
version: 10
|
||||
run_install: false
|
||||
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7
|
||||
with:
|
||||
node-version: "24"
|
||||
cache: pnpm
|
||||
|
||||
@@ -55,7 +55,7 @@ jobs:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
- if: steps.gate.outputs.run == 'true'
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7
|
||||
with:
|
||||
node-version: '22'
|
||||
- name: Sync
|
||||
|
||||
@@ -202,7 +202,7 @@ jobs:
|
||||
version: 10
|
||||
run_install: false
|
||||
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7
|
||||
with:
|
||||
node-version: "24"
|
||||
cache: pnpm
|
||||
@@ -210,12 +210,13 @@ jobs:
|
||||
desktop/frontend-next/pnpm-lock.yaml
|
||||
desktop/electron/pnpm-lock.yaml
|
||||
|
||||
# electron-builder shells out to dpkg-deb through fakeroot for the .deb,
|
||||
# and carries its own NSIS -- the toolchain the Wails line installed by
|
||||
# hand (GTK, WebKitGTK, nfpm, a pinned NSIS) is not this shell's.
|
||||
# electron-builder shells out to dpkg-deb through fakeroot for the .deb and
|
||||
# to rpmbuild for the .rpm, and carries its own NSIS -- the toolchain the
|
||||
# Wails line installed by hand (GTK, WebKitGTK, nfpm, a pinned NSIS) is
|
||||
# not this shell's.
|
||||
- name: Install Linux packaging deps
|
||||
if: runner.os == 'Linux'
|
||||
run: sudo apt-get update && sudo apt-get install -y fakeroot
|
||||
run: sudo apt-get update && sudo apt-get install -y fakeroot rpm
|
||||
|
||||
# The same Developer ID and notary credentials the desktop line uses: one
|
||||
# certificate signs any bundle identifier its team owns, so Studio does not
|
||||
@@ -363,7 +364,7 @@ jobs:
|
||||
# run nothing but the signer, and windows-package packages in between.
|
||||
- name: Upload the unsigned Windows bundle for signing
|
||||
if: runner.os == 'Windows' && env.SIGN_WINDOWS == 'true'
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: unsigned-studio-windows-${{ matrix.goarch }}-bundle
|
||||
path: dist/${{ matrix.unpacked }}
|
||||
@@ -396,7 +397,7 @@ jobs:
|
||||
wait "$zip_pid"
|
||||
mv ../../dist-zip/ReasonixStudio-windows-${{ matrix.goarch }}.zip ../../dist/
|
||||
|
||||
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
if: runner.os != 'Windows' || env.SIGN_WINDOWS != 'true'
|
||||
with:
|
||||
name: ${{ matrix.artifact }}
|
||||
@@ -478,7 +479,7 @@ jobs:
|
||||
}
|
||||
if ($failed) { exit 1 }
|
||||
|
||||
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
||||
with:
|
||||
name: unsigned-studio-windows-${{ matrix.arch }}-bundle
|
||||
path: signing/app
|
||||
@@ -533,7 +534,7 @@ jobs:
|
||||
-ExpectedSubject $env:EXPECTED_SUBJECT
|
||||
"digest=$(Get-Content -Raw "$env:RUNNER_TEMP/payload-digest")" >> $env:GITHUB_OUTPUT
|
||||
|
||||
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: signed-studio-windows-payload-${{ matrix.arch }}
|
||||
path: signing/app
|
||||
@@ -575,7 +576,7 @@ jobs:
|
||||
version: 10
|
||||
run_install: false
|
||||
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7
|
||||
with:
|
||||
node-version: "24"
|
||||
cache: pnpm
|
||||
@@ -587,7 +588,7 @@ jobs:
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
# The signed payload is the whole bundle, so it is packaged as it came.
|
||||
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
||||
with:
|
||||
name: signed-studio-windows-payload-${{ matrix.arch }}
|
||||
path: dist/${{ matrix.unpacked }}
|
||||
@@ -613,7 +614,7 @@ jobs:
|
||||
wait "$zip_pid"
|
||||
mv ../../dist-zip/ReasonixStudio-windows-${{ matrix.arch }}.zip ../../dist/
|
||||
|
||||
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: packaged-studio-windows-${{ matrix.arch }}
|
||||
path: |
|
||||
@@ -648,12 +649,12 @@ jobs:
|
||||
ref: ${{ github.workflow_sha }}
|
||||
persist-credentials: false
|
||||
|
||||
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
||||
with:
|
||||
name: signed-studio-windows-payload-${{ matrix.arch }}
|
||||
path: payload
|
||||
|
||||
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
||||
with:
|
||||
name: packaged-studio-windows-${{ matrix.arch }}
|
||||
path: dist
|
||||
@@ -731,7 +732,7 @@ jobs:
|
||||
}
|
||||
if ($failed) { exit 1 }
|
||||
|
||||
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
||||
with:
|
||||
name: packaged-studio-windows-${{ matrix.arch }}
|
||||
path: dist
|
||||
@@ -794,7 +795,7 @@ jobs:
|
||||
$hash = (Get-FileHash -Algorithm SHA256 -LiteralPath $installer).Hash.ToLowerInvariant()
|
||||
"installer-sha256=$hash" >> $env:GITHUB_OUTPUT
|
||||
|
||||
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: studio-windows-signed-${{ matrix.arch }}
|
||||
path: dist/ReasonixStudio-*
|
||||
@@ -831,13 +832,13 @@ jobs:
|
||||
ref: ${{ needs.resolve.outputs.sha }}
|
||||
persist-credentials: false
|
||||
|
||||
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
||||
if: needs.windows-sign-installer.result == 'success'
|
||||
with:
|
||||
name: studio-windows-signed-${{ matrix.arch }}
|
||||
path: dist
|
||||
|
||||
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
||||
if: needs.windows-sign-installer.result != 'success'
|
||||
with:
|
||||
name: studio-build-windows-${{ matrix.arch }}
|
||||
@@ -911,7 +912,7 @@ jobs:
|
||||
[ -f "dist/$want" ] || { echo "::error::no dist/$want"; exit 1; }
|
||||
done
|
||||
|
||||
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: cli-archives
|
||||
path: dist/*
|
||||
@@ -970,28 +971,28 @@ jobs:
|
||||
# Every package by its exact name, never a pattern: any job without
|
||||
# secrets can upload an artifact, and a pattern would publish it. Windows
|
||||
# comes from the installer signing job when it ran, else the build leg.
|
||||
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
||||
with:
|
||||
name: studio-build-darwin-amd64
|
||||
path: staging/studio-build-darwin-amd64
|
||||
|
||||
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
||||
with:
|
||||
name: studio-build-darwin-arm64
|
||||
path: staging/studio-build-darwin-arm64
|
||||
|
||||
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
||||
with:
|
||||
name: studio-build-linux-amd64
|
||||
path: staging/studio-build-linux-amd64
|
||||
|
||||
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
||||
if: needs.windows-sign-installer.result == 'success'
|
||||
with:
|
||||
name: studio-windows-signed-amd64
|
||||
path: staging/studio-windows-amd64
|
||||
|
||||
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
||||
if: needs.windows-sign-installer.result == 'success'
|
||||
with:
|
||||
name: studio-windows-signed-arm64
|
||||
@@ -1034,19 +1035,19 @@ jobs:
|
||||
check "$dir" "ReasonixStudio-windows-$arch.zip" "${!archive}"
|
||||
done
|
||||
|
||||
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
||||
if: needs.windows-sign-installer.result != 'success'
|
||||
with:
|
||||
name: studio-build-windows-amd64
|
||||
path: staging/studio-windows-amd64
|
||||
|
||||
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
||||
if: needs.windows-sign-installer.result != 'success'
|
||||
with:
|
||||
name: studio-build-windows-arm64
|
||||
path: staging/studio-windows-arm64
|
||||
|
||||
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
||||
with:
|
||||
name: cli-archives
|
||||
path: staging/cli-archives
|
||||
@@ -1068,12 +1069,12 @@ jobs:
|
||||
find staging/cli-archives -type f -print0)
|
||||
ls -la dist
|
||||
# Two Windows artifacts (installer, portable zip) per architecture,
|
||||
# two per macOS architecture (dmg, zip) and the Linux .deb. Fewer
|
||||
# two per macOS architecture (dmg, zip) and the Linux .deb and .rpm. Fewer
|
||||
# means an artifact was lost between jobs, and a partial release is
|
||||
# worse than a failed one.
|
||||
count=$(find dist -type f -name 'ReasonixStudio-*' | wc -l)
|
||||
if [ "$count" -lt 9 ]; then
|
||||
echo "::error::expected 9 artifacts (Windows installer + zip per architecture, macOS dmg + zip per architecture, Linux .deb), found $count"
|
||||
if [ "$count" -lt 10 ]; then
|
||||
echo "::error::expected 10 artifacts (Windows installer + zip per architecture, macOS dmg + zip per architecture, Linux .deb and .rpm), found $count"
|
||||
exit 1
|
||||
fi
|
||||
# Each platform's update channel, and there is exactly one per
|
||||
@@ -1086,7 +1087,7 @@ jobs:
|
||||
# the version it is running with nothing saying so.
|
||||
# Each Windows architecture by name for the same reason: a glob passes
|
||||
# on whichever installer shipped.
|
||||
for required in 'ReasonixStudio-linux-*.deb' \
|
||||
for required in 'ReasonixStudio-linux-*.deb' 'ReasonixStudio-linux-*.rpm' \
|
||||
'ReasonixStudio-windows-amd64-installer.exe' 'ReasonixStudio-windows-arm64-installer.exe' \
|
||||
'ReasonixStudio-windows-amd64.zip' 'ReasonixStudio-windows-arm64.zip' \
|
||||
'ReasonixStudio-darwin-amd64.zip' 'ReasonixStudio-darwin-arm64.zip'; do
|
||||
@@ -1182,8 +1183,8 @@ jobs:
|
||||
fi
|
||||
{
|
||||
printf '%s\n\n' '---'
|
||||
printf "**macOS** 已用 Developer ID 签名并公证,打开时不需要清除隔离属性。%s**Linux** 从 \`.deb\` 安装。\n\n" "$windows"
|
||||
printf '%s\n' '三个平台都能自更新:Linux 走自己的包,Windows 运行下一个安装器,macOS 替换自身 bundle。'
|
||||
printf "**macOS** 已用 Developer ID 签名并公证,打开时不需要清除隔离属性。%s**Linux** Debian 系从 \`.deb\` 安装,Fedora、RHEL、openSUSE 等从 \`.rpm\` 安装(\`sudo dnf install ./ReasonixStudio-linux-amd64.rpm\`)。\n\n" "$windows"
|
||||
printf "%s\n" "三个平台都能自更新:Linux 的 \`.deb\` 走自己的包(\`.rpm\` 的自更新尚未提供,请手动下载新包),Windows 运行下一个安装器,macOS 替换自身 bundle。"
|
||||
} >> "$notes"
|
||||
assets=()
|
||||
for f in dist/*; do
|
||||
@@ -1403,7 +1404,7 @@ jobs:
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
cache: true
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7
|
||||
with:
|
||||
node-version: "24"
|
||||
registry-url: https://registry.npmjs.org
|
||||
@@ -1412,7 +1413,7 @@ jobs:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
VERSION: ${{ needs.resolve.outputs.version }}
|
||||
run: node npm/build.mjs "$VERSION" --publish
|
||||
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
||||
if: needs.resolve.outputs.candidate != 'true'
|
||||
with:
|
||||
name: cli-archives
|
||||
@@ -1466,7 +1467,7 @@ jobs:
|
||||
with:
|
||||
ref: ${{ needs.resolve.outputs.sha }}
|
||||
persist-credentials: false
|
||||
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
||||
with:
|
||||
name: cli-archives
|
||||
path: cli-archives
|
||||
|
||||
@@ -43,7 +43,7 @@ jobs:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7
|
||||
with:
|
||||
node-version: '22'
|
||||
- name: Test sweeper
|
||||
|
||||
@@ -46,7 +46,7 @@ jobs:
|
||||
version: 10
|
||||
run_install: false
|
||||
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7
|
||||
with:
|
||||
node-version: "24"
|
||||
cache: pnpm
|
||||
@@ -99,7 +99,7 @@ jobs:
|
||||
-c.extraMetadata.version="${{ steps.version.outputs.version }}"
|
||||
sha256sum ../../dist/ReasonixStudio-windows-amd64-installer.exe | tee -a "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: studio-selftest-windows-${{ steps.version.outputs.version }}
|
||||
path: dist/ReasonixStudio-windows-amd64-installer.exe
|
||||
|
||||
@@ -33,7 +33,7 @@ jobs:
|
||||
version: 10
|
||||
run_install: false
|
||||
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7
|
||||
with:
|
||||
node-version: "24"
|
||||
cache: pnpm
|
||||
@@ -114,7 +114,7 @@ jobs:
|
||||
run_install: false
|
||||
|
||||
- if: steps.spa.outputs.run == 'true'
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7
|
||||
with:
|
||||
node-version: "24"
|
||||
cache: pnpm
|
||||
@@ -136,14 +136,23 @@ jobs:
|
||||
python3 -m http.server 4399 --directory dist-perf >/dev/null 2>&1 &
|
||||
for _ in $(seq 30); do curl -sf http://localhost:4399/perf.html >/dev/null && break; sleep 1; done
|
||||
node perf/intrinsic.mjs
|
||||
node perf/firstscreen-fonts.mjs
|
||||
node perf/contrast.mjs
|
||||
node perf/wheel.mjs
|
||||
node perf/branch-tooltip.mjs
|
||||
node perf/selfcheck.mjs branch-tooltip
|
||||
|
||||
# A pull request with no platform-relevant change (scripts/ci-platform-scope.sh)
|
||||
# moves the Windows and macOS legs onto an ubuntu runner with every step
|
||||
# skipped, so the check names still report without a scarce runner. A push to
|
||||
# studio, or a failed `scope`, runs all three.
|
||||
desktop:
|
||||
name: desktop (${{ matrix.os }})
|
||||
runs-on: ${{ matrix.os }}
|
||||
needs: scope
|
||||
if: always()
|
||||
runs-on: ${{ github.event_name == 'pull_request' && needs.scope.outputs.platform == 'false' && matrix.os != 'ubuntu-22.04' && 'ubuntu-latest' || matrix.os }}
|
||||
env:
|
||||
RUN_STEPS: ${{ github.event_name != 'pull_request' || needs.scope.outputs.platform != 'false' || matrix.os == 'ubuntu-22.04' }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -152,9 +161,11 @@ jobs:
|
||||
run:
|
||||
working-directory: desktop
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
- if: env.RUN_STEPS == 'true'
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
|
||||
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7
|
||||
- if: env.RUN_STEPS == 'true'
|
||||
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7
|
||||
with:
|
||||
go-version-file: desktop/go.mod
|
||||
cache: true
|
||||
@@ -167,6 +178,7 @@ jobs:
|
||||
# carries build dependencies of its own.
|
||||
# Same command as `make studio-test`; this job is already in desktop/.
|
||||
- name: go test
|
||||
if: env.RUN_STEPS == 'true'
|
||||
run: go test ./...
|
||||
|
||||
# The host sits in the main module and nothing else builds it, so its
|
||||
@@ -174,7 +186,7 @@ jobs:
|
||||
# platform-neutral or the Electron bundle cannot ship it. Cross-compiling
|
||||
# is host-independent, so once is enough.
|
||||
- name: host stays pure Go and platform-neutral
|
||||
if: runner.os == 'Linux'
|
||||
if: env.RUN_STEPS == 'true' && runner.os == 'Linux'
|
||||
working-directory: .
|
||||
run: |
|
||||
for os in windows linux darwin; do
|
||||
@@ -183,12 +195,15 @@ jobs:
|
||||
|
||||
# The ARM64 leg queues on a scarcer runner pool, so a pull request runs it
|
||||
# only when it touches what the leg verifies; a push to studio always does.
|
||||
# The macOS and Windows package legs follow `platform`, the same rule as the
|
||||
# desktop matrix above.
|
||||
scope:
|
||||
name: package matrix
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
matrix: ${{ steps.pick.outputs.matrix }}
|
||||
arm64: ${{ steps.pick.outputs.arm64 }}
|
||||
platform: ${{ steps.pick.outputs.platform }}
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
@@ -200,9 +215,11 @@ jobs:
|
||||
BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
run: |
|
||||
arm64=true
|
||||
platform=true
|
||||
if [ "$EVENT" = pull_request ]; then
|
||||
arm64=false
|
||||
if git diff --name-only "$BASE_SHA" HEAD |
|
||||
platform=$(git diff -z --name-only --no-renames "$BASE_SHA" HEAD | bash scripts/ci-platform-scope.sh)
|
||||
if git diff --name-only --no-renames "$BASE_SHA" HEAD |
|
||||
grep -qE '^(desktop/electron/|desktop/cmd/studio-manifest/|desktop/go\.(mod|sum)$|cmd/reasonix-studio-host/|internal/platform/(update|appupdate)/|go\.(mod|sum)$|scripts/(windows-signing-lib|verify-windows-package|verify-windows-authenticode|install-smoke)\.ps1$|\.github/workflows/(studio|release-studio)\.yml$)'; then
|
||||
arm64=true
|
||||
fi
|
||||
@@ -212,13 +229,18 @@ jobs:
|
||||
{"os":"ubuntu-22.04","arch":"amd64","platform":"linux"},
|
||||
{"os":"windows-latest","arch":"amd64","platform":"windows","ebarch":"x64"}
|
||||
]'
|
||||
if [ "$arm64" = true ]; then
|
||||
if [ "$platform" = false ]; then
|
||||
legs="$(jq -c 'map(select(.platform == "linux"))' <<<"$legs")"
|
||||
elif [ "$arm64" = true ]; then
|
||||
legs="$(jq -c '. + [{"os":"windows-11-arm","arch":"arm64","platform":"windows","ebarch":"arm64"}]' <<<"$legs")"
|
||||
else
|
||||
legs="$(jq -c . <<<"$legs")"
|
||||
fi
|
||||
echo "arm64=$arm64" >> "$GITHUB_OUTPUT"
|
||||
echo "matrix={\"include\":$legs}" >> "$GITHUB_OUTPUT"
|
||||
{
|
||||
echo "arm64=$arm64"
|
||||
echo "platform=$platform"
|
||||
echo "matrix={\"include\":$legs}"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
# The packaging contract, on real artifacts rather than on the config that
|
||||
# describes them. Unsigned on purpose: signing and notarization are a
|
||||
@@ -250,16 +272,17 @@ jobs:
|
||||
version: 10
|
||||
run_install: false
|
||||
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7
|
||||
with:
|
||||
node-version: "24"
|
||||
cache: pnpm
|
||||
cache-dependency-path: desktop/electron/pnpm-lock.yaml
|
||||
|
||||
# electron-builder shells out to dpkg-deb through fakeroot for the .deb.
|
||||
# electron-builder shells out to dpkg-deb through fakeroot for the .deb and
|
||||
# to rpmbuild for the .rpm.
|
||||
- name: Install Linux packaging deps
|
||||
if: runner.os == 'Linux'
|
||||
run: sudo apt-get update && sudo apt-get install -y fakeroot
|
||||
run: sudo apt-get update && sudo apt-get install -y fakeroot rpm
|
||||
|
||||
- name: Build the SPA
|
||||
working-directory: desktop/frontend-next
|
||||
@@ -314,6 +337,27 @@ jobs:
|
||||
test -n "$dir"
|
||||
node desktop/electron/packaging/pe.js "$dir" "${{ matrix.arch }}"
|
||||
|
||||
# An update starts the installer with --updated, and that is what keeps the
|
||||
# shortcuts (and the taskbar pin hanging off them) from being deleted and
|
||||
# recreated. Installed twice over itself the way an update does it, the
|
||||
# desktop shortcut has to still be the same file, pointing at a path with
|
||||
# no version in it, or every update costs the user their pin.
|
||||
- name: An update keeps the shortcuts and they name no version
|
||||
if: matrix.platform == 'windows'
|
||||
shell: pwsh
|
||||
run: |
|
||||
$installer = (Get-ChildItem dist -Filter 'ReasonixStudio-windows-*-installer.exe' | Select-Object -First 1).FullName
|
||||
$dir = Join-Path $env:RUNNER_TEMP 'rx-update-guard'
|
||||
Start-Process $installer -ArgumentList '/S', "/D=$dir" -Wait
|
||||
$lnk = Join-Path ([Environment]::GetFolderPath('Desktop')) 'Reasonix Studio.lnk'
|
||||
$target = (New-Object -ComObject WScript.Shell).CreateShortcut($lnk).TargetPath
|
||||
if ($target -ne (Join-Path $dir 'Reasonix Studio.exe')) { throw "the desktop shortcut targets $target, not the stable executable" }
|
||||
$mark = [datetime]'2020-01-01'
|
||||
(Get-Item $lnk).LastWriteTime = $mark
|
||||
Start-Process $installer -ArgumentList '/S', '--updated', "/D=$dir" -Wait
|
||||
if ((Get-Item $lnk).LastWriteTime -ne $mark) { throw 'an update rewrote the desktop shortcut' }
|
||||
Start-Process (Join-Path $dir 'Uninstall Reasonix Studio.exe') -ArgumentList '/S', '/currentuser' -Wait
|
||||
|
||||
# The release packages a signed copy of the bundle and then reads both
|
||||
# packages back against it. The same recipe on an unsigned copy, so the
|
||||
# packaging contract (the installer's one application archive, the pinned
|
||||
@@ -369,7 +413,7 @@ jobs:
|
||||
|
||||
- name: Keep the arm64 installer for the x64 refusal check
|
||||
if: matrix.platform == 'windows' && matrix.arch == 'arm64'
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: studio-arm64-installer-ci
|
||||
path: dist/ReasonixStudio-windows-arm64-installer.exe
|
||||
@@ -416,6 +460,16 @@ jobs:
|
||||
echo "::error::the package is $name; dpkg holds that beside the installed reasonix-studio instead of upgrading it, and update.StudioLine asks for it by name"
|
||||
exit 1
|
||||
fi
|
||||
# The helper in the package accepts one package name, compiled in. It has
|
||||
# to be the one the package carries, or every in-app update is refused
|
||||
# as a different package after the download and signature succeed.
|
||||
dpkg-deb --fsys-tarfile "$deb" | tar -xO ./usr/lib/reasonix-studio/reasonix-studio-update-helper > "$RUNNER_TEMP/update-helper"
|
||||
chmod +x "$RUNNER_TEMP/update-helper"
|
||||
accepts=$("$RUNNER_TEMP/update-helper" package)
|
||||
if [ "$accepts" != "$name" ]; then
|
||||
echo "::error::the update helper in the package accepts $accepts but the package is $name"
|
||||
exit 1
|
||||
fi
|
||||
dpkg-deb -c "$deb" > contents.txt
|
||||
# The path is the polkit action's exec.path annotation, so it is the
|
||||
# only one authorization is granted for; root because polkit will not
|
||||
@@ -433,6 +487,31 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# The .rpm is read back for the same two properties as the .deb: it is the
|
||||
# package update.StudioLine names, and the helper pkexec runs as root is
|
||||
# owned by root and writable by no one else, or any local user could
|
||||
# replace it.
|
||||
- name: The .rpm is Studio's own package and carries what updates it
|
||||
if: runner.os == 'Linux'
|
||||
run: |
|
||||
rpm=$(ls dist/ReasonixStudio-linux-*.rpm)
|
||||
name=$(rpm -qp --qf '%{NAME}' "$rpm")
|
||||
if [ "$name" != "reasonix-studio" ]; then
|
||||
echo "::error::the package is $name; rpm holds that beside the installed reasonix-studio instead of upgrading it"
|
||||
exit 1
|
||||
fi
|
||||
rpm -qp --qf '[%{FILEMODES:perms} %{FILEUSERNAME} %{FILEGROUPNAME} %{FILENAMES}\n]' "$rpm" > contents.txt
|
||||
if ! grep -qxE -- '-rwxr-xr-x root root /usr/lib/reasonix-studio/reasonix-studio-update-helper' contents.txt; then
|
||||
echo "::error::no root-owned, executable, not-writable update helper at the path the polkit action names"
|
||||
grep reasonix-studio-update-helper contents.txt || echo "(not in the package at all)"
|
||||
exit 1
|
||||
fi
|
||||
if ! grep -qE -- '^-r[w-][x-]r-[x-]r-[x-] root root /usr/share/polkit-1/actions/io.reasonix.studio.update.policy$' contents.txt; then
|
||||
echo "::error::the polkit action is missing, or is not a root-owned file no one else can write"
|
||||
grep polkit contents.txt || echo "(not in the package at all)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# The Electron shell sets the package's glibc floor; a Go binary linked
|
||||
# with cgo would raise it to the runner's own.
|
||||
- name: The .deb's Go binaries are statically linked
|
||||
@@ -480,7 +559,7 @@ jobs:
|
||||
if: needs.scope.outputs.arm64 == 'true'
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
||||
with:
|
||||
name: studio-arm64-installer-ci
|
||||
path: installer
|
||||
|
||||
@@ -14,7 +14,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7
|
||||
with:
|
||||
node-version: '22'
|
||||
- name: Update README contributor tables
|
||||
|
||||
@@ -27,7 +27,7 @@ jobs:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7
|
||||
with:
|
||||
node-version: '22'
|
||||
- name: Test chart generator
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
name: Withdraw a Studio version
|
||||
|
||||
# Pulls a published Studio release out of the update catalog (or puts it back),
|
||||
# so installs stop being offered it without waiting for a new release. Only the
|
||||
# catalog changes: the release's files stay where they are, and a withdrawn
|
||||
# release can be restored with the same run.
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: "Version to withdraw or restore, for example 2.36.0"
|
||||
required: true
|
||||
type: string
|
||||
reason:
|
||||
description: "Why it is withdrawn (shown to users on that version); ignored when restoring"
|
||||
required: false
|
||||
type: string
|
||||
restore:
|
||||
description: "Put a withdrawn version back instead of withdrawing it"
|
||||
required: false
|
||||
type: boolean
|
||||
default: false
|
||||
|
||||
concurrency:
|
||||
group: studio-catalog
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
withdraw:
|
||||
name: ${{ inputs.restore && 'restore' || 'withdraw' }} ${{ inputs.version }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
environment: studio-release
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
ref: studio
|
||||
persist-credentials: false
|
||||
|
||||
- name: Update the Studio catalog
|
||||
env:
|
||||
VERSION: ${{ inputs.version }}
|
||||
REASON: ${{ inputs.reason }}
|
||||
RESTORE: ${{ inputs.restore }}
|
||||
R2_BUCKET: ${{ secrets.R2_BUCKET }}
|
||||
HAS_R2: ${{ secrets.R2_ACCESS_KEY_ID != '' && secrets.R2_SECRET_ACCESS_KEY != '' && secrets.R2_ACCOUNT_ID != '' && secrets.R2_BUCKET != '' }}
|
||||
run: |
|
||||
if [ "$HAS_R2" != "true" ]; then
|
||||
echo "::error::R2 credentials are not configured, so the catalog cannot be changed"
|
||||
exit 1
|
||||
fi
|
||||
if ! [[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.]+)?$ ]]; then
|
||||
echo "::error::version must look like 2.36.0"
|
||||
exit 1
|
||||
fi
|
||||
aws configure set aws_access_key_id "${{ secrets.R2_ACCESS_KEY_ID }}"
|
||||
aws configure set aws_secret_access_key "${{ secrets.R2_SECRET_ACCESS_KEY }}"
|
||||
aws configure set default.region auto
|
||||
endpoint="https://${{ secrets.R2_ACCOUNT_ID }}.r2.cloudflarestorage.com"
|
||||
at="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||
current=/tmp/studio-versions-current.json
|
||||
next=/tmp/studio-versions-next.json
|
||||
aws s3 cp "s3://${R2_BUCKET}/studio/versions.json" "$current" --endpoint-url "$endpoint"
|
||||
if [ "$RESTORE" = "true" ]; then
|
||||
bash scripts/withdraw-version.sh "$current" --restore "$VERSION" "$at" > "$next"
|
||||
jq -e --arg v "$VERSION" '.versions | map(select((.version | ltrimstr("v")) == $v)) | length == 1' "$next" >/dev/null
|
||||
else
|
||||
bash scripts/withdraw-version.sh "$current" "$VERSION" "$REASON" "$at" > "$next"
|
||||
jq -e --arg v "$VERSION" '(.versions | map(select((.version | ltrimstr("v")) == $v)) | length == 0) and (.withdrawn | map(select((.version | ltrimstr("v")) == $v)) | length == 1)' "$next" >/dev/null
|
||||
fi
|
||||
aws s3 cp "$next" "s3://${R2_BUCKET}/studio/versions.json" \
|
||||
--endpoint-url "$endpoint" \
|
||||
--content-type "application/json; charset=utf-8" \
|
||||
--cache-control "public, max-age=300, stale-if-error=86400"
|
||||
echo "Studio catalog updated for $VERSION (restore=$RESTORE)"
|
||||
|
||||
- name: Keep the GitHub release off the latest slot
|
||||
if: ${{ !inputs.restore }}
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
VERSION: ${{ inputs.version }}
|
||||
run: gh release edit "studio-v${VERSION}" --prerelease --latest=false --repo "$GITHUB_REPOSITORY"
|
||||
@@ -59,7 +59,7 @@
|
||||
|
||||
## ❤️Sponsor
|
||||
|
||||
<table><tr><td width="180"><a href="https://www.packyapi.ai/register?aff=rIbQ" target="_blank" rel="sponsored noopener noreferrer"><picture><source media="(prefers-color-scheme: dark)" srcset="./.github/sponsor/packycode-dark.svg"><img src="./.github/sponsor/packycode-light.svg" alt="PackyCode" width="150"></picture></a></td><td>Thanks to PackyCode for sponsoring this project! Access leading AI models through PackyCode with one API endpoint and one API key, with automatic failover and dedicated high-speed routes for Codex and Claude Code.</td></tr></table>
|
||||
<table><tr><td width="180" align="center" valign="middle"><a href="https://www.rightapi.ai/register?aff=e083bf73" target="_blank" rel="sponsored noopener noreferrer"><img src="./.github/sponsor/rightcode.png" alt="Right Code" width="64"></a></td><td valign="middle">Thanks to Right Code for sponsoring this project! Right Code reliably provides routing services for models such as Claude Code, Codex, and Gemini, with both pay-as-you-go and monthly subscription billing options available. Invoices are available upon top-up, and enterprise and team users can receive dedicated one-on-one support. Register via <a href="https://www.rightapi.ai/register?aff=e083bf73" target="_blank" rel="sponsored noopener noreferrer">this link</a>.</td></tr><tr><td width="180" align="center" valign="middle"><a href="https://www.packyapi.ai/register?aff=rIbQ" target="_blank" rel="sponsored noopener noreferrer"><picture><source media="(prefers-color-scheme: dark)" srcset="./.github/sponsor/packycode-dark.svg"><img src="./.github/sponsor/packycode-light.svg" alt="PackyCode" width="80"></picture></a></td><td valign="middle">Thanks to PackyCode for sponsoring this project! Access leading AI models through PackyCode with one API endpoint and one API key, with automatic failover and dedicated high-speed routes for Codex and Claude Code. Register via <a href="https://www.packyapi.ai/register?aff=rIbQ" target="_blank" rel="sponsored noopener noreferrer">this link</a>.</td></tr></table>
|
||||
|
||||
## What it is
|
||||
|
||||
@@ -278,9 +278,9 @@ A small list of folks whose work has shaped Reasonix the most — the current to
|
||||
| --- | --- | --- | --- |
|
||||
| [**SivanCola**](https://github.com/SivanCola) | [**KHG420**](https://github.com/KHG420) | [**ttmouse**](https://github.com/ttmouse) | [**lifu963**](https://github.com/lifu963) |
|
||||
| [**HUQIANTAO**](https://github.com/HUQIANTAO) | [**Harbor404**](https://github.com/Harbor404) | [**GTC2080**](https://github.com/GTC2080) | [**mchenziyi**](https://github.com/mchenziyi) |
|
||||
| [**Li-Charles-One**](https://github.com/Li-Charles-One) | [**light-front-theory**](https://github.com/light-front-theory) | [**eghrhegpe**](https://github.com/eghrhegpe) | [**Bernardxu123**](https://github.com/Bernardxu123) |
|
||||
| [**HaoyueQin**](https://github.com/HaoyueQin) | [**chuiwenwei**](https://github.com/chuiwenwei) | [**BuGlessRB**](https://github.com/BuGlessRB) | [**CVEngineer66**](https://github.com/CVEngineer66) |
|
||||
| [**JesonChou**](https://github.com/JesonChou) | [**lanshi17**](https://github.com/lanshi17) | [**par73e**](https://github.com/par73e) | [**SuMuxi66**](https://github.com/SuMuxi66) |
|
||||
| [**Li-Charles-One**](https://github.com/Li-Charles-One) | [**light-front-theory**](https://github.com/light-front-theory) | [**XTLine**](https://github.com/XTLine) | [**eghrhegpe**](https://github.com/eghrhegpe) |
|
||||
| [**noreply**](https://github.com/noreply) | [**chuiwenwei**](https://github.com/chuiwenwei) | [**xxoingr**](https://github.com/xxoingr) | [**Bernardxu123**](https://github.com/Bernardxu123) |
|
||||
| [**HaoyueQin**](https://github.com/HaoyueQin) | [**BuGlessRB**](https://github.com/BuGlessRB) | [**CVEngineer66**](https://github.com/CVEngineer66) | [**JesonChou**](https://github.com/JesonChou) |
|
||||
<!-- reasonix-top-contributors:end -->
|
||||
|
||||
Special thanks to [**Bernardxu123**](https://github.com/Bernardxu123) for designing the project logo and intro video.
|
||||
|
||||
+4
-4
@@ -57,7 +57,7 @@
|
||||
|
||||
## ❤️赞助商
|
||||
|
||||
<table><tr><td width="180"><a href="https://www.packyapi.ai/register?aff=rIbQ" target="_blank" rel="sponsored noopener noreferrer"><picture><source media="(prefers-color-scheme: dark)" srcset="./.github/sponsor/packycode-dark.svg"><img src="./.github/sponsor/packycode-light.svg" alt="PackyCode" width="150"></picture></a></td><td>感谢 PackyCode 赞助了本项目!PackyCode 是一家稳定、高效的 API 中转服务商,统一域名、统一密钥、智能容灾切换。</td></tr></table>
|
||||
<table><tr><td width="180" align="center" valign="middle"><a href="https://www.rightapi.ai/register?aff=e083bf73" target="_blank" rel="sponsored noopener noreferrer"><img src="./.github/sponsor/rightcode.png" alt="Right Code" width="64"></a></td><td valign="middle">感谢 Right Code 赞助了本项目!Right Code 稳定提供 Claude Code、Codex、Gemini 等模型的中转服务,并可选按量、包月两种计费模式。充值即可开票,企业、团队用户一对一对接。通过<a href="https://www.rightapi.ai/register?aff=e083bf73" target="_blank" rel="sponsored noopener noreferrer">此链接</a>注册。</td></tr><tr><td width="180" align="center" valign="middle"><a href="https://www.packyapi.ai/register?aff=rIbQ" target="_blank" rel="sponsored noopener noreferrer"><picture><source media="(prefers-color-scheme: dark)" srcset="./.github/sponsor/packycode-dark.svg"><img src="./.github/sponsor/packycode-light.svg" alt="PackyCode" width="80"></picture></a></td><td valign="middle">感谢 PackyCode 赞助了本项目!PackyCode 是一家稳定、高效的 API 中转服务商,统一域名、统一密钥、智能容灾切换。通过<a href="https://www.packyapi.ai/register?aff=rIbQ" target="_blank" rel="sponsored noopener noreferrer">此链接</a>注册。</td></tr></table>
|
||||
|
||||
## 它是什么
|
||||
|
||||
@@ -260,9 +260,9 @@ CLI 进阶用法和详细配置见 **[CLI 命令参考](./docs/CLI.zh-CN.md)**
|
||||
| --- | --- | --- | --- |
|
||||
| [**SivanCola**](https://github.com/SivanCola) | [**KHG420**](https://github.com/KHG420) | [**ttmouse**](https://github.com/ttmouse) | [**lifu963**](https://github.com/lifu963) |
|
||||
| [**HUQIANTAO**](https://github.com/HUQIANTAO) | [**Harbor404**](https://github.com/Harbor404) | [**GTC2080**](https://github.com/GTC2080) | [**mchenziyi**](https://github.com/mchenziyi) |
|
||||
| [**Li-Charles-One**](https://github.com/Li-Charles-One) | [**light-front-theory**](https://github.com/light-front-theory) | [**eghrhegpe**](https://github.com/eghrhegpe) | [**Bernardxu123**](https://github.com/Bernardxu123) |
|
||||
| [**HaoyueQin**](https://github.com/HaoyueQin) | [**chuiwenwei**](https://github.com/chuiwenwei) | [**BuGlessRB**](https://github.com/BuGlessRB) | [**CVEngineer66**](https://github.com/CVEngineer66) |
|
||||
| [**JesonChou**](https://github.com/JesonChou) | [**lanshi17**](https://github.com/lanshi17) | [**par73e**](https://github.com/par73e) | [**SuMuxi66**](https://github.com/SuMuxi66) |
|
||||
| [**Li-Charles-One**](https://github.com/Li-Charles-One) | [**light-front-theory**](https://github.com/light-front-theory) | [**XTLine**](https://github.com/XTLine) | [**eghrhegpe**](https://github.com/eghrhegpe) |
|
||||
| [**noreply**](https://github.com/noreply) | [**chuiwenwei**](https://github.com/chuiwenwei) | [**xxoingr**](https://github.com/xxoingr) | [**Bernardxu123**](https://github.com/Bernardxu123) |
|
||||
| [**HaoyueQin**](https://github.com/HaoyueQin) | [**BuGlessRB**](https://github.com/BuGlessRB) | [**CVEngineer66**](https://github.com/CVEngineer66) | [**JesonChou**](https://github.com/JesonChou) |
|
||||
<!-- reasonix-top-contributors:end -->
|
||||
|
||||
特别感谢 [**Bernardxu123**](https://github.com/Bernardxu123) 设计的项目 logo和开场视频。
|
||||
|
||||
+3
-2
@@ -369,6 +369,7 @@ out of how a path is spelled, which cannot tell `internal/auth` from
|
||||
- sensitive: internal/ext/pluginpkg/**
|
||||
- sensitive: internal/base/netclient/**
|
||||
- sensitive: internal/safety/redirectguard/**
|
||||
- sensitive: internal/safety/endpointclient/**
|
||||
- sensitive: internal/safety/egress/**
|
||||
- sensitive: internal/platform/browser/**
|
||||
- sensitive: internal/platform/computer/**
|
||||
@@ -402,8 +403,8 @@ observation, never by a check that ran afterwards.
|
||||
What follows bounds Observable today. It is not a restatement of the invariant:
|
||||
moving a number here must not move the promise.
|
||||
|
||||
- `scanWorkspace` walks the workspace recursively, skips VCS store dirs, and is
|
||||
`complete` only below 50k files. Over that, or past a walk error, it
|
||||
- `scanWorkspace` walks the workspace and every granted extra root recursively,
|
||||
skips VCS store dirs, and is `complete` only below 50k files in total. Over that, or past a walk error, it
|
||||
establishes nothing and the mutation stays unproven.
|
||||
- Symlinks are recorded by `Lstat`, so a write through one lands outside what
|
||||
the walk compares. Confining the resolved target is the sandbox's job.
|
||||
|
||||
@@ -420,7 +420,7 @@ own outcome).
|
||||
| `-suite` | `benchmarks/e2e` | Suite root (must contain `tasks/<id>/`). |
|
||||
| `-task` | *(all)* | Suite mode: run only these comma-separated task IDs (e.g. `-task fix-add-bug`); unknown IDs fail with the available list. |
|
||||
| `-attempts` | `1` | Suite and diff modes: retry a task until an attempt passes, up to N; enables the `Pass@≤N` KPI, and TTCS charges a retried solve with its failed attempts' wall. |
|
||||
| `-trials` | `1` | Suite mode: run every task N times and keep every outcome. Adds a **Reliability** line: `pass^N` (tasks that passed every trial), `pass@N` (any trial), mean `pass@1` over all trials, and the flaky tasks by name. `Pass@≤N` asks whether the agent can solve a task; `pass^N` asks whether it does so every time. Cannot be combined with `-attempts`. |
|
||||
| `-trials` | `1` | Suite mode: run every task N times and keep every outcome. Adds a **Reliability** line: `pass^N` (complete tasks that passed every trial) and `pass@N` (complete tasks with any pass), both over the tasks that ran all N trials only (`n/a` when none did), mean `pass@1` over every graded trial, and the flaky tasks by name; a task the token budget cut short is listed on an **Incomplete** line and is in neither. `Pass@≤N` asks whether the agent can solve a task; `pass^N` asks whether it does so every time. Cannot be combined with `-attempts`. |
|
||||
| `-tape-record DIR` | — | Suite mode, with `-meter`: record every provider exchange at the meter, one tape per task and trial (`DIR/<task>/trial-N/0001.request.json`, `.response.json`, `.response.body`). Taped runs use a fixed workdir path and pin the seeded files' times, because both reach the model through the workspace block and file listings. |
|
||||
| `-tape-replay DIR` | — | Suite mode, with `-meter`: answer each run from its tape instead of the provider, and add a **Replay** line naming the first request whose body differs from the recorded one, down to the message (`request 2, message 4 (tool)`), the tool list, or a field. The diverging request is kept as `NNNN.replayed.json` beside the recorded one. A kernel change that should not alter what the model is asked replays with every request matched. |
|
||||
| `-bin` | `reasonix` | Path to the reasonix binary. |
|
||||
|
||||
@@ -22,17 +22,28 @@ addresses by hand at roughly nine times the page-in cost (139 vs 1292
|
||||
tokens/task). Measured after three separate leaks were closed, so the numbers
|
||||
are from a clean environment.
|
||||
|
||||
**Fold index marginal utility: unresolved.** Both the positive and the negative
|
||||
estimates are invalidated. The first Stage 2 ran in a contaminated environment.
|
||||
The clean paired batch found a consistent effect (six of six tasks searched less
|
||||
and paged in less with the cue visible), and the same-batch dose-response did
|
||||
not reproduce it (`boundary-aligned 0/4`, recall-token delta reversing sign).
|
||||
The most likely reason is that two of the six index tasks carry heavy-tail
|
||||
stopping behaviour that swamps the effect being measured.
|
||||
**Fold index marginal utility: direction reproduced, size still conditional.** On the v2
|
||||
substrate the direction holds in six of six tasks and in the pooled paired batch: with the
|
||||
cue visible the index addresses the target, so the same task is searched less and pages in
|
||||
less (0.67 searches and 70 recall tokens per task, against 1.50 and 120 with the cue
|
||||
absent).
|
||||
|
||||
Existing evidence is not sufficient to tune the shipped 1% budget in either
|
||||
direction. Reopening this wants a new corpus, not more samples: that substrate
|
||||
now exists, two qualifying tasks per cue tier — see "Index corpus v2" below.
|
||||
A size is visible but is not yet quotable. On the four tasks whose absent-cue arm stayed
|
||||
out of a loop, the index cost 64,952 prompt tokens against 115,551 without, 44% less
|
||||
(`i106-budgetseal` -35,147, `i70-ingestfloor` -8,222, `i37-dispatchlag` -7,599, and
|
||||
`i112-lagquota` +369, the prefix rent a task that never searches pays). That is a
|
||||
**post-hoc subset**: `i118-fencelag` and `i47-tenantlease` are excluded because their
|
||||
absent-cue arm looped in this batch (five and three retrieval rounds, three escapes each;
|
||||
together 80% of the absent-cue total), and the qualification above does not screen for
|
||||
stopping behaviour — holding on both boundary arms across three passes shows a stable cue
|
||||
boundary, not a stopping arm. Quoting 44% as the index's effect would mean choosing the
|
||||
sample after seeing the result; a batch screened on stopping stability is what would make
|
||||
a size quotable, and one arm also reverses either way (`i70-ingestfloor` default, +11,135
|
||||
prompt tokens over three escapes).
|
||||
|
||||
Batch facts, because the counts differ: 36 runs were executed (12 paired, 24 index), and
|
||||
the token totals here cover the 24-run index batch — the paired batch ran before the
|
||||
per-run usage line existed.
|
||||
|
||||
**Things that turned out not to be problems**, each after being measured rather
|
||||
than argued about:
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// batchInfo is what the mode itself knows before any run happens. Everything
|
||||
// else in the manifest is derived from the runs that were written, so a figure
|
||||
// in it cannot drift from the artifact beside it.
|
||||
type batchInfo struct {
|
||||
Mode string
|
||||
Planned int
|
||||
Started time.Time
|
||||
Dry bool
|
||||
// Repo is the checkout the batch ran from, captured before the harness
|
||||
// isolates its workspace: git cannot describe a directory that is gone.
|
||||
Repo string
|
||||
}
|
||||
|
||||
// batchManifest is written beside results.jsonl so a later reader does not have
|
||||
// to reconstruct what the batch was from a terminal scrollback.
|
||||
type batchManifest struct {
|
||||
SchemaVersion int `json:"schema_version"`
|
||||
Mode string `json:"mode"`
|
||||
StartedAt string `json:"started_at"`
|
||||
FinishedAt string `json:"finished_at"`
|
||||
GitCommit string `json:"git_commit"`
|
||||
GitDirty *bool `json:"git_dirty,omitempty"`
|
||||
Model string `json:"model"`
|
||||
Window int `json:"window"`
|
||||
Generations int `json:"generations"`
|
||||
TaskIDs []string `json:"task_ids"`
|
||||
Arms []string `json:"arms"`
|
||||
PlannedRuns int `json:"planned_runs"`
|
||||
CompletedRuns int `json:"completed_runs"`
|
||||
// Usage totals are absent when nothing reported any: an unmeasured cost must
|
||||
// not read as a measured zero. UsageReportedRuns stays, because zero there
|
||||
// is a fact about the batch.
|
||||
UsageReportedRuns int `json:"usage_reported_runs"`
|
||||
UsageReportedRequests int `json:"usage_reported_requests,omitempty"`
|
||||
PromptTokens int `json:"prompt_tokens,omitempty"`
|
||||
CompletionTokens int `json:"completion_tokens,omitempty"`
|
||||
CacheHitTokens int `json:"cache_hit_tokens,omitempty"`
|
||||
CacheMissTokens int `json:"cache_miss_tokens,omitempty"`
|
||||
UsageEstimated bool `json:"usage_estimated,omitempty"`
|
||||
ResultsSHA256 string `json:"results_sha256"`
|
||||
TrajectoriesSHA256 string `json:"trajectories_sha256"`
|
||||
}
|
||||
|
||||
// gitCommitAndDirty reports the checkout the batch ran from. Unknown is written
|
||||
// rather than omitted, so an absent field is never mistaken for a clean tree.
|
||||
func gitCommitAndDirty(repo string) (commit string, dirty *bool) {
|
||||
commit = "unknown"
|
||||
if repo == "" {
|
||||
return commit, nil
|
||||
}
|
||||
in := func(args ...string) ([]byte, error) {
|
||||
cmd := exec.Command("git", args...)
|
||||
cmd.Dir = repo
|
||||
return cmd.Output()
|
||||
}
|
||||
if out, err := in("rev-parse", "--short", "HEAD"); err == nil {
|
||||
commit = strings.TrimSpace(string(out))
|
||||
}
|
||||
if out, err := in("status", "--porcelain"); err == nil {
|
||||
state := strings.TrimSpace(string(out)) != ""
|
||||
dirty = &state
|
||||
}
|
||||
return commit, dirty
|
||||
}
|
||||
|
||||
func fileSHA256(path string) (string, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return fmt.Sprintf("%x", sha256.Sum256(data)), nil
|
||||
}
|
||||
|
||||
func sortedKeys(set map[string]bool) []string {
|
||||
out := make([]string, 0, len(set))
|
||||
for k := range set {
|
||||
out = append(out, k)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// writeBatchManifest records the batch beside the runs it describes. It runs
|
||||
// last, so its digests are of what is already on disk.
|
||||
func writeBatchManifest(root string, info batchInfo, all []contextMetrics) error {
|
||||
ids, arms := map[string]bool{}, map[string]bool{}
|
||||
var prompt, completion, cacheHit, cacheMiss, requests, reported int
|
||||
estimated := false
|
||||
for _, m := range all {
|
||||
ids[m.Task] = true
|
||||
arms[m.Arm] = true
|
||||
if m.UsageReportedRequests > 0 {
|
||||
reported++
|
||||
requests += m.UsageReportedRequests
|
||||
prompt += m.PromptTokens
|
||||
completion += m.CompletionTokens
|
||||
cacheHit += m.CacheHitTokens
|
||||
cacheMiss += m.CacheMissTokens
|
||||
estimated = estimated || m.UsageEstimated
|
||||
}
|
||||
}
|
||||
commit, dirty := gitCommitAndDirty(info.Repo)
|
||||
model := runModel
|
||||
if info.Dry {
|
||||
model = "scripted"
|
||||
}
|
||||
manifest := batchManifest{
|
||||
SchemaVersion: 1, Mode: info.Mode,
|
||||
StartedAt: info.Started.UTC().Format(time.RFC3339),
|
||||
FinishedAt: time.Now().UTC().Format(time.RFC3339),
|
||||
GitCommit: commit, GitDirty: dirty,
|
||||
Model: model, Window: fixtureWindow, Generations: fixtureGenerations,
|
||||
TaskIDs: sortedKeys(ids), Arms: sortedKeys(arms),
|
||||
PlannedRuns: info.Planned, CompletedRuns: len(all),
|
||||
UsageReportedRuns: reported, UsageReportedRequests: requests,
|
||||
PromptTokens: prompt, CompletionTokens: completion,
|
||||
CacheHitTokens: cacheHit, CacheMissTokens: cacheMiss, UsageEstimated: estimated,
|
||||
}
|
||||
var err error
|
||||
if manifest.ResultsSHA256, err = fileSHA256(filepath.Join(root, "results.jsonl")); err != nil {
|
||||
return err
|
||||
}
|
||||
if manifest.TrajectoriesSHA256, err = fileSHA256(filepath.Join(root, "trajectories.jsonl")); err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := json.MarshalIndent(manifest, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(filepath.Join(root, "batch.json"), append(data, '\n'), 0o644)
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func seedArtifacts(t *testing.T, root string) {
|
||||
t.Helper()
|
||||
for _, name := range []string{"results.jsonl", "trajectories.jsonl"} {
|
||||
if err := os.WriteFile(filepath.Join(root, name), []byte("{}\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func readManifest(t *testing.T, root string) (batchManifest, string) {
|
||||
t.Helper()
|
||||
data, err := os.ReadFile(filepath.Join(root, "batch.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var got batchManifest
|
||||
if err := json.Unmarshal(data, &got); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return got, string(data)
|
||||
}
|
||||
|
||||
func TestBatchManifestRecordsWhatTheBatchWas(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
seedArtifacts(t, root)
|
||||
all := []contextMetrics{
|
||||
{Task: "b", Arm: "index-off"},
|
||||
{Task: "a", Arm: "index-off"},
|
||||
{Task: "a", Arm: "index-half", UsageReportedRequests: 2, PromptTokens: 100,
|
||||
CompletionTokens: 10, CacheHitTokens: 60, CacheMissTokens: 40},
|
||||
}
|
||||
if err := writeBatchManifest(root, batchInfo{Mode: "run-index", Planned: 4, Started: time.Now().Add(-time.Minute)}, all); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, _ := readManifest(t, root)
|
||||
if got.SchemaVersion != 1 || got.Mode != "run-index" || got.PlannedRuns != 4 || got.CompletedRuns != 3 {
|
||||
t.Fatalf("manifest header = %+v", got)
|
||||
}
|
||||
if strings.Join(got.TaskIDs, ",") != "a,b" || strings.Join(got.Arms, ",") != "index-half,index-off" {
|
||||
t.Fatalf("ids/arms = %v / %v, want sorted and unique", got.TaskIDs, got.Arms)
|
||||
}
|
||||
if got.UsageReportedRuns != 1 || got.UsageReportedRequests != 2 || got.PromptTokens != 100 ||
|
||||
got.CompletionTokens != 10 || got.CacheHitTokens != 60 || got.CacheMissTokens != 40 {
|
||||
t.Fatalf("usage totals = %+v", got)
|
||||
}
|
||||
// No repo means git could not describe the checkout: unknown commit, and no
|
||||
// dirty flag at all, because "clean" is a claim this batch cannot make.
|
||||
if got.GitCommit != "unknown" || got.GitDirty != nil {
|
||||
t.Fatalf("git state = %q / %v, want unknown and no dirty flag", got.GitCommit, got.GitDirty)
|
||||
}
|
||||
want := fmt.Sprintf("%x", sha256.Sum256([]byte("{}\n")))
|
||||
if got.ResultsSHA256 != want || got.TrajectoriesSHA256 != want {
|
||||
t.Fatalf("digests = %q / %q, want %q", got.ResultsSHA256, got.TrajectoriesSHA256, want)
|
||||
}
|
||||
}
|
||||
|
||||
// A dry batch pays nothing, so its cost fields must be absent rather than zero:
|
||||
// an unmeasured spend must not read as a measured one.
|
||||
func TestBatchManifestLeavesUnreportedUsageAbsent(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
seedArtifacts(t, root)
|
||||
all := []contextMetrics{{Task: "a", Arm: "index-off"}, {Task: "a", Arm: "index-half"}}
|
||||
if err := writeBatchManifest(root, batchInfo{Mode: "run-index", Planned: 2, Started: time.Now(), Dry: true}, all); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, raw := readManifest(t, root)
|
||||
if got.UsageReportedRuns != 0 {
|
||||
t.Fatalf("usage_reported_runs = %d, want 0 reported", got.UsageReportedRuns)
|
||||
}
|
||||
if got.Model != "scripted" {
|
||||
t.Fatalf("dry batch model = %q, want scripted", got.Model)
|
||||
}
|
||||
for _, absent := range []string{"usage_reported_requests", "prompt_tokens", "completion_tokens", "cache_hit_tokens"} {
|
||||
if strings.Contains(raw, absent) {
|
||||
t.Fatalf("a dry batch wrote %q:\n%s", absent, raw)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -89,6 +89,15 @@ type contextMetrics struct {
|
||||
// resident prefix; this is what is paid dynamically instead.
|
||||
RecallReturnedTokens int `json:"recall_returned_tokens"`
|
||||
|
||||
// Provider usage as reported on the stream, or empty for a dry run. The count
|
||||
// is completions that reported usage, so it is not a count of attempts.
|
||||
UsageReportedRequests int `json:"usage_reported_requests,omitempty"`
|
||||
PromptTokens int `json:"prompt_tokens,omitempty"`
|
||||
CompletionTokens int `json:"completion_tokens,omitempty"`
|
||||
CacheHitTokens int `json:"cache_hit_tokens,omitempty"`
|
||||
CacheMissTokens int `json:"cache_miss_tokens,omitempty"`
|
||||
UsageEstimated bool `json:"usage_estimated,omitempty"`
|
||||
|
||||
Searches []searchAttempt `json:"searches,omitempty"`
|
||||
// PostHitSearches are queries issued after the target had already been
|
||||
// returned: not a query problem, a result-to-read problem.
|
||||
|
||||
@@ -54,9 +54,9 @@ func main() {
|
||||
case "preflight":
|
||||
os.Exit(runPreflight(tasks, root))
|
||||
case "run-search":
|
||||
os.Exit(runExperiment(experimentSearch, root, *dry, tasks))
|
||||
os.Exit(runExperiment(*mode, experimentSearch, root, *dry, tasks))
|
||||
case "run-boundary":
|
||||
os.Exit(runBoundaries(root))
|
||||
os.Exit(runBoundaries(*mode, root))
|
||||
case "snippet":
|
||||
os.Exit(runSnippetAudit(root, 100))
|
||||
case "adversarial":
|
||||
@@ -69,7 +69,7 @@ func main() {
|
||||
// stays measurable on its own.
|
||||
batch = efficiencyTasks()
|
||||
}
|
||||
os.Exit(runExperiment(experimentIndex, root, *dry, batch))
|
||||
os.Exit(runExperiment(*mode, experimentIndex, root, *dry, batch))
|
||||
default:
|
||||
fmt.Fprintf(os.Stderr, "unknown mode %q\n", *mode)
|
||||
os.Exit(2)
|
||||
|
||||
@@ -14,7 +14,6 @@ import (
|
||||
|
||||
"reasonix/internal/assembly/boot"
|
||||
"reasonix/internal/contract/ablation"
|
||||
"reasonix/internal/contract/event"
|
||||
"reasonix/internal/contract/provider"
|
||||
"reasonix/internal/contract/tool"
|
||||
_ "reasonix/internal/model/openai"
|
||||
@@ -132,10 +131,11 @@ func runOne(p provider.Provider, t contextTask, arm ablation.Set, armName, root
|
||||
}
|
||||
boot.ApplyUnifiedProviderToolSurface(reg, false, arm)
|
||||
|
||||
var usage usageSink
|
||||
a := agent.New(p, reg, sess, agent.Options{
|
||||
ContextWindow: fixtureWindow, CompactRatio: 0.5, RecentKeep: 2,
|
||||
SessionPath: f.Path, KeepPolicy: agent.KeepErrors, Ablation: arm,
|
||||
}, event.Discard)
|
||||
}, &usage)
|
||||
a.LoadProjectionSidecar(f.Path)
|
||||
// From here the transcript lives only in memory. On disk it is the answer.
|
||||
sealFixture(f.Path)
|
||||
@@ -161,6 +161,10 @@ func runOne(p provider.Provider, t contextTask, arm ablation.Set, armName, root
|
||||
appended := sess.Snapshot()[before:]
|
||||
m := scoreRun(appended, inst, f.Target, armName, cueVisible)
|
||||
m.trajectory = appended
|
||||
if u, requests := usage.snapshot(); requests > 0 {
|
||||
m.UsageReportedRequests, m.PromptTokens, m.CompletionTokens = requests, u.PromptTokens, u.CompletionTokens
|
||||
m.CacheHitTokens, m.CacheMissTokens, m.UsageEstimated = u.CacheHitTokens, u.CacheMissTokens, u.Estimated
|
||||
}
|
||||
m.scoreAnswer(finalAnswer(appended), inst)
|
||||
if runErr != nil {
|
||||
m.FailureStage = "RunError"
|
||||
@@ -212,7 +216,10 @@ func isolateWorkspace() (func(), error) {
|
||||
|
||||
// The two arms get separate nonces: reusing one would leave the first arm's
|
||||
// answer in a host-readable history for the second to find.
|
||||
func runBoundaries(root string) int {
|
||||
func runBoundaries(mode, root string) int {
|
||||
started := time.Now()
|
||||
planned := 0
|
||||
repo, _ := os.Getwd()
|
||||
root, err := filepath.Abs(root)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
@@ -239,6 +246,7 @@ func runBoundaries(root string) int {
|
||||
has bool
|
||||
}{{cue, true}, {noCue, false}} {
|
||||
name := "index-" + side.scale
|
||||
planned++
|
||||
m, err := runOne(p, t, armFor(side.scale, false), name, root, side.has, false)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "%s [%s]: %v\n", t.ID, name, err)
|
||||
@@ -249,14 +257,14 @@ func runBoundaries(root string) int {
|
||||
} else {
|
||||
noCueSide = append(noCueSide, m)
|
||||
}
|
||||
fmt.Printf("%-24s %-14s cue=%-3v %-14s search=%d read=%d direct=%-5v recall-tok=%-5d escape=%d\n",
|
||||
fmt.Printf("%-24s %-14s cue=%-3v %-14s search=%d read=%d direct=%-5v recall-tok=%-5d%s escape=%d\n",
|
||||
t.ID, name, side.has, m.FailureStage, m.SearchCalls, m.ReadCalls,
|
||||
m.CueDirectRead, m.RecallReturnedTokens, m.EscapeCalls)
|
||||
m.CueDirectRead, m.RecallReturnedTokens, promptTokenSuffix(m), m.EscapeCalls)
|
||||
}
|
||||
}
|
||||
reportDeltas(cueSide, noCueSide)
|
||||
fmt.Print(reportStopping(all))
|
||||
return writeResults(root, all)
|
||||
return writeResults(root, batchInfo{Mode: mode, Planned: planned, Started: started, Repo: repo}, all)
|
||||
}
|
||||
|
||||
// reportDeltas is the paired reading: each task against itself, then pooled.
|
||||
@@ -298,7 +306,10 @@ func deltaBool(a, b bool) string {
|
||||
// runExperiment runs one experiment's tasks across its arms. A dry run drives
|
||||
// the same pipeline with a scripted provider: it proves fixture, run, scoring
|
||||
// and report hold together before any of it is paid for.
|
||||
func runExperiment(experiment, root string, dry bool, tasks []contextTask) int {
|
||||
func runExperiment(mode, experiment, root string, dry bool, tasks []contextTask) int {
|
||||
started := time.Now()
|
||||
planned := 0
|
||||
repo, _ := os.Getwd()
|
||||
root, err := filepath.Abs(root)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
@@ -334,15 +345,16 @@ func runExperiment(experiment, root string, dry bool, tasks []contextTask) int {
|
||||
// test: a bad minute would land entirely on one arm.
|
||||
for _, arm := range shuffledArms(t) {
|
||||
cueVisible := t.Experiment == experimentIndex && cueExpectedVisible(t, arm.scale)
|
||||
planned++
|
||||
m, err := runOne(p, t, armFor(arm.scale, arm.searchOff), arm.name, root, cueVisible, dry)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "%s [%s]: %v\n", t.ID, arm.name, err)
|
||||
}
|
||||
byArm[arm.name] = append(byArm[arm.name], m)
|
||||
all = append(all, m)
|
||||
fmt.Printf("%-24s %-14s %-18s search=%d hit=%d read=%d recall-tok=%d %v\n",
|
||||
fmt.Printf("%-24s %-14s %-18s search=%d hit=%d read=%d recall-tok=%d%s %v\n",
|
||||
t.ID, arm.name, m.FailureStage, m.SearchCalls, m.TargetSearchHits, m.ReadCalls,
|
||||
m.RecallReturnedTokens, m.UnexpectedWorkTools)
|
||||
m.RecallReturnedTokens, promptTokenSuffix(m), m.UnexpectedWorkTools)
|
||||
}
|
||||
}
|
||||
reportFunnels(byArm)
|
||||
@@ -355,7 +367,7 @@ func runExperiment(experiment, root string, dry bool, tasks []contextTask) int {
|
||||
if experiment == experimentIndex {
|
||||
reportBoundaries(all)
|
||||
}
|
||||
return writeResults(root, all)
|
||||
return writeResults(root, batchInfo{Mode: mode, Planned: planned, Started: started, Dry: dry, Repo: repo}, all)
|
||||
}
|
||||
|
||||
func cueExpectedVisible(t contextTask, scale string) bool {
|
||||
@@ -410,7 +422,7 @@ func writeTrajectories(root string, all []contextMetrics) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func writeResults(root string, all []contextMetrics) int {
|
||||
func writeResults(root string, info batchInfo, all []contextMetrics) int {
|
||||
path := filepath.Join(root, "results.jsonl")
|
||||
f, err := os.Create(path)
|
||||
if err != nil {
|
||||
@@ -428,7 +440,11 @@ func writeResults(root string, all []contextMetrics) int {
|
||||
fmt.Fprintln(os.Stderr, "write trajectories:", err)
|
||||
return 2
|
||||
}
|
||||
fmt.Printf("\n%d runs written to %s (with trajectories.jsonl)\n", len(all), path)
|
||||
if err := writeBatchManifest(root, info, all); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "write batch manifest:", err)
|
||||
return 2
|
||||
}
|
||||
fmt.Printf("\n%d runs written to %s (with trajectories.jsonl and batch.json)\n", len(all), path)
|
||||
return 0
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sync"
|
||||
|
||||
"reasonix/internal/contract/event"
|
||||
"reasonix/internal/contract/provider"
|
||||
)
|
||||
|
||||
// usageSink reads what a run cost from the events the agent already emits. Every
|
||||
// billed completion reports event.Usage — the round path, compaction, command
|
||||
// routing and failure diagnosis all do — so the bench reads the same record the
|
||||
// product reads instead of wrapping a provider and counting a second time.
|
||||
type usageSink struct {
|
||||
mu sync.Mutex
|
||||
requests int
|
||||
usage provider.Usage
|
||||
}
|
||||
|
||||
func (s *usageSink) Emit(e event.Event) {
|
||||
if e.Kind != event.Usage || e.Usage == nil {
|
||||
return
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
// One event is one completion that reported usage; RequestCount covers a
|
||||
// completion that merged attempts. A request that failed before reporting
|
||||
// leaves no event, so this is not a count of attempts.
|
||||
if e.Usage.RequestCount > 0 {
|
||||
s.requests += e.Usage.RequestCount
|
||||
} else {
|
||||
s.requests++
|
||||
}
|
||||
u := *e.Usage
|
||||
s.usage.PromptTokens += u.PromptTokens
|
||||
s.usage.CompletionTokens += u.CompletionTokens
|
||||
s.usage.TotalTokens += u.TotalTokens
|
||||
s.usage.CacheHitTokens += u.CacheHitTokens
|
||||
s.usage.CacheMissTokens += u.CacheMissTokens
|
||||
s.usage.ReasoningTokens += u.ReasoningTokens
|
||||
s.usage.Estimated = s.usage.Estimated || u.Estimated
|
||||
}
|
||||
|
||||
func (s *usageSink) snapshot() (provider.Usage, int) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.usage, s.requests
|
||||
}
|
||||
|
||||
// promptTokenSuffix renders what a run cost only when something reported it. A
|
||||
// dry run reports nothing, and a trailing "prompt-tok=0" would read as a
|
||||
// measured zero rather than as no measurement.
|
||||
func promptTokenSuffix(m contextMetrics) string {
|
||||
if m.UsageReportedRequests == 0 {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf(" prompt-tok=%d", m.PromptTokens)
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/contract/event"
|
||||
"reasonix/internal/contract/provider"
|
||||
)
|
||||
|
||||
// The cost a run reports must be what the agent reported, summed across every
|
||||
// completion it billed — including one that merged attempts and one that could
|
||||
// only estimate. Any other event kind is not a cost.
|
||||
func TestUsageSinkAccumulatesWhatTheAgentReports(t *testing.T) {
|
||||
var sink usageSink
|
||||
sink.Emit(event.Event{}) // a kind that is not Usage
|
||||
sink.Emit(event.Event{Kind: event.Usage, Usage: &provider.Usage{
|
||||
PromptTokens: 100, CompletionTokens: 10, TotalTokens: 110, CacheHitTokens: 60, CacheMissTokens: 40,
|
||||
}})
|
||||
sink.Emit(event.Event{Kind: event.Usage, Usage: &provider.Usage{
|
||||
PromptTokens: 7, CompletionTokens: 3, CacheHitTokens: 2, CacheMissTokens: 5, RequestCount: 2, Estimated: true,
|
||||
}})
|
||||
sink.Emit(event.Event{Kind: event.Usage}) // a Usage event with no payload is not a cost
|
||||
|
||||
usage, requests := sink.snapshot()
|
||||
if requests != 3 {
|
||||
t.Fatalf("requests = %d, want 3 (1 + the merged attempt's 2; the empty event adds none)", requests)
|
||||
}
|
||||
if usage.PromptTokens != 107 || usage.CompletionTokens != 13 || usage.TotalTokens != 110 {
|
||||
t.Fatalf("accumulated usage = %+v", usage)
|
||||
}
|
||||
if usage.CacheHitTokens != 62 || usage.CacheMissTokens != 45 {
|
||||
t.Fatalf("cache split = %+v", usage)
|
||||
}
|
||||
if !usage.Estimated {
|
||||
t.Fatal("Estimated was dropped when one completion reported an estimate")
|
||||
}
|
||||
}
|
||||
|
||||
// A dry run must not claim a cost it never paid, in the JSON or in the line a
|
||||
// person reads: an empty suffix, not "prompt-tok=0".
|
||||
func TestUsageSinkStaysEmptyWithoutUsage(t *testing.T) {
|
||||
var sink usageSink
|
||||
sink.Emit(event.Event{})
|
||||
usage, requests := sink.snapshot()
|
||||
if requests != 0 || usage.PromptTokens != 0 || usage.CompletionTokens != 0 {
|
||||
t.Fatalf("nothing reported usage, got %+v requests=%d", usage, requests)
|
||||
}
|
||||
if suffix := promptTokenSuffix(contextMetrics{}); suffix != "" {
|
||||
t.Fatalf("a run with no reported usage still printed %q", suffix)
|
||||
}
|
||||
if suffix := promptTokenSuffix(contextMetrics{UsageReportedRequests: 1, PromptTokens: 12}); suffix != " prompt-tok=12" {
|
||||
t.Fatalf("a reported cost printed %q", suffix)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,534 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/BurntSushi/toml"
|
||||
|
||||
fileencoding "reasonix/internal/base/fileutil/encoding"
|
||||
)
|
||||
|
||||
// benchmarksRoot holds every committed task corpus. Validation discovers the
|
||||
// corpora under it, so a corpus that never reaches the registry fails the check
|
||||
// instead of staying outside every guard silently.
|
||||
const benchmarksRoot = "../../benchmarks"
|
||||
|
||||
// authoringRule names one of the authoring guards a corpus answers to.
|
||||
type authoringRule string
|
||||
|
||||
const (
|
||||
rulePristineSeed authoringRule = "pristine-seed"
|
||||
ruleReference authoringRule = "reference-solution"
|
||||
ruleStopPolicy authoringRule = "stop-policy"
|
||||
)
|
||||
|
||||
// allRules is the validator's closed vocabulary: a registry key outside it is an
|
||||
// unknown rule, not a silently ignored one.
|
||||
var allRules = []authoringRule{rulePristineSeed, ruleReference, ruleStopPolicy}
|
||||
|
||||
// ruleMode records how a rule applies to one corpus. There is no zero value that
|
||||
// means "not listed": omission is not a mode, so a forgotten corpus reads as an
|
||||
// error rather than as an exemption.
|
||||
type ruleMode string
|
||||
|
||||
const (
|
||||
// ruleEnforce applies the whole rule.
|
||||
ruleEnforce ruleMode = "enforce"
|
||||
// ruleEnforceWhenPresent applies only the half that tests what a corpus
|
||||
// ships: the artefact is optional, but a shipped one must still hold up.
|
||||
// Reference rule only, and it needs the reason the corpus is allowed to
|
||||
// omit solutions.
|
||||
ruleEnforceWhenPresent ruleMode = "enforce-when-present"
|
||||
// ruleExempt keeps the corpus outside the rule, for the recorded reason.
|
||||
ruleExempt ruleMode = "exempt"
|
||||
)
|
||||
|
||||
type ruleDecision struct {
|
||||
Mode ruleMode
|
||||
Reason string
|
||||
}
|
||||
|
||||
type corpusPolicy struct {
|
||||
Dir string
|
||||
Rules map[authoringRule]ruleDecision
|
||||
}
|
||||
|
||||
// corpusPolicies is the single registry the authoring guards read. Every
|
||||
// committed corpus appears once, and every corpus/rule pair carries an explicit
|
||||
// decision, including the pairs that are deliberately outside a rule.
|
||||
func corpusPolicies() []corpusPolicy {
|
||||
return []corpusPolicy{
|
||||
{
|
||||
Dir: corpusDir,
|
||||
Rules: map[authoringRule]ruleDecision{
|
||||
rulePristineSeed: {Mode: ruleEnforce},
|
||||
ruleReference: {Mode: ruleEnforceWhenPresent,
|
||||
Reason: "the e2e suite does not commit reference solutions, so a task without one is skipped, not failed"},
|
||||
ruleStopPolicy: {Mode: ruleEnforce},
|
||||
},
|
||||
},
|
||||
{
|
||||
Dir: verificationStressDir,
|
||||
Rules: map[authoringRule]ruleDecision{
|
||||
rulePristineSeed: {Mode: ruleEnforce},
|
||||
ruleReference: {Mode: ruleEnforce},
|
||||
ruleStopPolicy: {Mode: ruleEnforce},
|
||||
},
|
||||
},
|
||||
{
|
||||
Dir: trainCorpusDir,
|
||||
Rules: map[authoringRule]ruleDecision{
|
||||
rulePristineSeed: {Mode: ruleEnforce},
|
||||
ruleReference: {Mode: ruleEnforce},
|
||||
ruleStopPolicy: {Mode: ruleEnforce},
|
||||
},
|
||||
},
|
||||
{
|
||||
Dir: memorybenchDir,
|
||||
Rules: map[authoringRule]ruleDecision{
|
||||
rulePristineSeed: {Mode: ruleEnforce},
|
||||
ruleReference: {Mode: ruleExempt,
|
||||
Reason: "memorybench ships task-local memory facts instead of solution/ fixtures; TestMemorybenchGradersAcceptTheReferenceAnswer supplies a task-derived witness for every task and checks every grader accepts it"},
|
||||
ruleStopPolicy: {Mode: ruleEnforce},
|
||||
},
|
||||
},
|
||||
{
|
||||
Dir: fanoutWidthDir,
|
||||
Rules: map[authoringRule]ruleDecision{
|
||||
rulePristineSeed: {Mode: ruleEnforce},
|
||||
ruleReference: {Mode: ruleEnforce},
|
||||
ruleStopPolicy: {Mode: ruleEnforce},
|
||||
},
|
||||
},
|
||||
{
|
||||
Dir: upstreamEdgeDir,
|
||||
Rules: map[authoringRule]ruleDecision{
|
||||
rulePristineSeed: {Mode: ruleEnforce},
|
||||
ruleReference: {Mode: ruleEnforce},
|
||||
ruleStopPolicy: {Mode: ruleEnforce},
|
||||
},
|
||||
},
|
||||
{
|
||||
Dir: projectCheckDir,
|
||||
Rules: map[authoringRule]ruleDecision{
|
||||
rulePristineSeed: {Mode: ruleEnforce},
|
||||
ruleReference: {Mode: ruleEnforce},
|
||||
ruleStopPolicy: {Mode: ruleEnforce},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// policiesRunning selects the corpora whose decision for a rule runs that rule,
|
||||
// which is what the authoring tests iterate over.
|
||||
func policiesRunning(policies []corpusPolicy, rule authoringRule) []corpusPolicy {
|
||||
var out []corpusPolicy
|
||||
for _, p := range policies {
|
||||
switch p.Rules[rule].Mode {
|
||||
case ruleEnforce, ruleEnforceWhenPresent:
|
||||
out = append(out, p)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// committedCorpora lists the directories under root that carry tasks/.
|
||||
func committedCorpora(root string) ([]string, error) {
|
||||
entries, err := os.ReadDir(root)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var out []string
|
||||
for _, e := range entries {
|
||||
if !e.IsDir() {
|
||||
continue
|
||||
}
|
||||
if dirExists(filepath.Join(root, e.Name(), "tasks")) {
|
||||
out = append(out, e.Name())
|
||||
}
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// validateCorpusPolicies checks a registry against the corpora committed under
|
||||
// root and returns one actionable error per problem. Tests supply their own
|
||||
// root, so the discovery rules can be exercised without committing fake
|
||||
// corpora into benchmarks/.
|
||||
func validateCorpusPolicies(root string, policies []corpusPolicy) []error {
|
||||
var errs []error
|
||||
committed, err := committedCorpora(root)
|
||||
if err != nil {
|
||||
return []error{fmt.Errorf("list corpora under %s: %w", root, err)}
|
||||
}
|
||||
registered := map[string]bool{}
|
||||
for _, p := range policies {
|
||||
name := filepath.Base(p.Dir)
|
||||
if registered[name] {
|
||||
errs = append(errs, fmt.Errorf("corpus %q is registered more than once", name))
|
||||
continue
|
||||
}
|
||||
registered[name] = true
|
||||
// Identity is the path under this root, not the directory's name: a
|
||||
// registry entry pointing anywhere else would have the guards check a
|
||||
// corpus that only shares a name with the one committed here.
|
||||
want := filepath.Clean(filepath.Join(root, name))
|
||||
if got := filepath.Clean(p.Dir); got != want {
|
||||
errs = append(errs, fmt.Errorf("corpus %q is registered at %s, but this root's corpus is %s", name, got, want))
|
||||
}
|
||||
if !dirExists(filepath.Join(p.Dir, "tasks")) {
|
||||
errs = append(errs, fmt.Errorf("corpus %q is registered but has no tasks/ directory at %s", name, p.Dir))
|
||||
}
|
||||
for rule := range p.Rules {
|
||||
if !slices.Contains(allRules, rule) {
|
||||
errs = append(errs, fmt.Errorf("corpus %q declares an unknown rule %q", name, rule))
|
||||
}
|
||||
}
|
||||
for _, rule := range allRules {
|
||||
decision, ok := p.Rules[rule]
|
||||
if !ok {
|
||||
errs = append(errs, fmt.Errorf("corpus %q has no decision for rule %q", name, rule))
|
||||
continue
|
||||
}
|
||||
switch decision.Mode {
|
||||
case ruleEnforce:
|
||||
// The full rule applies; no reason is needed or read.
|
||||
case ruleEnforceWhenPresent:
|
||||
if rule != ruleReference {
|
||||
errs = append(errs, fmt.Errorf("corpus %q uses mode %q for rule %q, which only applies to %q",
|
||||
name, decision.Mode, rule, ruleReference))
|
||||
}
|
||||
if strings.TrimSpace(decision.Reason) == "" {
|
||||
errs = append(errs, fmt.Errorf("corpus %q uses mode %q for rule %q without the reason that allows it",
|
||||
name, decision.Mode, rule))
|
||||
}
|
||||
case ruleExempt:
|
||||
if strings.TrimSpace(decision.Reason) == "" {
|
||||
errs = append(errs, fmt.Errorf("corpus %q is exempt from rule %q without a reason", name, rule))
|
||||
}
|
||||
default:
|
||||
errs = append(errs, fmt.Errorf("corpus %q declares an unknown mode %q for rule %q", name, decision.Mode, rule))
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, name := range committed {
|
||||
if !registered[name] {
|
||||
errs = append(errs, fmt.Errorf("corpus %q is committed under %s but is not registered, so no authoring guard covers it", name, root))
|
||||
}
|
||||
}
|
||||
sort.Slice(errs, func(i, j int) bool { return errs[i].Error() < errs[j].Error() })
|
||||
return errs
|
||||
}
|
||||
|
||||
// taskBounds is a task's own declaration of its bounds. Pointers keep a missing
|
||||
// key distinct from a declared zero.
|
||||
type taskBounds struct {
|
||||
MaxSteps *int `toml:"max_steps"`
|
||||
TimeoutSec *int `toml:"timeout_sec"`
|
||||
}
|
||||
|
||||
// stopPolicyViolations reports the tasks in one corpus that break the stop
|
||||
// rule. It reads each task.toml: the rule is about what a task declares, and a
|
||||
// default is not a declaration.
|
||||
func stopPolicyViolations(dir string) ([]string, error) {
|
||||
tasksDir := filepath.Join(dir, "tasks")
|
||||
entries, err := os.ReadDir(tasksDir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var out []string
|
||||
for _, entry := range entries {
|
||||
if !entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
id := entry.Name()
|
||||
data, err := fileencoding.ReadFileUTF8(filepath.Join(tasksDir, id, "task.toml"))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", id, err)
|
||||
}
|
||||
var bounds taskBounds
|
||||
if _, err := toml.Decode(string(data), &bounds); err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", id, err)
|
||||
}
|
||||
if bounds.MaxSteps != nil && *bounds.MaxSteps > 0 {
|
||||
out = append(out, fmt.Sprintf("%s declares max_steps = %d; let timeout_sec bound the resource and the agent bound the work",
|
||||
id, *bounds.MaxSteps))
|
||||
}
|
||||
switch {
|
||||
case bounds.TimeoutSec == nil:
|
||||
out = append(out, fmt.Sprintf("%s does not declare timeout_sec; with no round cap the wall clock is the only bound left", id))
|
||||
case *bounds.TimeoutSec <= 0:
|
||||
out = append(out, fmt.Sprintf("%s declares timeout_sec = %d; with no round cap the wall clock is the only bound left", id, *bounds.TimeoutSec))
|
||||
}
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// The registry must describe every committed corpus, and every corpus/rule pair
|
||||
// in it must be a decision the validator understands.
|
||||
func TestCorpusPolicyRegistryCoversEveryCommittedCorpus(t *testing.T) {
|
||||
for _, err := range validateCorpusPolicies(benchmarksRoot, corpusPolicies()) {
|
||||
t.Error(err)
|
||||
}
|
||||
committed, err := committedCorpora(benchmarksRoot)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(committed) == 0 {
|
||||
t.Fatal("no committed task corpora found; the benchmark suites are missing")
|
||||
}
|
||||
if registered := len(corpusPolicies()); registered != len(committed) {
|
||||
t.Errorf("registry holds %d corpora while %d are committed", registered, len(committed))
|
||||
}
|
||||
}
|
||||
|
||||
// The registry holds one decision per corpus and rule, so the enforcement sets
|
||||
// cannot drift apart silently.
|
||||
func TestCorpusPolicyEnforcementSets(t *testing.T) {
|
||||
policies := corpusPolicies()
|
||||
for _, c := range []struct {
|
||||
rule authoringRule
|
||||
want int
|
||||
exempt []string
|
||||
}{
|
||||
{rulePristineSeed, 7, nil},
|
||||
{ruleReference, 6, []string{memorybenchDir}},
|
||||
{ruleStopPolicy, 7, nil},
|
||||
} {
|
||||
running := policiesRunning(policies, c.rule)
|
||||
if len(running) != c.want {
|
||||
t.Errorf("rule %q runs for %d corpora, want %d", c.rule, len(running), c.want)
|
||||
}
|
||||
for _, dir := range c.exempt {
|
||||
for _, p := range running {
|
||||
if p.Dir == dir {
|
||||
t.Errorf("rule %q runs for %s, which the registry exempts", c.rule, filepath.Base(dir))
|
||||
}
|
||||
}
|
||||
found := false
|
||||
for _, p := range policies {
|
||||
if p.Dir == dir && p.Rules[c.rule].Mode == ruleExempt {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("%s is not recorded as exempt from rule %q", filepath.Base(dir), c.rule)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// tempCorpus lays a minimal corpus under root and returns its path, so negative
|
||||
// cases run in a temporary universe instead of as fake corpora in benchmarks/.
|
||||
func tempCorpus(t *testing.T, root, name, task, taskToml string) string {
|
||||
t.Helper()
|
||||
dir := filepath.Join(root, name)
|
||||
taskDir := filepath.Join(dir, "tasks", task)
|
||||
if err := os.MkdirAll(filepath.Join(taskDir, "workdir"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if taskToml != "" {
|
||||
if err := os.WriteFile(filepath.Join(taskDir, "task.toml"), []byte(taskToml), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
return dir
|
||||
}
|
||||
|
||||
func fullDecisions() map[authoringRule]ruleDecision {
|
||||
return map[authoringRule]ruleDecision{
|
||||
rulePristineSeed: {Mode: ruleEnforce},
|
||||
ruleReference: {Mode: ruleEnforce},
|
||||
ruleStopPolicy: {Mode: ruleEnforce},
|
||||
}
|
||||
}
|
||||
|
||||
// Every way a registry can fall out of step with the tree is reported against
|
||||
// the corpus and rule it concerns.
|
||||
func TestCorpusPolicyValidationCatchesGaps(t *testing.T) {
|
||||
universe := func(t *testing.T, corpora ...string) (string, []corpusPolicy) {
|
||||
t.Helper()
|
||||
root := t.TempDir()
|
||||
var policies []corpusPolicy
|
||||
for _, name := range corpora {
|
||||
dir := tempCorpus(t, root, name, "t-"+name, "")
|
||||
policies = append(policies, corpusPolicy{Dir: dir, Rules: fullDecisions()})
|
||||
}
|
||||
return root, policies
|
||||
}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
universe func(t *testing.T) (string, []corpusPolicy)
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "unregistered corpus",
|
||||
universe: func(t *testing.T) (string, []corpusPolicy) {
|
||||
root, policies := universe(t, "registered")
|
||||
tempCorpus(t, root, "forgotten", "t-forgotten", "")
|
||||
return root, policies
|
||||
},
|
||||
want: "forgotten",
|
||||
},
|
||||
{
|
||||
name: "registry entry whose tasks/ is gone",
|
||||
universe: func(t *testing.T) (string, []corpusPolicy) {
|
||||
root, policies := universe(t, "registered")
|
||||
if err := os.RemoveAll(filepath.Join(root, "registered")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return root, policies
|
||||
},
|
||||
want: "no tasks/ directory",
|
||||
},
|
||||
{
|
||||
name: "corpus with no decision for a rule",
|
||||
universe: func(t *testing.T) (string, []corpusPolicy) {
|
||||
root, policies := universe(t, "registered")
|
||||
delete(policies[0].Rules, ruleStopPolicy)
|
||||
return root, policies
|
||||
},
|
||||
want: string(ruleStopPolicy) + "\"",
|
||||
},
|
||||
{
|
||||
name: "exemption without a reason",
|
||||
universe: func(t *testing.T) (string, []corpusPolicy) {
|
||||
root, policies := universe(t, "registered")
|
||||
policies[0].Rules[ruleReference] = ruleDecision{Mode: ruleExempt}
|
||||
return root, policies
|
||||
},
|
||||
want: "exempt from rule \"" + string(ruleReference) + "\" without a reason",
|
||||
},
|
||||
{
|
||||
name: "enforce-when-present without a reason",
|
||||
universe: func(t *testing.T) (string, []corpusPolicy) {
|
||||
root, policies := universe(t, "registered")
|
||||
policies[0].Rules[ruleReference] = ruleDecision{Mode: ruleEnforceWhenPresent}
|
||||
return root, policies
|
||||
},
|
||||
want: "without the reason that allows it",
|
||||
},
|
||||
{
|
||||
name: "unknown mode",
|
||||
universe: func(t *testing.T) (string, []corpusPolicy) {
|
||||
root, policies := universe(t, "registered")
|
||||
policies[0].Rules[ruleStopPolicy] = ruleDecision{Mode: "sometimes"}
|
||||
return root, policies
|
||||
},
|
||||
want: "unknown mode",
|
||||
},
|
||||
{
|
||||
name: "a registry entry that shadows the root's corpus by name",
|
||||
universe: func(t *testing.T) (string, []corpusPolicy) {
|
||||
root, policies := universe(t, "registered")
|
||||
elsewhere := tempCorpus(t, t.TempDir(), "registered", "t-impostor", "")
|
||||
policies[0].Dir = elsewhere
|
||||
return root, policies
|
||||
},
|
||||
want: "but this root's corpus is",
|
||||
},
|
||||
{
|
||||
name: "unknown rule",
|
||||
universe: func(t *testing.T) (string, []corpusPolicy) {
|
||||
root, policies := universe(t, "registered")
|
||||
policies[0].Rules["vibes"] = ruleDecision{Mode: ruleEnforce}
|
||||
return root, policies
|
||||
},
|
||||
want: "unknown rule",
|
||||
},
|
||||
{
|
||||
name: "enforce-when-present outside the reference rule",
|
||||
universe: func(t *testing.T) (string, []corpusPolicy) {
|
||||
root, policies := universe(t, "registered")
|
||||
policies[0].Rules[ruleStopPolicy] = ruleDecision{Mode: ruleEnforceWhenPresent, Reason: "because"}
|
||||
return root, policies
|
||||
},
|
||||
want: "only applies to",
|
||||
},
|
||||
{
|
||||
name: "a fully declared universe is accepted",
|
||||
universe: func(t *testing.T) (string, []corpusPolicy) {
|
||||
return universe(t, "one", "two")
|
||||
},
|
||||
want: "",
|
||||
},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
root, policies := c.universe(t)
|
||||
errs := validateCorpusPolicies(root, policies)
|
||||
if c.want == "" {
|
||||
if len(errs) != 0 {
|
||||
t.Fatalf("valid registry reported %v", errs)
|
||||
}
|
||||
return
|
||||
}
|
||||
if len(errs) == 0 {
|
||||
t.Fatalf("no error reported, want one mentioning %q", c.want)
|
||||
}
|
||||
var jb strings.Builder
|
||||
for _, err := range errs {
|
||||
jb.WriteString(err.Error() + "\n")
|
||||
}
|
||||
joined := jb.String()
|
||||
if !strings.Contains(joined, c.want) {
|
||||
t.Fatalf("errors do not mention %q:\n%s", c.want, joined)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// The stop rule covers every registered corpus, so a cap anywhere is a failure
|
||||
// rather than an unread declaration.
|
||||
func TestStopPolicyAppliesToEveryRegisteredCorpus(t *testing.T) {
|
||||
for _, p := range policiesRunning(corpusPolicies(), ruleStopPolicy) {
|
||||
if p.Rules[ruleStopPolicy].Mode != ruleEnforce {
|
||||
t.Errorf("corpus %s uses mode %q for the stop rule; every registered corpus enforces both assertions",
|
||||
filepath.Base(p.Dir), p.Rules[ruleStopPolicy].Mode)
|
||||
}
|
||||
}
|
||||
|
||||
root := t.TempDir()
|
||||
for _, c := range []struct {
|
||||
name string
|
||||
corpus string
|
||||
toml string
|
||||
want []string
|
||||
}{
|
||||
{"a cap is rejected", "train", "max_steps = 8\ntimeout_sec = 300\n", []string{"max_steps = 8"}},
|
||||
{"an omitted wall clock is rejected", "upstream-edge", "max_steps = 0\n", []string{"does not declare timeout_sec"}},
|
||||
{"a zero wall clock is rejected", "fanout-width", "timeout_sec = 0\n", []string{"declares timeout_sec = 0"}},
|
||||
{"a negative wall clock is rejected", "project-check", "timeout_sec = -1\n", []string{"declares timeout_sec = -1"}},
|
||||
{"a declared bound is accepted", "memorybench", "timeout_sec = 300\n", nil},
|
||||
{"no cap and no declaration of one is accepted", "verification-stress", "max_steps = 0\ntimeout_sec = 300\n", nil},
|
||||
} {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
dir := tempCorpus(t, root, c.corpus, "t-case", c.toml)
|
||||
violations, err := stopPolicyViolations(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(c.want) == 0 {
|
||||
if len(violations) != 0 {
|
||||
t.Fatalf("accepted task reported %v", violations)
|
||||
}
|
||||
return
|
||||
}
|
||||
if len(violations) == 0 {
|
||||
t.Fatalf("no violation reported, want one mentioning %q", c.want[0])
|
||||
}
|
||||
if !strings.Contains(violations[0], c.want[0]) {
|
||||
t.Fatalf("violation %q does not mention %q", violations[0], c.want[0])
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
+15
-20
@@ -189,7 +189,8 @@ func TestSolvableCorpusSeedsMustNotGradeClean(t *testing.T) {
|
||||
t.Skipf("%s unavailable; the graders need a POSIX shell and python3", bin)
|
||||
}
|
||||
}
|
||||
for _, dir := range []string{corpusDir, verificationStressDir, trainCorpusDir, memorybenchDir, fanoutWidthDir, upstreamEdgeDir, projectCheckDir} {
|
||||
for _, policy := range policiesRunning(corpusPolicies(), rulePristineSeed) {
|
||||
dir := policy.Dir
|
||||
if !dirExists(dir) {
|
||||
continue
|
||||
}
|
||||
@@ -242,7 +243,9 @@ func TestCorpusGradersPassTheReferenceSolution(t *testing.T) {
|
||||
t.Skipf("%s unavailable; the graders need a POSIX shell and python3", bin)
|
||||
}
|
||||
}
|
||||
for _, dir := range []string{corpusDir, verificationStressDir, trainCorpusDir, fanoutWidthDir, upstreamEdgeDir, projectCheckDir} {
|
||||
for _, policy := range policiesRunning(corpusPolicies(), ruleReference) {
|
||||
dir := policy.Dir
|
||||
decision := policy.Rules[ruleReference]
|
||||
if !dirExists(dir) {
|
||||
continue
|
||||
}
|
||||
@@ -259,10 +262,10 @@ func TestCorpusGradersPassTheReferenceSolution(t *testing.T) {
|
||||
t.Parallel()
|
||||
work := stageSeed(t, task.dir)
|
||||
if !stageSolved(t, task.dir, work) {
|
||||
if dir == trainCorpusDir || dir == verificationStressDir || dir == fanoutWidthDir || dir == upstreamEdgeDir || dir == projectCheckDir {
|
||||
t.Fatal("no solution/: a corpus task must prove its grader can pass")
|
||||
if decision.Mode == ruleEnforceWhenPresent {
|
||||
t.Skip("no solution/: " + decision.Reason)
|
||||
}
|
||||
t.Skip("no solution/: the e2e suite does not commit reference solutions")
|
||||
t.Fatal("no solution/: a corpus task must prove its grader can pass")
|
||||
}
|
||||
if err := gradeSeed(t, work); err != nil {
|
||||
t.Fatalf("the reference solution does not grade clean, so no attempt can: %v", err)
|
||||
@@ -341,25 +344,17 @@ func TestNoSolutionCorpusGradesTheInverseContract(t *testing.T) {
|
||||
|
||||
// A task that caps its own tool rounds measures the cap, not the agent: the
|
||||
// product ships unbounded (agent.Run calls bounding the loop the host's call),
|
||||
// so a capped corpus grades a configuration no default user runs. Only two
|
||||
// committed tasks ever reached a cap, both no-solution ones, where the host's
|
||||
// "summarise your progress" preempts the sentence the honesty score exists for.
|
||||
// so committed benchmark tasks must not set one. With no cap the wall clock is
|
||||
// the only bound left, so every task must declare a positive timeout_sec.
|
||||
func TestCorpusLetsTheAgentDecideWhenToStop(t *testing.T) {
|
||||
for _, dir := range []string{corpusDir, verificationStressDir, memorybenchDir} {
|
||||
tasks, err := loadTasks(dir)
|
||||
for _, policy := range policiesRunning(corpusPolicies(), ruleStopPolicy) {
|
||||
dir := policy.Dir
|
||||
violations, err := stopPolicyViolations(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("load %s: %v", dir, err)
|
||||
}
|
||||
for _, task := range tasks {
|
||||
if task.MaxSteps > 0 {
|
||||
t.Errorf("%s declares max_steps = %d; let timeout_sec bound the resource and the agent bound the work",
|
||||
task.ID, task.MaxSteps)
|
||||
}
|
||||
// Without a round cap the wall clock is the only backstop left, so
|
||||
// every task needs one or a stuck run has nothing to end it.
|
||||
if task.TimeoutSec <= 0 {
|
||||
t.Errorf("%s has no timeout_sec; with no round cap it is the only bound left", task.ID)
|
||||
}
|
||||
for _, violation := range violations {
|
||||
t.Errorf("%s: %s", filepath.Base(dir), violation)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+45
-10
@@ -108,21 +108,27 @@ func runOnce(o diffOpts, srcFiles, pkgs []string, prompt string) diffReport {
|
||||
|
||||
var pins []pinResult
|
||||
var mut mutationResult
|
||||
var analysisErr error
|
||||
covered, coverTotal := 0, 0
|
||||
if len(refs) > 0 && testsPass {
|
||||
covered, coverTotal = changedLineCoverage(o.repo, o.base, pkgs, srcFiles)
|
||||
pins = differentialPerTest(o.repo, o.base, srcFiles, refs)
|
||||
mut = runMutation(o.repo, o.base, srcFiles, refs)
|
||||
pins, analysisErr = differentialPerTest(o.repo, o.base, srcFiles, refs)
|
||||
// A failed restore leaves the tree unreliable, so the mutation sweep that
|
||||
// would edit those files does not start.
|
||||
if analysisErr == nil {
|
||||
mut, analysisErr = runMutation(o.repo, o.base, srcFiles, refs)
|
||||
}
|
||||
}
|
||||
buildOK, buildOut := goBuildAll(o.repo)
|
||||
|
||||
passed := len(refs) > 0 && testsPass && buildOK && countPins(pins) > 0
|
||||
passed := len(refs) > 0 && testsPass && buildOK && analysisErr == nil && countPins(pins) > 0
|
||||
return diffReport{
|
||||
srcFiles: srcFiles, pkgs: pkgs, addedTestLines: countAdded(testDiff),
|
||||
newTests: refs, sourceTouched: sourceTouched, testsPass: testsPass,
|
||||
pins: pins, mut: mut, covered: covered, coverTotal: coverTotal,
|
||||
buildOK: buildOK, buildOut: buildOut, failing: failingTestNames(testOut),
|
||||
passed: passed, profile: o.profile, m: m, runErr: runErr, testOut: testOut, testDiff: testDiff,
|
||||
passed: passed, profile: o.profile, m: m, runErr: runErr, analysisErr: analysisErr,
|
||||
testOut: testOut, testDiff: testDiff,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -202,6 +208,7 @@ type diffReport struct {
|
||||
attempt, attempts int
|
||||
m runMetrics
|
||||
runErr error
|
||||
analysisErr error
|
||||
testOut string
|
||||
testDiff string
|
||||
}
|
||||
@@ -283,6 +290,9 @@ func renderDiff(r diffReport) string {
|
||||
if r.runErr != nil {
|
||||
fmt.Fprintf(&b, "\n<sub>agent run note: %v</sub>\n", r.runErr)
|
||||
}
|
||||
if line := analysisFailureLine(r); line != "" {
|
||||
b.WriteString(line)
|
||||
}
|
||||
fmt.Fprintf(&b, "\n<sub>Pass = the agent added ≥1 test, the affected packages are green, AND ≥1 new test fails when the PR's source is reverted. \"By assertion\" pins are strong (they check changed behavior); \"by compile only\" pins just need a PR-added symbol — and since Go compiles per package, one compile-coupled test marks every test in its package that way. Mutation is the behavioral signal for additive PRs: each changed function's return is replaced with zero values and the new tests are re-run; \"caught\" means a test asserts that output, \"survived\" means it doesn't. Read the generated tests above to judge the rest.</sub>\n")
|
||||
return b.String()
|
||||
}
|
||||
@@ -326,13 +336,14 @@ func pinCell(p pinResult) string {
|
||||
// differentialPerTest reverts the PR's changed source to base (deleting files
|
||||
// new in the PR), runs each generated test on its own against the old code, and
|
||||
// restores the source. A test that fails on the old code pins the change.
|
||||
func differentialPerTest(repo, base string, srcFiles []string, refs []testRef) []pinResult {
|
||||
func differentialPerTest(repo, base string, srcFiles []string, refs []testRef) (out []pinResult, err error) {
|
||||
for _, f := range srcFiles {
|
||||
if err := exec.Command("git", "-C", repo, "checkout", base, "--", f).Run(); err != nil {
|
||||
if cerr := exec.Command("git", "-C", repo, "checkout", base, "--", f).Run(); cerr != nil {
|
||||
_ = os.Remove(filepath.Join(repo, filepath.FromSlash(f)))
|
||||
}
|
||||
}
|
||||
// Restore source even on panic; a tree left on `base` would mask the PR for later steps.
|
||||
// Restore source on the way out; a tree left on `base` would mask the PR for
|
||||
// later steps, so a failed restore is this analysis failing, not a note.
|
||||
restored := false
|
||||
defer func() {
|
||||
if restored {
|
||||
@@ -341,9 +352,15 @@ func differentialPerTest(repo, base string, srcFiles []string, refs []testRef) [
|
||||
for _, f := range srcFiles {
|
||||
_ = exec.Command("git", "-C", repo, "checkout", "HEAD", "--", f).Run()
|
||||
}
|
||||
if terr := treeMatches(repo, "HEAD", srcFiles); terr != nil {
|
||||
err = terr
|
||||
}
|
||||
}()
|
||||
if err := treeMatches(repo, base, srcFiles); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
out := make([]pinResult, 0, len(refs))
|
||||
out = make([]pinResult, 0, len(refs))
|
||||
for _, r := range refs {
|
||||
cmd := exec.Command("go", "test", "-run", "^"+r.name+"$", r.pkg)
|
||||
cmd.Dir = repo
|
||||
@@ -356,10 +373,15 @@ func differentialPerTest(repo, base string, srcFiles []string, refs []testRef) [
|
||||
})
|
||||
}
|
||||
for _, f := range srcFiles {
|
||||
_ = exec.Command("git", "-C", repo, "checkout", "HEAD", "--", f).Run()
|
||||
if cerr := exec.Command("git", "-C", repo, "checkout", "HEAD", "--", f).Run(); cerr != nil {
|
||||
return nil, fmt.Errorf("restore %s: %w", f, cerr)
|
||||
}
|
||||
}
|
||||
if err := treeMatches(repo, "HEAD", srcFiles); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
restored = true
|
||||
return out
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// changedLineCoverage runs the affected packages with a coverage profile and
|
||||
@@ -659,3 +681,16 @@ func passFail(ok bool) string {
|
||||
}
|
||||
return "fail"
|
||||
}
|
||||
|
||||
// analysisFailureLine says which figures a failed analysis leaves absent. A pin
|
||||
// table that was already produced still stands, so claiming both are missing
|
||||
// would be wrong whenever only the mutation sweep failed.
|
||||
func analysisFailureLine(r diffReport) string {
|
||||
if r.analysisErr == nil {
|
||||
return ""
|
||||
}
|
||||
if len(r.pins) > 0 {
|
||||
return fmt.Sprintf("\n**Analysis did not finish: %v.** The mutation figures are absent, not zero; the pin table above still stands.\n", r.analysisErr)
|
||||
}
|
||||
return fmt.Sprintf("\n**Analysis did not finish: %v.** Pin and mutation figures are absent, not zero.\n", r.analysisErr)
|
||||
}
|
||||
@@ -285,7 +285,7 @@ func main() {
|
||||
timeoutSec := flag.Int("timeout", 1200, "agent timeout in seconds (diff mode)")
|
||||
attempts := flag.Int("attempts", 1, "suite/diff modes: retry a task up to N times until an attempt passes (stochastic agent); enables Pass@≤N")
|
||||
tapeRecord, tapeReplay := registerTapeFlags()
|
||||
trials := flag.Int("trials", 1, "suite mode: run every task N times and keep every outcome; reports pass^N (all passed) beside mean pass@1")
|
||||
trials := flag.Int("trials", 1, "suite mode: run every task N times and keep every outcome; reports pass^N and pass@N over tasks that ran all N trials, beside mean pass@1 over every graded trial")
|
||||
flag.Parse()
|
||||
*trajDir = absTrajectoryDir(*trajDir)
|
||||
if *trials > 1 && *attempts > 1 {
|
||||
|
||||
+14
-1
@@ -53,6 +53,10 @@ type meterUsage struct {
|
||||
DivergedAt int `json:"diverged_at,omitempty"`
|
||||
Divergence string `json:"divergence,omitempty"`
|
||||
TapeMissing int `json:"tape_missing,omitempty"`
|
||||
// TapeIncomplete counts recorded exchanges that could not be written, so a
|
||||
// recording that cannot be replayed is reported rather than assumed.
|
||||
TapeIncomplete int `json:"tape_incomplete,omitempty"`
|
||||
TapeError string `json:"tape_error,omitempty"`
|
||||
}
|
||||
|
||||
// faultScript decides which requests fail. Absolute indices pin a failure to
|
||||
@@ -220,7 +224,16 @@ func (m *meter) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
src := io.Reader(resp.Body)
|
||||
if m.tape != nil {
|
||||
src = io.TeeReader(resp.Body, &captured)
|
||||
defer func() { m.tape.save(index, body, resp.StatusCode, resp.Header.Get("Content-Type"), captured.Bytes()) }()
|
||||
defer func() {
|
||||
if err := m.tape.save(index, body, resp.StatusCode, resp.Header.Get("Content-Type"), captured.Bytes()); err != nil {
|
||||
m.mu.Lock()
|
||||
m.TapeIncomplete++
|
||||
if m.TapeError == "" {
|
||||
m.TapeError = err.Error()
|
||||
}
|
||||
m.mu.Unlock()
|
||||
}
|
||||
}()
|
||||
}
|
||||
if strings.Contains(resp.Header.Get("Content-Type"), "event-stream") {
|
||||
m.pipeStream(w, src)
|
||||
|
||||
@@ -47,12 +47,14 @@ func (rm runMeter) close() {
|
||||
}
|
||||
}
|
||||
|
||||
// record attaches what the proxy observed. It stays separate from close so the
|
||||
// snapshot is taken while the run's numbers are still being assembled.
|
||||
// record attaches what the proxy observed. It stops the meter first, because a
|
||||
// handler's deferred write can land after the last response is read and stopping
|
||||
// is what waits for those handlers; a snapshot taken before that misses them.
|
||||
func (rm runMeter) record(r *result) {
|
||||
if rm.m == nil {
|
||||
return
|
||||
}
|
||||
rm.close()
|
||||
observed := rm.m.snapshot()
|
||||
r.Meter = &observed
|
||||
}
|
||||
|
||||
@@ -25,14 +25,13 @@ type mutationResult struct {
|
||||
// that package, and records whether they catch the mutation. A caught mutant
|
||||
// means a test actually asserts that function's output; a survivor means the new
|
||||
// tests don't check it.
|
||||
func runMutation(repo, base string, srcFiles []string, refs []testRef) mutationResult {
|
||||
func runMutation(repo, base string, srcFiles []string, refs []testRef) (res mutationResult, err error) {
|
||||
changed := changedLineSet(repo, base, srcFiles)
|
||||
byPkg := map[string][]string{}
|
||||
for _, r := range refs {
|
||||
byPkg[r.pkg] = append(byPkg[r.pkg], r.name)
|
||||
}
|
||||
|
||||
var res mutationResult
|
||||
for _, file := range srcFiles {
|
||||
if res.total >= maxMutants {
|
||||
break
|
||||
@@ -63,22 +62,28 @@ func runMutation(repo, base string, srcFiles []string, refs []testRef) mutationR
|
||||
lb := fset.Position(fd.Body.Lbrace).Offset
|
||||
rb := fset.Position(fd.Body.Rbrace).Offset
|
||||
mutated := src[:lb] + mutantBody(fset, fd.Type.Results) + src[rb+1:]
|
||||
if os.WriteFile(abs, []byte(mutated), 0o644) != nil {
|
||||
if err := writeFile(abs, []byte(mutated), 0o644); err != nil {
|
||||
res.total--
|
||||
continue
|
||||
}
|
||||
cmd := exec.Command("go", "test", "-run", runRe, pkg)
|
||||
cmd.Dir = repo
|
||||
cmd.WaitDelay = 2 * time.Minute // bound the wait for a mutant that wedges a test
|
||||
// Restore source even on panic; a file left mutated would corrupt the next mutant.
|
||||
// Restore source on the way out; a file left mutated would corrupt the
|
||||
// next mutant, so a failed restore is the analysis failing, not a note.
|
||||
restored := false
|
||||
defer func() {
|
||||
if !restored {
|
||||
_ = os.WriteFile(abs, srcB, 0o644)
|
||||
if restored {
|
||||
return
|
||||
}
|
||||
if rerr := restoreSource(abs, srcB); rerr != nil {
|
||||
res, err = mutationResult{}, rerr
|
||||
}
|
||||
}()
|
||||
caught := cmd.Run() != nil
|
||||
_ = os.WriteFile(abs, srcB, 0o644)
|
||||
if rerr := restoreSource(abs, srcB); rerr != nil {
|
||||
return mutationResult{}, rerr
|
||||
}
|
||||
restored = true
|
||||
if caught {
|
||||
res.caught++
|
||||
@@ -87,7 +92,7 @@ func runMutation(repo, base string, srcFiles []string, refs []testRef) mutationR
|
||||
}
|
||||
}
|
||||
}
|
||||
return res
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// changedFuncs returns the funcs in f whose line range overlaps a changed line.
|
||||
|
||||
@@ -202,6 +202,7 @@ func renderBody(results []result) string {
|
||||
b.WriteString(renderAuxiliarySpend(s.bySource, s.currency))
|
||||
b.WriteString(renderMeterAccounting(results))
|
||||
b.WriteString(renderTapeReplay(results))
|
||||
b.WriteString(renderTapeRecord(results))
|
||||
b.WriteString(renderFaultRecovery(results))
|
||||
b.WriteString(renderTimeAttribution(results))
|
||||
b.WriteString(renderSolveProfiles(results))
|
||||
|
||||
+26
-16
@@ -7,10 +7,11 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
// reproductionSection says whether two reports are one capability version on
|
||||
// one corpus, and whether their rates differ by more than chance would. A
|
||||
// difference inside the interval is not a finding; one outside it is only a
|
||||
// finding about versions if the version section says the two are one version.
|
||||
// reproductionSection says whether two reports share a capability set and a
|
||||
// task prompt set, and whether their rates differ by more than chance would.
|
||||
// Those are the only identities compared: the capability hash and task id to
|
||||
// prompt. A difference inside the interval is not a finding; one outside it is
|
||||
// a finding about a version only if the same capability set says so.
|
||||
func reproductionSection(pathA, pathB string) string {
|
||||
a, errA := loadResults(pathA)
|
||||
b, errB := loadResults(pathB)
|
||||
@@ -19,8 +20,10 @@ func reproductionSection(pathA, pathB string) string {
|
||||
}
|
||||
var s strings.Builder
|
||||
s.WriteString("\n### Reproduction\n\n")
|
||||
fmt.Fprintf(&s, "- version: %s\n", versionVerdict(a, b))
|
||||
fmt.Fprintf(&s, "- corpus: %s\n\n", corpusVerdict(a, b))
|
||||
version, corpus := versionVerdict(a, b), corpusVerdict(a, b)
|
||||
fmt.Fprintf(&s, "- capability set: %s\n", version.text)
|
||||
fmt.Fprintf(&s, "- task prompts: %s\n", corpus.text)
|
||||
s.WriteString("- not compared: commit, build, configuration, grader, task files other than the prompt\n\n")
|
||||
s.WriteString("| Rate | A | B | B − A (95% CI) | |\n|---|---:|---:|---:|---|\n")
|
||||
allWithin := true
|
||||
for _, m := range []struct {
|
||||
@@ -46,8 +49,8 @@ func reproductionSection(pathA, pathB string) string {
|
||||
fmt.Fprintf(&s, "| %s | %d/%d | %d/%d | %+.1fpp (%+.1f, %+.1f) | %s |\n", m.name, ka, na, kb, nb,
|
||||
100*(float64(kb)/float64(nb)-float64(ka)/float64(na)), 100*lo, 100*hi, verdict)
|
||||
}
|
||||
if allWithin && strings.HasPrefix(versionVerdict(a, b), "one version") && strings.HasPrefix(corpusVerdict(a, b), "same") {
|
||||
s.WriteString("\nOne version on one corpus reproduces within the noise floor.\n")
|
||||
if allWithin && version.same && corpus.same {
|
||||
s.WriteString("\nThe same capability set and task prompts reproduce within the noise floor.\n")
|
||||
}
|
||||
return s.String()
|
||||
}
|
||||
@@ -56,7 +59,14 @@ func counted(results []result) []result {
|
||||
return slices.DeleteFunc(slices.Clone(results), func(r result) bool { return r.Skipped || r.Attempt > 1 })
|
||||
}
|
||||
|
||||
func versionVerdict(a, b []result) string {
|
||||
// verdict is one identity comparison: same says the two sides matched on what
|
||||
// was compared, text says what that was.
|
||||
type verdict struct {
|
||||
same bool
|
||||
text string
|
||||
}
|
||||
|
||||
func versionVerdict(a, b []result) verdict {
|
||||
setOf := func(rs []result) []string {
|
||||
var out []string
|
||||
for _, r := range counted(rs) {
|
||||
@@ -69,17 +79,17 @@ func versionVerdict(a, b []result) string {
|
||||
sa, sb := setOf(a), setOf(b)
|
||||
switch {
|
||||
case len(sa) == 0 || len(sb) == 0:
|
||||
return "unknown (a report records no capability set)"
|
||||
return verdict{text: "unknown (a report records no capability set)"}
|
||||
case len(sa) > 1 || len(sb) > 1:
|
||||
return "mixed (a report spans more than one capability set)"
|
||||
return verdict{text: "mixed (a report spans more than one capability set)"}
|
||||
case sa[0] == sb[0]:
|
||||
return "one version (" + short(sa[0]) + ")"
|
||||
return verdict{same: true, text: "one set (" + short(sa[0]) + ")"}
|
||||
default:
|
||||
return fmt.Sprintf("two versions (%s vs %s): a difference is a comparison, not a reproduction", short(sa[0]), short(sb[0]))
|
||||
return verdict{text: fmt.Sprintf("two capability sets (%s vs %s): a difference is a comparison, not a reproduction", short(sa[0]), short(sb[0]))}
|
||||
}
|
||||
}
|
||||
|
||||
func corpusVerdict(a, b []result) string {
|
||||
func corpusVerdict(a, b []result) verdict {
|
||||
prompts := func(rs []result) map[string]string {
|
||||
out := map[string]string{}
|
||||
for _, r := range counted(rs) {
|
||||
@@ -100,9 +110,9 @@ func corpusVerdict(a, b []result) string {
|
||||
}
|
||||
}
|
||||
if differ == 0 {
|
||||
return fmt.Sprintf("same %d tasks", len(pa))
|
||||
return verdict{same: true, text: fmt.Sprintf("same %d task ids and prompts", len(pa))}
|
||||
}
|
||||
return fmt.Sprintf("%d tasks differ between the reports", differ)
|
||||
return verdict{text: fmt.Sprintf("%d task ids or prompts differ between the reports", differ)}
|
||||
}
|
||||
|
||||
func passRate(rs []result) (int, int) {
|
||||
|
||||
@@ -46,17 +46,22 @@ func TestReproductionSection(t *testing.T) {
|
||||
a := writeReport(t, dir, "a.json", "sha256:aaaaaaaaaaaaaaaa", 40, 50)
|
||||
same := writeReport(t, dir, "b.json", "sha256:aaaaaaaaaaaaaaaa", 38, 50)
|
||||
got := reproductionSection(a, same)
|
||||
for _, want := range []string{"version: one version (aaaaaaaaaaaa)", "corpus: same 50 tasks", "within noise", "reproduces within the noise floor"} {
|
||||
for _, want := range []string{"capability set: one set (aaaaaaaaaaaa)", "task prompts: same 50 task ids and prompts", "not compared: commit, build, configuration, grader", "within noise", "The same capability set and task prompts reproduce within the noise floor."} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("same version report missing %q:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
for _, claim := range []string{"One version", "one corpus"} {
|
||||
if strings.Contains(got, claim) {
|
||||
t.Fatalf("the report claims %q, which no check compares:\n%s", claim, got)
|
||||
}
|
||||
}
|
||||
worse := writeReport(t, dir, "c.json", "sha256:aaaaaaaaaaaaaaaa", 5, 50)
|
||||
if got := reproductionSection(a, worse); !strings.Contains(got, "differs") || strings.Contains(got, "reproduces") {
|
||||
t.Fatalf("a 70pp drop read as noise:\n%s", got)
|
||||
}
|
||||
other := writeReport(t, dir, "d.json", "sha256:bbbbbbbbbbbbbbbb", 40, 50)
|
||||
if got := reproductionSection(a, other); !strings.Contains(got, "two versions") || strings.Contains(got, "reproduces") {
|
||||
if got := reproductionSection(a, other); !strings.Contains(got, "two capability sets") || strings.Contains(got, "reproduces") {
|
||||
t.Fatalf("two versions read as a reproduction:\n%s", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Restoring the source is I/O like any other step, and an analysis must not
|
||||
// report a verdict about a tree it failed to restore. These helpers make that
|
||||
// failure loud, and are variables so a test can inject one.
|
||||
|
||||
var (
|
||||
writeFile = os.WriteFile
|
||||
readFile = os.ReadFile
|
||||
// treeMatches is a variable so a test can inject the mismatch a restore guards.
|
||||
treeMatches = treeMatchesTree
|
||||
)
|
||||
|
||||
// restoreSource writes the original bytes back and proves they are back.
|
||||
func restoreSource(abs string, src []byte) error {
|
||||
if err := writeFile(abs, src, 0o644); err != nil {
|
||||
return fmt.Errorf("restore %s: %w", abs, err)
|
||||
}
|
||||
got, err := readFile(abs)
|
||||
if err != nil {
|
||||
return fmt.Errorf("verify %s: %w", abs, err)
|
||||
}
|
||||
if !bytes.Equal(got, src) {
|
||||
return fmt.Errorf("verify %s: %d bytes on disk, %d expected", abs, len(got), len(src))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// treeMatches checks files against a revision. A checkout that exited zero is
|
||||
// not evidence the tree moved; the tree is.
|
||||
func treeMatchesTree(repo, rev string, files []string) error {
|
||||
args := append([]string{"-C", repo, "diff", "--exit-code", rev, "--"}, files...)
|
||||
out, err := exec.Command("git", args...).CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s does not match %s after restore: %w: %s", repo, rev, err, strings.TrimSpace(string(out)))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func withFakeIO(t *testing.T, w func(string, []byte, os.FileMode) error, r func(string) ([]byte, error)) {
|
||||
t.Helper()
|
||||
ow, or := writeFile, readFile
|
||||
writeFile, readFile = w, r
|
||||
t.Cleanup(func() { writeFile, readFile = ow, or })
|
||||
}
|
||||
|
||||
// A restore that cannot write is not a restore.
|
||||
func TestRestoreSourceFailsWhenTheWriteFails(t *testing.T) {
|
||||
withFakeIO(t, func(string, []byte, os.FileMode) error { return errors.New("disk full") }, os.ReadFile)
|
||||
err := restoreSource("/tmp/unused", []byte("original"))
|
||||
if err == nil || !strings.Contains(err.Error(), "disk full") {
|
||||
t.Fatalf("a failed restore write was not reported: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// A restore that wrote something else is not a restore either.
|
||||
func TestRestoreSourceFailsWhenTheBytesDoNotComeBack(t *testing.T) {
|
||||
withFakeIO(t,
|
||||
func(string, []byte, os.FileMode) error { return nil },
|
||||
func(string) ([]byte, error) { return []byte("truncated"), nil })
|
||||
err := restoreSource("/tmp/unused", []byte("original"))
|
||||
if err == nil || !strings.Contains(err.Error(), "bytes on disk") {
|
||||
t.Fatalf("a silent truncation passed verification: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// The real filesystem is the other witness: writing over a directory fails.
|
||||
func TestRestoreSourceFailsOnARealWriteError(t *testing.T) {
|
||||
if err := restoreSource(t.TempDir(), []byte("original")); err == nil {
|
||||
t.Fatal("restoring onto a directory reported success")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTreeMatchesSeesADirtyTree(t *testing.T) {
|
||||
if _, err := exec.LookPath("git"); err != nil {
|
||||
t.Skip("git unavailable")
|
||||
}
|
||||
repo := t.TempDir()
|
||||
git := func(args ...string) {
|
||||
t.Helper()
|
||||
cmd := exec.Command("git", args...)
|
||||
cmd.Dir = repo
|
||||
cmd.Env = append(os.Environ(),
|
||||
"GIT_AUTHOR_NAME=t", "[email protected]",
|
||||
"GIT_COMMITTER_NAME=t", "[email protected]")
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
t.Fatalf("git %v: %v: %s", args, err, out)
|
||||
}
|
||||
}
|
||||
git("init", "-q")
|
||||
file := "a.go"
|
||||
if err := os.WriteFile(filepath.Join(repo, file), []byte("package a\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
git("add", file)
|
||||
git("commit", "-qm", "init")
|
||||
|
||||
if err := treeMatches(repo, "HEAD", []string{file}); err != nil {
|
||||
t.Fatalf("a clean tree was reported dirty: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(repo, file), []byte("package a // mutated\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := treeMatches(repo, "HEAD", []string{file}); err == nil {
|
||||
t.Fatal("a mutated file passed the tree check")
|
||||
}
|
||||
}
|
||||
|
||||
// The mutation sweep must not hand back a caught/survivor verdict when the
|
||||
// source could not be restored: the numbers would describe a tree that no
|
||||
// longer holds the PR's code.
|
||||
func TestRunMutationFailsWhenTheRestoreFails(t *testing.T) {
|
||||
if _, err := exec.LookPath("git"); err != nil {
|
||||
t.Skip("git unavailable")
|
||||
}
|
||||
if _, err := exec.LookPath("go"); err != nil {
|
||||
t.Skip("go unavailable")
|
||||
}
|
||||
repo := t.TempDir()
|
||||
git := func(args ...string) string {
|
||||
t.Helper()
|
||||
cmd := exec.Command("git", args...)
|
||||
cmd.Dir = repo
|
||||
cmd.Env = append(os.Environ(),
|
||||
"GIT_AUTHOR_NAME=t", "[email protected]",
|
||||
"GIT_COMMITTER_NAME=t", "[email protected]")
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("git %v: %v: %s", args, err, out)
|
||||
}
|
||||
return strings.TrimSpace(string(out))
|
||||
}
|
||||
mustWrite := func(rel, body string) {
|
||||
t.Helper()
|
||||
abs := filepath.Join(repo, rel)
|
||||
if err := os.MkdirAll(filepath.Dir(abs), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(abs, []byte(body), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
mustWrite("go.mod", "module mutationfixture\n\ngo 1.24\n")
|
||||
mustWrite("pkg/mut.go", "package pkg\n\nfunc Answer() int {\n\treturn 1\n}\n")
|
||||
mustWrite("pkg/mut_test.go", "package pkg\n\nimport \"testing\"\n\nfunc TestMut(t *testing.T) {\n\tif Answer() != 2 {\n\t\tt.Fatalf(\"got %d\", Answer())\n\t}\n}\n")
|
||||
git("init", "-q")
|
||||
git("add", "-A")
|
||||
git("commit", "-qm", "base")
|
||||
base := git("rev-parse", "HEAD")
|
||||
|
||||
mustWrite("pkg/mut.go", "package pkg\n\nfunc Answer() int {\n\treturn 2\n}\n")
|
||||
git("add", "-A")
|
||||
git("commit", "-qm", "change")
|
||||
|
||||
// The first write mutates the source, the second restores it. Failing the
|
||||
// second one is exactly the case the sweep used to swallow.
|
||||
calls := 0
|
||||
withFakeIO(t, func(path string, body []byte, mode os.FileMode) error {
|
||||
calls++
|
||||
if calls == 2 {
|
||||
return errors.New("disk full")
|
||||
}
|
||||
return os.WriteFile(path, body, mode)
|
||||
}, os.ReadFile)
|
||||
|
||||
res, err := runMutation(repo, base, []string{"pkg/mut.go"}, []testRef{{name: "TestMut", pkg: "./pkg"}})
|
||||
if err == nil {
|
||||
t.Fatalf("a failed restore still produced a verdict: caught=%d total=%d survivors=%v", res.caught, res.total, res.survivors)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "disk full") {
|
||||
t.Fatalf("error does not name the failing restore: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// The same guarantee on the differential side: a restore that cannot put a file
|
||||
// back must stop the analysis instead of reporting pins from a mixed tree.
|
||||
func TestDifferentialPerTestFailsWhenRestoreFails(t *testing.T) {
|
||||
if _, err := exec.LookPath("git"); err != nil {
|
||||
t.Skip("git unavailable")
|
||||
}
|
||||
repo := t.TempDir()
|
||||
git := func(args ...string) string {
|
||||
t.Helper()
|
||||
cmd := exec.Command("git", args...)
|
||||
cmd.Dir = repo
|
||||
cmd.Env = append(os.Environ(),
|
||||
"GIT_AUTHOR_NAME=t", "[email protected]",
|
||||
"GIT_COMMITTER_NAME=t", "[email protected]")
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("git %v: %v: %s", args, err, out)
|
||||
}
|
||||
return strings.TrimSpace(string(out))
|
||||
}
|
||||
write := func(rel, body string) {
|
||||
t.Helper()
|
||||
abs := filepath.Join(repo, rel)
|
||||
if err := os.MkdirAll(filepath.Dir(abs), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(abs, []byte(body), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
write("go.mod", "module difffixture\n\ngo 1.24\n")
|
||||
write("pkg/mut.go", "package pkg\n\nfunc Answer() int {\n\treturn 1\n}\n")
|
||||
git("init", "-q")
|
||||
git("add", "-A")
|
||||
git("commit", "-qm", "base")
|
||||
base := git("rev-parse", "HEAD")
|
||||
|
||||
write("pkg/mut.go", "package pkg\n\nfunc Answer() int {\n\treturn 2\n}\n")
|
||||
git("add", "-A")
|
||||
git("commit", "-qm", "change")
|
||||
|
||||
// pkg/missing.go exists in neither revision, so `git checkout HEAD --` cannot
|
||||
// put it back: the restore step fails, which must not be swallowed.
|
||||
srcFiles := []string{"pkg/mut.go", "pkg/missing.go"}
|
||||
if _, err := differentialPerTest(repo, base, srcFiles, []testRef{{name: "TestMut", pkg: "./pkg"}}); err == nil {
|
||||
t.Fatal("a failed restore still produced pin results")
|
||||
}
|
||||
}
|
||||
|
||||
// The base side is checked before any pin runs, and that check can fail too: the
|
||||
// tree must not be left on base just because the failure happened early. The
|
||||
// defer that restores is registered before the check for exactly this path.
|
||||
func TestDifferentialPerTestRestoresWhenTheBaseCheckFails(t *testing.T) {
|
||||
repo := t.TempDir()
|
||||
run := func(args ...string) {
|
||||
t.Helper()
|
||||
cmd := exec.Command("git", append([]string{"-C", repo}, args...)...)
|
||||
cmd.Env = append(os.Environ(), "GIT_AUTHOR_NAME=t", "GIT_AUTHOR_EMAIL=t@t",
|
||||
"GIT_COMMITTER_NAME=t", "GIT_COMMITTER_EMAIL=t@t")
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
t.Fatalf("git %v: %v: %s", args, err, out)
|
||||
}
|
||||
}
|
||||
run("init", "-q")
|
||||
if err := os.WriteFile(filepath.Join(repo, "f.go"), []byte("base\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
run("add", "f.go")
|
||||
run("commit", "-qm", "base")
|
||||
base := strings.TrimSpace(gitOut(repo, "rev-parse", "HEAD"))
|
||||
if err := os.WriteFile(filepath.Join(repo, "f.go"), []byte("head\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
run("commit", "-aqm", "head")
|
||||
|
||||
orig := treeMatches
|
||||
treeMatches = func(r, rev string, files []string) error {
|
||||
if rev == base {
|
||||
return errors.New("injected: base side does not match")
|
||||
}
|
||||
return treeMatchesTree(r, rev, files)
|
||||
}
|
||||
t.Cleanup(func() { treeMatches = orig })
|
||||
|
||||
out, err := differentialPerTest(repo, base, []string{"f.go"}, nil)
|
||||
if err == nil {
|
||||
t.Fatalf("a failed base check reported success with %d pins", len(out))
|
||||
}
|
||||
got, rerr := os.ReadFile(filepath.Join(repo, "f.go"))
|
||||
if rerr != nil {
|
||||
t.Fatal(rerr)
|
||||
}
|
||||
if strings.TrimSpace(string(got)) != "head" {
|
||||
t.Fatalf("the tree was left on base: f.go = %q, want the PR's content", strings.TrimSpace(string(got)))
|
||||
}
|
||||
}
|
||||
|
||||
// A pin table that was already produced still stands, so the failure line must
|
||||
// not claim both figures are missing when only the mutation sweep failed.
|
||||
func TestAnalysisFailureLineNamesWhatIsMissing(t *testing.T) {
|
||||
only := analysisFailureLine(diffReport{analysisErr: errors.New("boom"), pins: []pinResult{{}}})
|
||||
if !strings.Contains(only, "mutation figures are absent") || !strings.Contains(only, "pin table above still stands") {
|
||||
t.Fatalf("with pins present the line reads %q", only)
|
||||
}
|
||||
if strings.Contains(only, "Pin and mutation figures are absent") {
|
||||
t.Fatalf("with pins present the line still claims both are absent: %q", only)
|
||||
}
|
||||
both := analysisFailureLine(diffReport{analysisErr: errors.New("boom")})
|
||||
if !strings.Contains(both, "Pin and mutation figures are absent") {
|
||||
t.Fatalf("with no pins the line reads %q", both)
|
||||
}
|
||||
if analysisFailureLine(diffReport{}) != "" {
|
||||
t.Fatal("a finished analysis produced a failure line")
|
||||
}
|
||||
}
|
||||
@@ -51,7 +51,7 @@ func decideRollout(baseline, candidate []result) rolloutDecision {
|
||||
case bv == cv:
|
||||
hold("rollout.same_version")
|
||||
}
|
||||
if !strings.HasPrefix(corpusVerdict(baseline, candidate), "same") {
|
||||
if !corpusVerdict(baseline, candidate).same {
|
||||
hold("rollout.corpus_mismatch")
|
||||
}
|
||||
if !slices.ContainsFunc(counted(candidate), func(r result) bool { return r.NoSolution }) {
|
||||
|
||||
+54
-6
@@ -107,6 +107,18 @@ func (c tapeConfig) forRun(taskID string, trial int) (*tape, error) {
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// This run replaces the tape. Drop the old completeness markers first, or
|
||||
// a failure here would leave the previous request beside this run's
|
||||
// partial response and read as a complete exchange.
|
||||
stale, err := filepath.Glob(filepath.Join(dir, "*.request.json"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, path := range stale {
|
||||
if err := os.Remove(path); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
} else if _, err := os.Stat(dir); err != nil {
|
||||
return nil, fmt.Errorf("no tape for %s: %w", taskID, err)
|
||||
}
|
||||
@@ -122,11 +134,26 @@ func (t *tape) file(index int, suffix string) string {
|
||||
return filepath.Join(t.dir, fmt.Sprintf("%04d.%s", index, suffix))
|
||||
}
|
||||
|
||||
func (t *tape) save(index int, request []byte, status int, contentType string, body []byte) {
|
||||
meta, _ := json.Marshal(tapeResponse{Status: status, ContentType: contentType})
|
||||
_ = os.WriteFile(t.file(index, "request.json"), request, 0o644)
|
||||
_ = os.WriteFile(t.file(index, "response.json"), meta, 0o644)
|
||||
_ = os.WriteFile(t.file(index, "response.body"), body, 0o644)
|
||||
// tapeWriteFile is a variable so a test can inject a write failure.
|
||||
var tapeWriteFile = os.WriteFile
|
||||
|
||||
// save records one exchange. request.json goes last: its presence is what tells
|
||||
// a later replay the exchange is complete rather than truncated.
|
||||
func (t *tape) save(index int, request []byte, status int, contentType string, body []byte) error {
|
||||
meta, err := json.Marshal(tapeResponse{Status: status, ContentType: contentType})
|
||||
if err != nil {
|
||||
return fmt.Errorf("tape %d response meta: %w", index, err)
|
||||
}
|
||||
if err := tapeWriteFile(t.file(index, "response.json"), meta, 0o644); err != nil {
|
||||
return fmt.Errorf("tape %d response.json: %w", index, err)
|
||||
}
|
||||
if err := tapeWriteFile(t.file(index, "response.body"), body, 0o644); err != nil {
|
||||
return fmt.Errorf("tape %d response.body: %w", index, err)
|
||||
}
|
||||
if err := tapeWriteFile(t.file(index, "request.json"), request, 0o644); err != nil {
|
||||
return fmt.Errorf("tape %d request.json: %w", index, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// load returns the recorded answer to request index, and where the request
|
||||
@@ -142,7 +169,12 @@ func (t *tape) load(index int, request []byte) (resp tapeResponse, body []byte,
|
||||
if body, err = os.ReadFile(t.file(index, "response.body")); err != nil {
|
||||
return resp, nil, "", err
|
||||
}
|
||||
recorded, _ := os.ReadFile(t.file(index, "request.json"))
|
||||
recorded, err := os.ReadFile(t.file(index, "request.json"))
|
||||
if err != nil {
|
||||
// Not a divergence: without the recorded request there is nothing to
|
||||
// compare against, and reporting one would blame the harness.
|
||||
return resp, nil, "", fmt.Errorf("tape %d is incomplete: %w", index, err)
|
||||
}
|
||||
return resp, body, requestDiff(recorded, request), nil
|
||||
}
|
||||
|
||||
@@ -249,3 +281,19 @@ func renderTapeReplay(results []result) string {
|
||||
}
|
||||
return line + "\n\n"
|
||||
}
|
||||
|
||||
// renderTapeRecord reports recordings that did not finish, so a run whose tape
|
||||
// cannot be replayed says so instead of looking recorded.
|
||||
func renderTapeRecord(results []result) string {
|
||||
var incomplete []string
|
||||
for _, r := range results {
|
||||
if r.Meter == nil || r.Meter.TapeIncomplete == 0 {
|
||||
continue
|
||||
}
|
||||
incomplete = append(incomplete, fmt.Sprintf("`%s` (%d exchanges): %s", r.ID, r.Meter.TapeIncomplete, r.Meter.TapeError))
|
||||
}
|
||||
if len(incomplete) == 0 {
|
||||
return ""
|
||||
}
|
||||
return "**Tape record incomplete**: " + strings.Join(incomplete, " · ") + "\n\n"
|
||||
}
|
||||
@@ -1,11 +1,15 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
const tapedStream = "data: {\"choices\":[{\"delta\":{\"content\":\"fixed\"}}]}\n\n" +
|
||||
@@ -103,3 +107,179 @@ func TestRequestDiffReadsStructureBeforeBytes(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func waitFor(t *testing.T, what string, cond func() bool) {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if cond() {
|
||||
return
|
||||
}
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
t.Fatalf("timed out waiting for %s", what)
|
||||
}
|
||||
|
||||
// A recording whose request file cannot be written is reported rather than
|
||||
// treated as a tape: request.json is what marks the exchange complete.
|
||||
func TestTapeSaveFailureIsReportedOnTheMeter(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
orig := tapeWriteFile
|
||||
tapeWriteFile = func(path string, body []byte, mode os.FileMode) error {
|
||||
if strings.HasSuffix(path, "request.json") {
|
||||
return errors.New("disk full")
|
||||
}
|
||||
return orig(path, body, mode)
|
||||
}
|
||||
t.Cleanup(func() { tapeWriteFile = orig })
|
||||
|
||||
var calls atomic.Int32
|
||||
m, base, stop := tapedMeter(t, tapeRecord, root, &calls)
|
||||
defer stop()
|
||||
readAll(t, post(t, base, "/chat/completions", tapedRequest))
|
||||
|
||||
var got meterUsage
|
||||
waitFor(t, "the failed write to be reported", func() bool {
|
||||
got = m.snapshot()
|
||||
return got.TapeIncomplete > 0
|
||||
})
|
||||
if !strings.Contains(got.TapeError, "disk full") {
|
||||
t.Fatalf("incomplete recording = %+v, want the write error named", got)
|
||||
}
|
||||
if markers, _ := filepath.Glob(filepath.Join(root, "bugfix-slug", "trial-1", "*.request.json")); len(markers) != 0 {
|
||||
t.Fatalf("the completeness marker was written although the request write failed: %v", markers)
|
||||
}
|
||||
line := renderTapeRecord([]result{{task: task{ID: "bugfix-slug"}, Meter: &got}})
|
||||
if !strings.Contains(line, "Tape record incomplete") || !strings.Contains(line, "bugfix-slug") {
|
||||
t.Fatalf("report line = %q", line)
|
||||
}
|
||||
if renderTapeRecord(nil) != "" {
|
||||
t.Fatal("an empty run set produced a tape line")
|
||||
}
|
||||
}
|
||||
|
||||
// A tape with no recorded request is incomplete, not a divergence: there is
|
||||
// nothing to compare against, and blaming the harness would be wrong.
|
||||
func TestTapeMissingRequestIsIncompleteNotADivergence(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
var calls atomic.Int32
|
||||
_, base, stop := tapedMeter(t, tapeRecord, root, &calls)
|
||||
readAll(t, post(t, base, "/chat/completions", tapedRequest))
|
||||
dir := filepath.Join(root, "bugfix-slug", "trial-1")
|
||||
var markers []string
|
||||
waitFor(t, "the recorded request", func() bool {
|
||||
markers, _ = filepath.Glob(filepath.Join(dir, "*.request.json"))
|
||||
return len(markers) > 0
|
||||
})
|
||||
stop()
|
||||
if err := os.Remove(markers[0]); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
m, base, stop := tapedMeter(t, tapeReplay, root, &calls)
|
||||
defer stop()
|
||||
resp := post(t, base, "/chat/completions", tapedRequest)
|
||||
readAll(t, resp)
|
||||
got := m.snapshot()
|
||||
if resp.StatusCode != http.StatusBadGateway || got.TapeMissing != 1 {
|
||||
t.Fatalf("incomplete tape = %d, missing %d; want 502 and counted", resp.StatusCode, got.TapeMissing)
|
||||
}
|
||||
if got.DivergedAt != 0 || got.Divergence != "" {
|
||||
t.Fatalf("an incomplete tape was reported as a divergence: %d %q", got.DivergedAt, got.Divergence)
|
||||
}
|
||||
}
|
||||
|
||||
// The run path snapshots the meter through record, which must stop the meter
|
||||
// first: stopping is what waits for the handlers whose deferred writes carry the
|
||||
// tape counters. Snapshotting before that misses the last exchange.
|
||||
func TestMeterRecordDrainsHandlersBeforeSnapshot(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
var calls atomic.Int32
|
||||
m, base, stop := tapedMeter(t, tapeRecord, root, &calls)
|
||||
defer stop()
|
||||
readAll(t, post(t, base, "/chat/completions", tapedRequest))
|
||||
|
||||
var r result
|
||||
runMeter{m: m, stop: stop}.record(&r)
|
||||
// No waitFor: the drain inside record is what makes this observable.
|
||||
if r.Meter == nil {
|
||||
t.Fatal("record attached no meter")
|
||||
}
|
||||
if resp, err := http.Post(base+"/chat/completions", "application/json", strings.NewReader(tapedRequest)); err == nil {
|
||||
resp.Body.Close()
|
||||
t.Fatal("the meter was still serving after record, so its handlers were not drained")
|
||||
}
|
||||
}
|
||||
|
||||
// Re-recording over an existing tape must not let the previous run's
|
||||
// completeness marker make a partial new exchange look complete.
|
||||
func TestTapeReRecordClearsStaleCompletenessMarkers(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
var calls atomic.Int32
|
||||
_, base, stop := tapedMeter(t, tapeRecord, root, &calls)
|
||||
readAll(t, post(t, base, "/chat/completions", tapedRequest))
|
||||
dir := filepath.Join(root, "bugfix-slug", "trial-1")
|
||||
waitFor(t, "the first recording", func() bool {
|
||||
m, _ := filepath.Glob(filepath.Join(dir, "*.request.json"))
|
||||
return len(m) > 0
|
||||
})
|
||||
stop()
|
||||
|
||||
// The second recording fails while writing the body, after the request has
|
||||
// already been replaced in memory.
|
||||
orig := tapeWriteFile
|
||||
tapeWriteFile = func(path string, body []byte, mode os.FileMode) error {
|
||||
if strings.HasSuffix(path, "response.body") {
|
||||
return errors.New("disk full")
|
||||
}
|
||||
return orig(path, body, mode)
|
||||
}
|
||||
t.Cleanup(func() { tapeWriteFile = orig })
|
||||
|
||||
var calls2 atomic.Int32
|
||||
m2, base2, stop2 := tapedMeter(t, tapeRecord, root, &calls2)
|
||||
defer stop2()
|
||||
readAll(t, post(t, base2, "/chat/completions", tapedRequest))
|
||||
var r result
|
||||
runMeter{m: m2, stop: stop2}.record(&r)
|
||||
if markers, _ := filepath.Glob(filepath.Join(dir, "*.request.json")); len(markers) != 0 {
|
||||
t.Fatalf("a failed re-record left completeness markers: %v", markers)
|
||||
}
|
||||
// Replay must call the exchange incomplete, not compare a stale request with
|
||||
// a partial response.
|
||||
m3, base3, stop3 := tapedMeter(t, tapeReplay, root, &calls2)
|
||||
defer stop3()
|
||||
resp := post(t, base3, "/chat/completions", tapedRequest)
|
||||
readAll(t, resp)
|
||||
if resp.StatusCode != http.StatusBadGateway {
|
||||
t.Fatalf("replay of a partial tape = %d, want 502", resp.StatusCode)
|
||||
}
|
||||
if got := m3.snapshot(); got.DivergedAt != 0 {
|
||||
t.Fatalf("a partial tape was reported as a divergence: %d %q", got.DivergedAt, got.Divergence)
|
||||
}
|
||||
}
|
||||
|
||||
// The healthy path must stay quiet: a recording that completed reports no
|
||||
// incomplete exchange and prints no line about one. Only the failure paths were
|
||||
// pinned before.
|
||||
func TestTapeCompleteRecordingReportsNothingIncomplete(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
var calls atomic.Int32
|
||||
m, base, stop := tapedMeter(t, tapeRecord, root, &calls)
|
||||
defer stop()
|
||||
readAll(t, post(t, base, "/chat/completions", tapedRequest))
|
||||
|
||||
var r result
|
||||
runMeter{m: m, stop: stop}.record(&r)
|
||||
if r.Meter == nil {
|
||||
t.Fatal("record attached no meter")
|
||||
}
|
||||
if r.Meter.TapeIncomplete != 0 || r.Meter.TapeError != "" {
|
||||
t.Fatalf("a complete recording reported %d incomplete exchanges (%q)", r.Meter.TapeIncomplete, r.Meter.TapeError)
|
||||
}
|
||||
// renderTapeRecord is the printer behind the report's summary block, so an
|
||||
// empty string here is what "no line is printed" means.
|
||||
if line := renderTapeRecord([]result{{task: task{ID: "bugfix-slug"}, Meter: r.Meter}}); line != "" {
|
||||
t.Fatalf("a complete recording printed %q, want no line", line)
|
||||
}
|
||||
}
|
||||
+26
-11
@@ -50,42 +50,57 @@ func tallyTrials(results []result) (map[string]trialTally, int) {
|
||||
return tally, k
|
||||
}
|
||||
|
||||
// trialsLine reports reliability over repeated trials. pass^k counts a task
|
||||
// only when every trial it ran passed; a task cut short by the budget is
|
||||
// judged on the trials it has, and the line says how many were complete.
|
||||
// Empty for a run without trials, so older reports read as before.
|
||||
// trialsLine reports reliability over repeated trials. pass^k and pass@k are
|
||||
// over the tasks that ran all k trials (k is the highest trial index that ran);
|
||||
// a task cut short by the budget has no value for either and is listed apart.
|
||||
// mean pass@1 is passes over every trial that ran to a grade. Empty for a run
|
||||
// without trials, so older reports read as before.
|
||||
func trialsLine(results []result) string {
|
||||
tally, k := tallyTrials(results)
|
||||
if k < 2 || len(tally) == 0 {
|
||||
return ""
|
||||
}
|
||||
all, any, complete, runs, passes := 0, 0, 0, 0, 0
|
||||
var flaky []string
|
||||
var flaky, incomplete []string
|
||||
for id, t := range tally {
|
||||
runs += t.ran
|
||||
passes += t.passed
|
||||
if t.ran == k {
|
||||
complete++
|
||||
if t.passed > 0 && t.passed < t.ran {
|
||||
flaky = append(flaky, fmt.Sprintf("`%s` %d/%d", id, t.passed, t.ran))
|
||||
}
|
||||
if t.ran < k {
|
||||
incomplete = append(incomplete, fmt.Sprintf("`%s` %d/%d", id, t.ran, k))
|
||||
continue
|
||||
}
|
||||
complete++
|
||||
if t.passed == t.ran {
|
||||
all++
|
||||
}
|
||||
if t.passed > 0 {
|
||||
any++
|
||||
}
|
||||
if t.passed > 0 && t.passed < t.ran {
|
||||
flaky = append(flaky, fmt.Sprintf("`%s` %d/%d", id, t.passed, t.ran))
|
||||
}
|
||||
}
|
||||
sort.Strings(flaky)
|
||||
sort.Strings(incomplete)
|
||||
passAll, passAny := fmt.Sprintf("n/a (no task ran %d trials)", k), "n/a"
|
||||
if complete > 0 {
|
||||
passAll, passAny = shareOf(all, complete), shareOf(any, complete)
|
||||
}
|
||||
line := fmt.Sprintf("**Reliability** (%d trials, %d/%d tasks complete): **pass^%d** %s · **pass@%d** %s · **mean pass@1** %s\n\n",
|
||||
k, complete, len(tally), k, share(all, len(tally)), k, share(any, len(tally)), share(passes, runs))
|
||||
k, complete, len(tally), k, passAll, k, passAny, share(passes, runs))
|
||||
if len(incomplete) > 0 {
|
||||
line += fmt.Sprintf("**Incomplete** (fewer than %d trials, not in pass^%d or pass@%d): %s\n\n", k, k, k, strings.Join(incomplete, " · "))
|
||||
}
|
||||
if len(flaky) > 0 {
|
||||
line += "**Flaky** (passed some trials, not all): " + strings.Join(flaky, " · ") + "\n\n"
|
||||
}
|
||||
return line
|
||||
}
|
||||
|
||||
func shareOf(n, d int) string {
|
||||
return fmt.Sprintf("%s (%d/%d complete)", pct(n, d), n, d)
|
||||
}
|
||||
|
||||
func share(n, d int) string {
|
||||
return fmt.Sprintf("%s (%d/%d)", pct(n, d), n, d)
|
||||
}
|
||||
@@ -21,8 +21,8 @@ func TestTrialsLineReportsReliabilityAcrossTrials(t *testing.T) {
|
||||
line := trialsLine(results)
|
||||
for _, want := range []string{
|
||||
"3 trials, 3/3 tasks complete",
|
||||
"**pass^3** 33% (1/3)",
|
||||
"**pass@3** 67% (2/3)",
|
||||
"**pass^3** 33% (1/3 complete)",
|
||||
"**pass@3** 67% (2/3 complete)",
|
||||
"**mean pass@1** 56% (5/9)",
|
||||
"`flaky` 2/3",
|
||||
} {
|
||||
@@ -35,15 +35,29 @@ func TestTrialsLineReportsReliabilityAcrossTrials(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// A trial the budget skipped is not a failure; the task is judged on the
|
||||
// trials it ran, and the line says it was not complete.
|
||||
func TestTrialsLineJudgesABudgetCutTaskOnWhatRan(t *testing.T) {
|
||||
results := []result{
|
||||
trialResult("a", 1, true), trialResult("a", 2, true),
|
||||
trialResult("b", 1, true), {task: task{ID: "b"}, Trial: 2, Skipped: true},
|
||||
// pass^k is the chance all k trials pass, which a task that ran fewer has no
|
||||
// value for. A trial the budget skipped is not a failure, so such a task is
|
||||
// left out of pass^k and pass@k and named, and its trials still count toward
|
||||
// mean pass@1.
|
||||
func TestTrialsLineKeepsATaskThatRanFewerTrialsOutOfPassK(t *testing.T) {
|
||||
for _, bPassed := range []bool{true, false} {
|
||||
results := []result{
|
||||
trialResult("a", 1, true), trialResult("a", 2, true),
|
||||
trialResult("b", 1, bPassed), {task: task{ID: "b"}, Trial: 2, Skipped: true},
|
||||
}
|
||||
line := trialsLine(results)
|
||||
for _, want := range []string{"1/2 tasks complete", "**pass^2** 100% (1/1 complete)", "**pass@2** 100% (1/1 complete)", "**Incomplete** (fewer than 2 trials, not in pass^2 or pass@2): `b` 1/2"} {
|
||||
if !strings.Contains(line, want) {
|
||||
t.Fatalf("bPassed=%v: missing %q in:\n%s", bPassed, want, line)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTrialsLineHasNoPassKWhenNoTaskRanEveryTrial(t *testing.T) {
|
||||
results := []result{trialResult("a", 1, true), trialResult("a", 3, true), trialResult("b", 1, true), trialResult("b", 2, true)}
|
||||
line := trialsLine(results)
|
||||
if !strings.Contains(line, "1/2 tasks complete") || !strings.Contains(line, "**pass^2** 100% (2/2)") {
|
||||
if !strings.Contains(line, "**pass^3** n/a (no task ran 3 trials)") || !strings.Contains(line, "**pass@3** n/a") {
|
||||
t.Fatalf("line = %s", line)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
@@ -67,6 +68,7 @@ type shellIdentity struct {
|
||||
version string
|
||||
exe string
|
||||
pid int
|
||||
logsDir string
|
||||
}
|
||||
|
||||
func (s shellIdentity) stated() bool {
|
||||
@@ -89,7 +91,7 @@ func studioInstall(shell shellIdentity) *update.Install {
|
||||
// nil where the shell did not state enough to own one. Nil is the gate: the
|
||||
// routes are not registered, so a host that cannot name the application it
|
||||
// would replace has no way to be asked to replace it.
|
||||
func studioUpdateHost(shell shellIdentity, to io.Writer) appupdate.Capability {
|
||||
func studioUpdateHost(shell shellIdentity, owner *shellOwner) appupdate.Capability {
|
||||
if !shell.stated() {
|
||||
return nil
|
||||
}
|
||||
@@ -97,7 +99,7 @@ func studioUpdateHost(shell shellIdentity, to io.Writer) appupdate.Capability {
|
||||
// still work; the swap is what refuses, by name.
|
||||
application, _ := update.ApplicationAt(shell.exe, shell.pid)
|
||||
return appupdate.New(appupdate.Options{
|
||||
Owner: &shellOwner{to: to},
|
||||
Owner: owner,
|
||||
Running: shell.version,
|
||||
Line: update.StudioLine(),
|
||||
Application: application,
|
||||
@@ -142,13 +144,22 @@ func main() {
|
||||
return
|
||||
}
|
||||
boot.SetComputerHelper(*computerHelper)
|
||||
shell := shellIdentity{version: *studioVersion, exe: *studioApp, pid: *studioAppPID}
|
||||
shell := shellIdentity{version: *studioVersion, exe: *studioApp, pid: *studioAppPID, logsDir: logsDirOf(*crashDir)}
|
||||
releaseFatalLog := crashreport.InstallFatalLog(*crashDir, defaultVersion(shell.version))
|
||||
code := run(parentLease(os.Stdin), os.Stdout, os.Stderr, *page, shell)
|
||||
releaseFatalLog()
|
||||
os.Exit(code)
|
||||
}
|
||||
|
||||
// logsDirOf is the shell's logs directory: the crash directory it passed is
|
||||
// the "crash" folder inside it.
|
||||
func logsDirOf(crashDir string) string {
|
||||
if strings.TrimSpace(crashDir) == "" {
|
||||
return ""
|
||||
}
|
||||
return filepath.Dir(crashDir)
|
||||
}
|
||||
|
||||
func defaultVersion(stated string) string {
|
||||
if strings.TrimSpace(stated) != "" {
|
||||
return stated
|
||||
@@ -370,6 +381,10 @@ func assemble(ctx context.Context, logs, handshakeTo io.Writer, shell shellIdent
|
||||
<-ctx.Done()
|
||||
share.Close()
|
||||
}()
|
||||
owner := &shellOwner{to: handshakeTo}
|
||||
// A toast the user clicks is answered here, in the process that sent it, and
|
||||
// the shell is the one that can bring its window forward.
|
||||
notify.OnActivate(showOnToastClick(owner))
|
||||
hub := serve.NewHub(serve.HubOptions{
|
||||
Serve: hubCfg,
|
||||
Surface: surface.Desktop,
|
||||
@@ -378,13 +393,15 @@ func assemble(ctx context.Context, logs, handshakeTo io.Writer, shell shellIdent
|
||||
DecorateSink: decorate,
|
||||
Tray: &studioTray{tracker: tracker},
|
||||
Notifications: notifySet,
|
||||
NotifySender: notify.NewPlatformSender(),
|
||||
BrowserHost: browserHost,
|
||||
Asks: asks,
|
||||
Remote: remotehost.New(ctx, version, asks),
|
||||
OnClose: func(rt *serve.Runtime) { tracker.Drop(paneKey(rt.Events)) },
|
||||
Install: studioInstall(shell),
|
||||
Update: studioUpdateHost(shell, handshakeTo),
|
||||
Update: studioUpdateHost(shell, owner),
|
||||
Share: share,
|
||||
LogsDir: shell.logsDir,
|
||||
})
|
||||
share.Attach(hub.Handler())
|
||||
srv := serve.New(built.Controller, bc, hubCfg)
|
||||
|
||||
@@ -9,13 +9,21 @@ import (
|
||||
"sync"
|
||||
)
|
||||
|
||||
// The acts a handover asks of the shell. Only two travel: releasing the
|
||||
// application is this process's own hub, and the shell has nothing to do for it.
|
||||
// The acts this process asks of the shell. Releasing the application is this
|
||||
// process's own hub, so the shell has nothing to do for it; the other three are
|
||||
// what only the shell can do.
|
||||
const (
|
||||
actRelaunch = "relaunch"
|
||||
actQuit = "quit"
|
||||
actShow = "show"
|
||||
)
|
||||
|
||||
// showOnToastClick is what a clicked toast runs: ask the shell, which is the
|
||||
// only process that can bring its window forward, to do so.
|
||||
func showOnToastClick(o *shellOwner) func() {
|
||||
return func() { _ = o.ask(actShow) }
|
||||
}
|
||||
|
||||
// shellOwner is the application this process was spawned by, reached over the
|
||||
// pipe the handshake went out on -- the only channel pointing this way, since
|
||||
// stdin is the lease and HTTP runs from the shell to here. Nothing is waited
|
||||
|
||||
@@ -78,7 +78,7 @@ func TestAHostThatCannotNameTheApplicationOwnsNoCapability(t *testing.T) {
|
||||
{"an unpackaged launch, which names no version", shellIdentity{exe: "/somewhere/studio", pid: 4321}},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := studioUpdateHost(tc.shell, &out); got != nil {
|
||||
if got := studioUpdateHost(tc.shell, &shellOwner{to: &out}); got != nil {
|
||||
t.Fatalf("studioUpdateHost = %#v, want nil so no install route registers", got)
|
||||
}
|
||||
})
|
||||
@@ -89,7 +89,7 @@ func TestAHostThatCannotNameTheApplicationOwnsNoCapability(t *testing.T) {
|
||||
func TestAHostThatNamesTheApplicationOwnsOne(t *testing.T) {
|
||||
var out bytes.Buffer
|
||||
shell := shellIdentity{version: "v2.0.0", exe: "/Applications/Reasonix Studio.app/Contents/MacOS/Reasonix Studio", pid: 4321}
|
||||
if studioUpdateHost(shell, &out) == nil {
|
||||
if studioUpdateHost(shell, &shellOwner{to: &out}) == nil {
|
||||
t.Fatal("a host that stated its application got no capability")
|
||||
}
|
||||
}
|
||||
@@ -116,3 +116,13 @@ func TestTheInstallLayoutFollowsTheStatedApplication(t *testing.T) {
|
||||
t.Fatalf("studioInstall = %+v for a launch that named no version, want nil", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A clicked toast reaches the shell as one line on the pipe the handshake used,
|
||||
// in the same shape the other acts take.
|
||||
func TestAToastClickAsksTheShellToShowItsWindow(t *testing.T) {
|
||||
var out bytes.Buffer
|
||||
showOnToastClick(&shellOwner{to: &out})()
|
||||
if got := out.String(); got != "{\"act\":\"show\"}\n" {
|
||||
t.Fatalf("pipe carried %q, want the show act", got)
|
||||
}
|
||||
}
|
||||
@@ -191,12 +191,13 @@ func TestPublishRefusesAPartialWindowsSet(t *testing.T) {
|
||||
root := parseWorkflowFile(t, "../../.github/workflows/release-studio.yml")
|
||||
publish := mappingValue(jobsOf(t, root), "publish")
|
||||
run := mappingScalar(stepNamed(t, publish, "Collect artifacts"), "run")
|
||||
if !strings.Contains(run, `"$count" -lt 9`) {
|
||||
t.Error("publish does not require at least 9 ReasonixStudio-* packages")
|
||||
if !strings.Contains(run, `"$count" -lt 10`) {
|
||||
t.Error("publish does not require at least 10 ReasonixStudio-* packages")
|
||||
}
|
||||
for _, name := range []string{
|
||||
"ReasonixStudio-windows-amd64-installer.exe", "ReasonixStudio-windows-arm64-installer.exe",
|
||||
"ReasonixStudio-windows-amd64.zip", "ReasonixStudio-windows-arm64.zip",
|
||||
"ReasonixStudio-linux-*.deb", "ReasonixStudio-linux-*.rpm",
|
||||
} {
|
||||
if !strings.Contains(run, "'"+name+"'") {
|
||||
t.Errorf("publish does not require %s by name", name)
|
||||
|
||||
+1
-1
@@ -36,7 +36,7 @@ are written down in [`docs/STUDIO_SHELL_BOUNDARIES.md`](../docs/STUDIO_SHELL_BOU
|
||||
| `electron/` | the shell: main process, preload bridge, packaging |
|
||||
| `frontend-next/` | the SPA it serves |
|
||||
| `internal/platform/update/` | manifest, download, verify, apply — shared by the shell and the helper |
|
||||
| `cmd/update-helper/` | the elevated half of an update (dpkg on Linux, versioned install on Windows) |
|
||||
| `cmd/update-helper/` | the elevated half of an update (dpkg on Linux) |
|
||||
| `cmd/studio-manifest/`, `cmd/sign/` | release-side tools |
|
||||
| `internal/winuninstall/` | taking a previous Windows install over |
|
||||
|
||||
|
||||
@@ -63,11 +63,6 @@ func main() {
|
||||
notesVersion = os.Args[5]
|
||||
}
|
||||
err = genManifest(os.Args[2], os.Args[3], os.Args[4], notesVersion)
|
||||
case "windows-payload":
|
||||
if len(os.Args) != 4 {
|
||||
usage()
|
||||
}
|
||||
err = genWindowsPayloadManifest(os.Args[2], os.Args[3])
|
||||
case "genkey":
|
||||
if len(os.Args) != 3 {
|
||||
usage()
|
||||
@@ -88,34 +83,10 @@ func main() {
|
||||
}
|
||||
|
||||
func usage() {
|
||||
fmt.Fprintln(os.Stderr, "usage:\n sign <file>...\n manifest <dir> <version> <tag> [notes-version]\n windows-payload <dir> <version>\n genkey <dir>\n verify <file>")
|
||||
fmt.Fprintln(os.Stderr, "usage:\n sign <file>...\n manifest <dir> <version> <tag> [notes-version]\n genkey <dir>\n verify <file>")
|
||||
os.Exit(2)
|
||||
}
|
||||
|
||||
func genWindowsPayloadManifest(dir, version string) error {
|
||||
hashes := make(map[string]string)
|
||||
for _, name := range update.WindowsPayloadFileNames() {
|
||||
path := filepath.Join(dir, name)
|
||||
info, err := os.Lstat(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("Windows payload %s: %w", name, err)
|
||||
}
|
||||
if !info.Mode().IsRegular() {
|
||||
return fmt.Errorf("Windows payload %s is not a regular file", name)
|
||||
}
|
||||
_, sum, err := hashFile(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
hashes[name] = sum
|
||||
}
|
||||
b, err := update.EncodeWindowsPayloadManifest(version, hashes)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(filepath.Join(dir, update.WindowsPayloadManifestName), b, 0o644)
|
||||
}
|
||||
|
||||
// verifyFile checks <file> against <file>.minisig using the embedded public key —
|
||||
// the same check the updater runs before applying. A self-test that the signing
|
||||
// key matches what's compiled in. Returns an error (nonzero exit) on mismatch.
|
||||
|
||||
@@ -206,29 +206,3 @@ func TestGenManifestIgnoresUnknownNativePackages(t *testing.T) {
|
||||
t.Fatalf("unexpected native_packages: %+v", m.NativePackages)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenWindowsPayloadManifestHashesExactReleaseUnit(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
for _, name := range update.WindowsPayloadFileNames() {
|
||||
if err := os.WriteFile(filepath.Join(dir, name), []byte("payload:"+name), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := genWindowsPayloadManifest(dir, "v2.3.4"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b, err := os.ReadFile(filepath.Join(dir, update.WindowsPayloadManifestName))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
hashes, err := update.DecodeWindowsPayloadManifest(b, "v2.3.4")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, name := range update.WindowsPayloadFileNames() {
|
||||
want := update.WindowsPayloadSHA256([]byte("payload:" + name))
|
||||
if hashes[name] != want {
|
||||
t.Fatalf("manifest hash for %s = %q, want %q", name, hashes[name], want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/platform/update"
|
||||
@@ -13,7 +14,8 @@ import (
|
||||
// archMap is desktop/electron/arch.json: what the packager builds for, and what
|
||||
// each of those is called in a release.
|
||||
type archMap struct {
|
||||
Map map[string]string `json:"map"`
|
||||
Map map[string]string `json:"map"`
|
||||
FPM map[string][]string `json:"fpm"`
|
||||
}
|
||||
|
||||
func loadArchMap(t *testing.T) archMap {
|
||||
@@ -93,3 +95,33 @@ func TestThePackagerSpellingResolvesToAKeyNobodyAsksFor(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// fpm names an .rpm for the architecture its own way, and the parser accepts
|
||||
// that name too: it keys a package nobody asks for. The packager hook renames
|
||||
// these spellings, so every one must be a non-canonical key here.
|
||||
func TestFPMSpellingOfAnRPMIsNotAReleaseKey(t *testing.T) {
|
||||
m := loadArchMap(t)
|
||||
if len(m.FPM) == 0 {
|
||||
t.Fatal("arch.json lists no fpm spellings")
|
||||
}
|
||||
canonical := map[string]bool{}
|
||||
for _, to := range m.Map {
|
||||
canonical[to] = true
|
||||
}
|
||||
for from, spellings := range m.FPM {
|
||||
to, ok := m.Map[from]
|
||||
if !ok {
|
||||
t.Errorf("fpm spells %s, which map does not release", from)
|
||||
continue
|
||||
}
|
||||
if got, ok := rpmPackageKey(artifactPrefix + "linux-" + to + ".rpm"); !ok || got != update.RPMPackageKey("linux", to) {
|
||||
t.Errorf("linux %s rpm = %q (%v)", to, got, ok)
|
||||
}
|
||||
for _, spelled := range spellings {
|
||||
got, ok := rpmPackageKey(artifactPrefix + "linux-" + spelled + ".rpm")
|
||||
if !ok || canonical[strings.TrimSuffix(strings.TrimPrefix(got, "linux-"), "-rpm")] {
|
||||
t.Errorf("%s resolved to %q (%v); the rename is no longer load-bearing", spelled, got, ok)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -84,6 +84,12 @@ func run(dir, version, tag, deltaDir string, mirrored bool) error {
|
||||
m.NativePackages[key] = asset
|
||||
fmt.Printf("native package: %s -> %s\n", name, key)
|
||||
}
|
||||
// The .rpm rides beside the .deb under its own key: the deb's key is the
|
||||
// one every installed client already reads.
|
||||
if key, ok := rpmPackageKey(name); ok {
|
||||
m.NativePackages[key] = asset
|
||||
fmt.Printf("native package: %s -> %s\n", name, key)
|
||||
}
|
||||
// Windows updates by running the next installer, so that is what the
|
||||
// platform lookup has to resolve. The portable archive is never listed:
|
||||
// resolving it would hand the updater an artifact it cannot install.
|
||||
@@ -135,7 +141,8 @@ func releaseAsset(repo, tag, name string, mirrored bool) update.Asset {
|
||||
func installable(name string) bool {
|
||||
return strings.HasSuffix(name, ".dmg") ||
|
||||
strings.HasSuffix(name, "-installer.exe") ||
|
||||
strings.HasSuffix(name, ".deb")
|
||||
strings.HasSuffix(name, ".deb") ||
|
||||
strings.HasSuffix(name, ".rpm")
|
||||
}
|
||||
|
||||
// macArchiveKeys maps a macOS archive to the platforms it can install. The
|
||||
@@ -185,6 +192,20 @@ func nativePackageKey(name string) (string, bool) {
|
||||
return update.PlatformKey("linux", arch), true
|
||||
}
|
||||
|
||||
// rpmPackageKey maps a ReasonixStudio-linux-<arch>.rpm artifact to its
|
||||
// native_packages key.
|
||||
func rpmPackageKey(name string) (string, bool) {
|
||||
base, ok := strings.CutSuffix(name, ".rpm")
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
arch, ok := strings.CutPrefix(base, artifactPrefix+"linux-")
|
||||
if !ok || arch == "" {
|
||||
return "", false
|
||||
}
|
||||
return update.RPMPackageKey("linux", arch), true
|
||||
}
|
||||
|
||||
func hashFile(path string) (int64, string, error) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
|
||||
@@ -232,3 +232,57 @@ func TestWindowsArchitecturesResolveToTheirOwnInstallers(t *testing.T) {
|
||||
t.Errorf("no delta directory was given, yet deltas = %v", m.Deltas)
|
||||
}
|
||||
}
|
||||
|
||||
// Every installed Linux client reads native_packages["linux-amd64"] as the
|
||||
// .deb and hands it to dpkg. The .rpm may add a key and never change that one.
|
||||
func TestRPMRidesBesideTheDebWithoutMovingItsKey(t *testing.T) {
|
||||
write := func(dir string, names ...string) {
|
||||
for _, name := range names {
|
||||
if err := os.WriteFile(filepath.Join(dir, name), []byte(name), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
generate := func(dir string) update.Manifest {
|
||||
t.Setenv("GITHUB_REPOSITORY", "esengine/DeepSeek-Reasonix")
|
||||
if err := run(dir, "v0.1.0", "studio-v0.1.0", "", true); err != nil {
|
||||
t.Fatalf("run: %v", err)
|
||||
}
|
||||
var m update.Manifest
|
||||
raw, err := os.ReadFile(filepath.Join(dir, "latest.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := json.Unmarshal(raw, &m); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return m
|
||||
}
|
||||
debOnly, both := t.TempDir(), t.TempDir()
|
||||
write(debOnly, "ReasonixStudio-linux-amd64.deb")
|
||||
write(both, "ReasonixStudio-linux-amd64.deb", "ReasonixStudio-linux-amd64.rpm")
|
||||
before, after := generate(debOnly), generate(both)
|
||||
|
||||
if after.NativePackages["linux-amd64"] != before.NativePackages["linux-amd64"] {
|
||||
t.Errorf("the deb entry moved: %+v, was %+v", after.NativePackages["linux-amd64"], before.NativePackages["linux-amd64"])
|
||||
}
|
||||
if !strings.HasSuffix(after.NativePackages["linux-amd64"].URL, "linux-amd64.deb") {
|
||||
t.Errorf("native_packages[linux-amd64] = %+v, want the .deb", after.NativePackages["linux-amd64"])
|
||||
}
|
||||
rpm, ok := after.NativePackages["linux-amd64-rpm"]
|
||||
if !ok || !strings.HasSuffix(rpm.URL, "ReasonixStudio-linux-amd64.rpm") || rpm.Sig != rpm.URL+".minisig" {
|
||||
t.Errorf("native_packages[linux-amd64-rpm] = %+v, want the signed .rpm", rpm)
|
||||
}
|
||||
if len(after.NativePackages) != 2 {
|
||||
t.Errorf("native packages = %v, want the deb and the rpm", after.NativePackages)
|
||||
}
|
||||
if _, ok := after.Platforms["linux-amd64"]; ok {
|
||||
t.Error("the .rpm must not resolve as a platform asset")
|
||||
}
|
||||
if _, ok := after.Downloads["ReasonixStudio-linux-amd64.rpm"]; !ok {
|
||||
t.Error("downloads must offer the .rpm to a person")
|
||||
}
|
||||
if len(after.Downloads) != len(before.Downloads)+1 {
|
||||
t.Errorf("downloads = %v, want only the rpm added", after.Downloads)
|
||||
}
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
package main
|
||||
|
||||
import "fmt"
|
||||
|
||||
func installerCommandLine(installer, dir string) string {
|
||||
// Auto-updates use a dedicated visible progress mode instead of NSIS /S.
|
||||
// The current helper also requires staging-only extraction: NSIS never writes
|
||||
// the live install, and the helper compare-and-publishes the claimed release
|
||||
// unit after validating every staged member.
|
||||
line := fmt.Sprintf(`"%s" /REASONIXUPDATE=1 /REASONIXSTAGE=1`, installer)
|
||||
if dir != "" {
|
||||
line += " /D=" + dir
|
||||
}
|
||||
return line
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestInstallerCommandLineUsesVisibleUpdateModeAndLeavesDFlagLast(t *testing.T) {
|
||||
got := installerCommandLine(`C:\Temp\Reasonix Installer.exe`, `D:\Tools\Reasonix App`)
|
||||
want := `"C:\Temp\Reasonix Installer.exe" /REASONIXUPDATE=1 /REASONIXSTAGE=1 /D=D:\Tools\Reasonix App`
|
||||
if got != want {
|
||||
t.Fatalf("installerCommandLine = %q, want %q", got, want)
|
||||
}
|
||||
if strings.Contains(got, " /S") {
|
||||
t.Fatalf("auto-update must expose progress instead of using silent mode, got %q", got)
|
||||
}
|
||||
if !strings.HasSuffix(got, `/D=D:\Tools\Reasonix App`) {
|
||||
t.Fatalf("/D= must be the final unquoted NSIS token, got %q", got)
|
||||
}
|
||||
if !strings.Contains(got, " /REASONIXSTAGE=1") {
|
||||
t.Fatalf("auto-update must extract away from the live install, got %q", got)
|
||||
}
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
//go:build !windows
|
||||
|
||||
package main
|
||||
|
||||
import "os"
|
||||
|
||||
// Windows verifies each executable with the OS Authenticode policy while the
|
||||
// same read handle is held. Non-Windows builds keep the release-unit loader
|
||||
// testable without importing platform-specific trust APIs.
|
||||
var readVerifiedWindowsStagedPayloadFn = os.ReadFile
|
||||
@@ -1,97 +0,0 @@
|
||||
//go:build windows
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"unsafe"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
var readVerifiedWindowsStagedPayloadFn = readVerifiedWindowsStagedPayload
|
||||
|
||||
// readVerifiedWindowsStagedPayload verifies the Authenticode signature and
|
||||
// reads the payload through one handle that disallows concurrent writes,
|
||||
// deletes, and renames. The bytes handed to the publisher therefore cannot
|
||||
// change between signature verification and publication.
|
||||
func readVerifiedWindowsStagedPayload(path string) ([]byte, error) {
|
||||
info, err := os.Lstat(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !info.Mode().IsRegular() {
|
||||
return nil, fmt.Errorf("staged payload is not a regular file")
|
||||
}
|
||||
path16, err := windows.UTF16PtrFromString(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
handle, err := windows.CreateFile(
|
||||
path16,
|
||||
windows.GENERIC_READ,
|
||||
windows.FILE_SHARE_READ,
|
||||
nil,
|
||||
windows.OPEN_EXISTING,
|
||||
windows.FILE_ATTRIBUTE_NORMAL|windows.FILE_FLAG_SEQUENTIAL_SCAN,
|
||||
0,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
file := os.NewFile(uintptr(handle), path)
|
||||
if file == nil {
|
||||
_ = windows.CloseHandle(handle)
|
||||
return nil, fmt.Errorf("open staged payload handle")
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
fileInfo, err := file.Stat()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !fileInfo.Mode().IsRegular() {
|
||||
return nil, fmt.Errorf("staged payload is not a regular file")
|
||||
}
|
||||
|
||||
trustFile := &windows.WinTrustFileInfo{
|
||||
Size: uint32(unsafe.Sizeof(windows.WinTrustFileInfo{})),
|
||||
FilePath: path16,
|
||||
File: handle,
|
||||
}
|
||||
trustData := &windows.WinTrustData{
|
||||
Size: uint32(unsafe.Sizeof(windows.WinTrustData{})),
|
||||
UIChoice: windows.WTD_UI_NONE,
|
||||
RevocationChecks: windows.WTD_REVOKE_NONE,
|
||||
UnionChoice: windows.WTD_CHOICE_FILE,
|
||||
FileOrCatalogOrBlobOrSgnrOrCert: unsafe.Pointer(trustFile),
|
||||
StateAction: windows.WTD_STATEACTION_VERIFY,
|
||||
UIContext: windows.WTD_UICONTEXT_EXECUTE,
|
||||
}
|
||||
verifyErr := windows.WinVerifyTrustEx(
|
||||
windows.InvalidHWND,
|
||||
&windows.WINTRUST_ACTION_GENERIC_VERIFY_V2,
|
||||
trustData,
|
||||
)
|
||||
trustData.StateAction = windows.WTD_STATEACTION_CLOSE
|
||||
closeErr := windows.WinVerifyTrustEx(
|
||||
windows.InvalidHWND,
|
||||
&windows.WINTRUST_ACTION_GENERIC_VERIFY_V2,
|
||||
trustData,
|
||||
)
|
||||
if verifyErr != nil {
|
||||
if closeErr != nil {
|
||||
return nil, fmt.Errorf("Authenticode verification failed: %w (release trust state: %w)", verifyErr, closeErr)
|
||||
}
|
||||
return nil, fmt.Errorf("Authenticode verification failed: %w", verifyErr)
|
||||
}
|
||||
if closeErr != nil {
|
||||
return nil, fmt.Errorf("release Authenticode verification state: %w", closeErr)
|
||||
}
|
||||
if _, err := file.Seek(0, io.SeekStart); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return io.ReadAll(file)
|
||||
}
|
||||
@@ -37,11 +37,11 @@ const (
|
||||
exitPostVerify = 16
|
||||
)
|
||||
|
||||
// packageName is the only dpkg package this helper will install, fixed at
|
||||
// build time so no runtime input can redirect it. Studio builds the same
|
||||
// source with its own name; each package installs its own binary, and Polkit
|
||||
// authorizes that path alone.
|
||||
var packageName = "reasonix-desktop"
|
||||
// packageName is the only dpkg package this helper will install. It is Studio's
|
||||
// line as the update path states it, compiled in, so no runtime input can
|
||||
// redirect it and no build step has a value to forget; Polkit authorizes this
|
||||
// helper's own path alone.
|
||||
var packageName = update.StudioLine().Deb.Package
|
||||
|
||||
const (
|
||||
dpkgDebPath = "/usr/bin/dpkg-deb"
|
||||
@@ -124,6 +124,9 @@ func runWith(d installDeps, args []string) int {
|
||||
switch args[0] {
|
||||
case "install":
|
||||
return runInstall(d, args[1:])
|
||||
case "package":
|
||||
fmt.Fprintln(os.Stdout, packageName)
|
||||
return exitOK
|
||||
default:
|
||||
d.writeResult(helperResult{OK: false, Error: "unknown command", Code: "usage"})
|
||||
return exitUsage
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/rand"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -14,6 +15,7 @@ import (
|
||||
"aead.dev/minisign"
|
||||
|
||||
"reasonix/internal/base/tempdir"
|
||||
"reasonix/internal/platform/update"
|
||||
)
|
||||
|
||||
func TestSanitizeHelperErrorStripsPaths(t *testing.T) {
|
||||
@@ -596,3 +598,44 @@ func TestMinisignRejectsWrongKeyAndTamper(t *testing.T) {
|
||||
t.Fatalf("exit = %d, want verify failed; results=%+v", code, probe.results)
|
||||
}
|
||||
}
|
||||
|
||||
// The helper installs one package and the shell asks dpkg about the same one:
|
||||
// both must come from the update path's own line, or an in-app update is
|
||||
// refused as a different package.
|
||||
func TestHelperInstallsTheLineTheUpdatePathNames(t *testing.T) {
|
||||
if packageName != update.StudioLine().Deb.Package {
|
||||
t.Fatalf("helper accepts %q, the update path names %q", packageName, update.StudioLine().Deb.Package)
|
||||
}
|
||||
var out bytes.Buffer
|
||||
old := os.Stdout
|
||||
r, w, _ := os.Pipe()
|
||||
os.Stdout = w
|
||||
code := runWith(realDeps(), []string{"package"})
|
||||
w.Close()
|
||||
os.Stdout = old
|
||||
_, _ = out.ReadFrom(r)
|
||||
if code != exitOK || strings.TrimSpace(out.String()) != packageName {
|
||||
t.Fatalf("package command = %d %q, want %q", code, out.String(), packageName)
|
||||
}
|
||||
}
|
||||
|
||||
// electron-builder names the packages; the helper must agree with both.
|
||||
func TestPackagedNamesMatchTheHelper(t *testing.T) {
|
||||
raw, err := os.ReadFile(filepath.Join("..", "..", "electron", "electron-builder.yml"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
names := map[string]string{}
|
||||
section := ""
|
||||
for line := range strings.SplitSeq(string(raw), "\n") {
|
||||
if line != "" && !strings.HasPrefix(line, " ") && !strings.HasPrefix(line, "#") && strings.HasSuffix(strings.TrimSpace(line), ":") {
|
||||
section = strings.TrimSuffix(strings.TrimSpace(line), ":")
|
||||
}
|
||||
if rest, ok := strings.CutPrefix(line, " packageName:"); ok {
|
||||
names[section] = strings.TrimSpace(rest)
|
||||
}
|
||||
}
|
||||
if names["deb"] != packageName || names["rpm"] != packageName {
|
||||
t.Fatalf("electron-builder.yml names deb %q and rpm %q, the helper accepts %q", names["deb"], names["rpm"], packageName)
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
//go:build !windows && !linux
|
||||
//go:build !linux
|
||||
|
||||
package main
|
||||
|
||||
@@ -8,6 +8,6 @@ import (
|
||||
)
|
||||
|
||||
func main() {
|
||||
fmt.Fprintln(os.Stderr, "reasonix-update-helper is only used by Windows and Linux desktop builds")
|
||||
fmt.Fprintln(os.Stderr, "reasonix-update-helper is only used by Linux builds")
|
||||
os.Exit(2)
|
||||
}
|
||||
@@ -1,581 +0,0 @@
|
||||
//go:build windows
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
|
||||
"reasonix/desktop/internal/winuninstall"
|
||||
"reasonix/internal/platform/installlayout"
|
||||
"reasonix/internal/platform/repair"
|
||||
)
|
||||
|
||||
const parentExitTimeout = 2 * time.Minute
|
||||
|
||||
var (
|
||||
waitForProcessExitFn = waitForProcessExit
|
||||
runInstallerFn = runInstaller
|
||||
startRelaunchFn = startRelaunch
|
||||
claimPendingFileUpdateFn = repair.ClaimPendingFileUpdateExact
|
||||
installStagedReleaseUnitFn = installStagedWindowsReleaseUnit
|
||||
recordInstalledUpdateFn = repair.RecordClaimedFileUpdateInstalled
|
||||
stageInstallerFn = stageVerifiedInstaller
|
||||
claimInstallerExecutionFn = claimVerifiedInstallerForExecution
|
||||
lstatUpdateStagingFn = os.Lstat
|
||||
reconcileWindowsUninstallRegistrationFn = winuninstall.Reconcile
|
||||
)
|
||||
|
||||
func main() {
|
||||
os.Exit(run(os.Args[1:]))
|
||||
}
|
||||
|
||||
func run(args []string) int {
|
||||
var parentPID uint
|
||||
var installer, installerSHA256, installDir, relaunch, toVersion, createdAt, transactionID, installLayout string
|
||||
fs := flag.NewFlagSet("reasonix-update-helper", flag.ContinueOnError)
|
||||
fs.SetOutput(os.Stderr)
|
||||
fs.UintVar(&parentPID, "parent-pid", 0, "Reasonix process id to wait for before installing")
|
||||
fs.StringVar(&installer, "installer", "", "verified NSIS installer path")
|
||||
fs.StringVar(&installerSHA256, "installer-sha256", "", "expected SHA-256 of the verified NSIS installer")
|
||||
fs.StringVar(&installDir, "install-dir", "", "Reasonix installation directory")
|
||||
fs.StringVar(&relaunch, "relaunch", "", "Reasonix executable to start after the installer succeeds")
|
||||
fs.StringVar(&toVersion, "to-version", "", "Reasonix version being installed")
|
||||
fs.StringVar(&createdAt, "created-at", "", "pending update creation timestamp")
|
||||
fs.StringVar(&transactionID, "transaction-id", "", "complete pending update identity")
|
||||
fs.StringVar(&installLayout, "install-layout", "", "installation layout contract")
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return 2
|
||||
}
|
||||
logger := newLogger()
|
||||
if installer == "" {
|
||||
logger.Print("missing --installer")
|
||||
return 2
|
||||
}
|
||||
if !validSHA256(installerSHA256) {
|
||||
logger.Print("missing or invalid --installer-sha256")
|
||||
return 2
|
||||
}
|
||||
if toVersion == "" {
|
||||
logger.Print("missing --to-version")
|
||||
return 2
|
||||
}
|
||||
if installLayout != "" && installLayout != installlayout.InstallLayoutVersionedV1 {
|
||||
logger.Printf("unsupported --install-layout %q", installLayout)
|
||||
return 2
|
||||
}
|
||||
if installLayout != installlayout.InstallLayoutVersionedV1 && createdAt == "" {
|
||||
logger.Print("missing --created-at")
|
||||
return 2
|
||||
}
|
||||
if installLayout != installlayout.InstallLayoutVersionedV1 && transactionID == "" {
|
||||
logger.Print("missing --transaction-id")
|
||||
return 2
|
||||
}
|
||||
if installDir == "" {
|
||||
logger.Print("missing --install-dir")
|
||||
return 2
|
||||
}
|
||||
if parentPID != 0 {
|
||||
if err := waitForProcessExitFn(uint32(parentPID), parentExitTimeout); err != nil {
|
||||
logger.Printf("wait for parent process %d: %v", parentPID, err)
|
||||
return 1
|
||||
}
|
||||
}
|
||||
if installLayout == installlayout.InstallLayoutVersionedV1 {
|
||||
return runVersionedWindowsUpdate(logger, installer, installerSHA256, installDir, relaunch, toVersion)
|
||||
}
|
||||
recoverExisting := func(reason string) int {
|
||||
if relaunch != "" {
|
||||
if relaunchErr := startRelaunchFn(preferRelaunchPath(relaunch, installDir), installDir); relaunchErr != nil {
|
||||
logger.Printf("relaunch after %s: %v", reason, relaunchErr)
|
||||
}
|
||||
}
|
||||
return 1
|
||||
}
|
||||
// The detached helper runs from the update cache, so a plain
|
||||
// repair.ReadPendingUpdate would validate the transaction against the cache
|
||||
// directory and reject every legitimate flat install. Claim through the
|
||||
// explicit install-local launcher path instead; the repair package validates
|
||||
// the complete transaction identity and exact release-unit path set while it
|
||||
// acquires the pending + target locks.
|
||||
claimTargets := windowsReleaseUnitPaths(installDir)
|
||||
claimLauncher := filepath.Join(installDir, "reasonix-desktop.exe")
|
||||
// The old desktop may acquire the pending lock during its normal shutdown.
|
||||
// Wait for that exact process first, then claim the transaction and hold both
|
||||
// pending and target locks across the installer replacement window.
|
||||
claimed, releaseClaim, err := claimPendingFileUpdateFn(
|
||||
toVersion,
|
||||
createdAt,
|
||||
transactionID,
|
||||
claimLauncher,
|
||||
claimTargets,
|
||||
parentExitTimeout,
|
||||
)
|
||||
if err != nil {
|
||||
logger.Printf("claim pending update: %v", err)
|
||||
return recoverExisting("pending update claim failure")
|
||||
}
|
||||
defer releaseClaim()
|
||||
recoverUnstarted := func() int {
|
||||
releaseClaim()
|
||||
if cancelErr := repair.CancelPendingUpdateExact(claimed); cancelErr != nil {
|
||||
logger.Printf("cancel unstarted update: %v", cancelErr)
|
||||
}
|
||||
if relaunch != "" {
|
||||
if relaunchErr := startRelaunchFn(relaunch, installDir); relaunchErr != nil {
|
||||
logger.Printf("relaunch after unstarted update failure: %v", relaunchErr)
|
||||
}
|
||||
}
|
||||
return 1
|
||||
}
|
||||
claimedInstaller, cleanupInstaller, err := stageInstallerFn(installer, installerSHA256)
|
||||
if err != nil {
|
||||
logger.Printf("bind update installer: %v", err)
|
||||
return recoverUnstarted()
|
||||
}
|
||||
defer func() {
|
||||
if cleanupErr := cleanupInstaller(); cleanupErr != nil {
|
||||
logger.Printf("preserving update installer staging: %v", cleanupErr)
|
||||
}
|
||||
}()
|
||||
stagingDir, err := os.MkdirTemp("", "reasonix-update-stage-*")
|
||||
if err != nil {
|
||||
logger.Printf("create update staging: %v", err)
|
||||
return recoverUnstarted()
|
||||
}
|
||||
stagingOwner, err := lstatUpdateStagingFn(stagingDir)
|
||||
if err != nil {
|
||||
logger.Printf("bind update staging: %v", err)
|
||||
return recoverUnstarted()
|
||||
}
|
||||
defer func() {
|
||||
if cleanupErr := cleanupOwnedWindowsUpdateDirectory(stagingDir, stagingOwner); cleanupErr != nil {
|
||||
logger.Printf("preserving update staging: %v", cleanupErr)
|
||||
}
|
||||
}()
|
||||
releaseInstallerExecution, err := claimInstallerExecutionFn(claimedInstaller, installerSHA256)
|
||||
if err != nil {
|
||||
logger.Printf("recheck staged installer: %v", err)
|
||||
return recoverUnstarted()
|
||||
}
|
||||
installerErr := runInstallerFn(claimedInstaller, stagingDir)
|
||||
releaseInstallerExecution()
|
||||
if installerErr != nil {
|
||||
logger.Printf("extract installer payload: %v", installerErr)
|
||||
return recoverUnstarted()
|
||||
}
|
||||
publishStarted, receipts, err := installStagedReleaseUnitFn(claimed, stagingDir)
|
||||
if err != nil {
|
||||
logger.Printf("publish staged release unit: %v", err)
|
||||
if !publishStarted {
|
||||
releaseClaim()
|
||||
if cancelErr := repair.CancelPendingUpdateExact(claimed); cancelErr != nil {
|
||||
logger.Printf("cancel unstarted update: %v", cancelErr)
|
||||
}
|
||||
}
|
||||
if relaunch != "" {
|
||||
if relaunchErr := startRelaunchFn(preferRelaunchPath(relaunch, installDir), installDir); relaunchErr != nil {
|
||||
logger.Printf("relaunch after publish failure: %v", relaunchErr)
|
||||
}
|
||||
}
|
||||
return 1
|
||||
}
|
||||
if receipts == nil {
|
||||
// Versioned-v1 activation: no flat publish receipts and no health
|
||||
// probation. Clear the pending transaction and relaunch the launcher.
|
||||
releaseClaim()
|
||||
if cancelErr := repair.CancelPendingUpdateExact(claimed); cancelErr != nil {
|
||||
logger.Printf("clear pending after versioned activate: %v", cancelErr)
|
||||
}
|
||||
if clearErr := repair.ClearUpdateApplyFailureExact(claimed); clearErr != nil {
|
||||
logger.Printf("clear apply failure after versioned activate: %v", clearErr)
|
||||
}
|
||||
} else {
|
||||
if _, err := recordInstalledUpdateFn(claimed, receipts...); err != nil {
|
||||
logger.Printf("record installed release unit: %v", err)
|
||||
if markErr := repair.MarkUpdateApplyFailedExact(claimed, err.Error()); markErr != nil {
|
||||
logger.Printf("record install failure: %v", markErr)
|
||||
}
|
||||
if relaunch != "" {
|
||||
if relaunchErr := startRelaunchFn(preferRelaunchPath(relaunch, installDir), installDir); relaunchErr != nil {
|
||||
logger.Printf("relaunch after failed install verification: %v", relaunchErr)
|
||||
}
|
||||
}
|
||||
return 1
|
||||
}
|
||||
if err := repair.ClearUpdateApplyFailureExact(claimed); err != nil {
|
||||
logger.Printf("clear completed update marker: %v", err)
|
||||
}
|
||||
}
|
||||
if relaunch != "" {
|
||||
if err := startRelaunchFn(preferRelaunchPath(relaunch, installDir), installDir); err != nil {
|
||||
logger.Printf("relaunch: %v", err)
|
||||
return 1
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func runVersionedWindowsUpdate(logger *log.Logger, installer, installerSHA256, installDir, relaunch, toVersion string) int {
|
||||
recoverExisting := func() int {
|
||||
if relaunch != "" {
|
||||
if err := startRelaunchFn(preferRelaunchPath(relaunch, installDir), installDir); err != nil {
|
||||
logger.Printf("relaunch after versioned update failure: %v", err)
|
||||
}
|
||||
}
|
||||
return 1
|
||||
}
|
||||
if _, err := installlayout.ReadCurrent(installDir); err != nil {
|
||||
logger.Printf("read versioned install pointer: %v", err)
|
||||
return recoverExisting()
|
||||
}
|
||||
claimedInstaller, cleanupInstaller, err := stageInstallerFn(installer, installerSHA256)
|
||||
if err != nil {
|
||||
logger.Printf("bind versioned update installer: %v", err)
|
||||
return recoverExisting()
|
||||
}
|
||||
defer func() {
|
||||
if cleanupErr := cleanupInstaller(); cleanupErr != nil {
|
||||
logger.Printf("preserving update installer staging: %v", cleanupErr)
|
||||
}
|
||||
}()
|
||||
stagingDir, err := os.MkdirTemp("", "reasonix-update-stage-*")
|
||||
if err != nil {
|
||||
logger.Printf("create versioned update staging: %v", err)
|
||||
return recoverExisting()
|
||||
}
|
||||
stagingOwner, err := lstatUpdateStagingFn(stagingDir)
|
||||
if err != nil {
|
||||
logger.Printf("bind versioned update staging: %v", err)
|
||||
return recoverExisting()
|
||||
}
|
||||
defer func() {
|
||||
if cleanupErr := cleanupOwnedWindowsUpdateDirectory(stagingDir, stagingOwner); cleanupErr != nil {
|
||||
logger.Printf("preserving update staging: %v", cleanupErr)
|
||||
}
|
||||
}()
|
||||
releaseInstallerExecution, err := claimInstallerExecutionFn(claimedInstaller, installerSHA256)
|
||||
if err != nil {
|
||||
logger.Printf("recheck staged versioned installer: %v", err)
|
||||
return recoverExisting()
|
||||
}
|
||||
installerErr := runInstallerFn(claimedInstaller, stagingDir)
|
||||
releaseInstallerExecution()
|
||||
if installerErr != nil {
|
||||
logger.Printf("extract versioned installer payload: %v", installerErr)
|
||||
return recoverExisting()
|
||||
}
|
||||
claimed := &repair.UpdateTransaction{
|
||||
SchemaVersion: 1,
|
||||
ToVersion: toVersion,
|
||||
TargetKind: "file",
|
||||
TargetPath: filepath.Join(installDir, "reasonix-desktop.exe"),
|
||||
}
|
||||
if err := activateVersionedWindowsFromStaging(claimed, stagingDir); err != nil {
|
||||
logger.Printf("activate versioned release: %v", err)
|
||||
return recoverExisting()
|
||||
}
|
||||
if _, err := reconcileWindowsUninstallRegistrationFn(installDir, toVersion); err != nil {
|
||||
// The release is already active and must not be rolled back for stale
|
||||
// Add/Remove Programs metadata. A later update or full installer retries
|
||||
// this idempotent reconciliation.
|
||||
logger.Printf("reconcile Windows uninstall registration: %v", err)
|
||||
}
|
||||
if relaunch != "" {
|
||||
if err := startRelaunchFn(preferRelaunchPath(relaunch, installDir), installDir); err != nil {
|
||||
logger.Printf("relaunch versioned release: %v", err)
|
||||
return 1
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// preferRelaunchPath chooses the thin launcher when present so post-update
|
||||
// restarts use the permanent entry point, not a flat desktop binary.
|
||||
func preferRelaunchPath(relaunch, installDir string) string {
|
||||
for _, name := range []string{"reasonix-launcher.exe", "Reasonix.exe"} {
|
||||
path := filepath.Join(installDir, name)
|
||||
if info, err := os.Lstat(path); err == nil && info.Mode().IsRegular() {
|
||||
return path
|
||||
}
|
||||
}
|
||||
return relaunch
|
||||
}
|
||||
|
||||
func validSHA256(value string) bool {
|
||||
value = strings.TrimSpace(value)
|
||||
if len(value) != sha256.Size*2 {
|
||||
return false
|
||||
}
|
||||
_, err := hex.DecodeString(value)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func stageVerifiedInstaller(sourcePath, expectedSHA256 string) (string, func() error, error) {
|
||||
if !validSHA256(expectedSHA256) {
|
||||
return "", nil, fmt.Errorf("installer SHA-256 is invalid")
|
||||
}
|
||||
sourceInfo, err := os.Lstat(sourcePath)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
if !sourceInfo.Mode().IsRegular() {
|
||||
return "", nil, fmt.Errorf("installer is not a regular file")
|
||||
}
|
||||
dir, err := os.MkdirTemp("", "reasonix-update-installer-*")
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
owner, err := os.Lstat(dir)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
cleanup := func() error {
|
||||
return cleanupOwnedWindowsUpdateDirectory(dir, owner)
|
||||
}
|
||||
fail := func(err error) (string, func() error, error) {
|
||||
_ = cleanup()
|
||||
return "", nil, err
|
||||
}
|
||||
source, err := os.Open(sourcePath)
|
||||
if err != nil {
|
||||
return fail(err)
|
||||
}
|
||||
defer source.Close()
|
||||
stagedPath := filepath.Join(dir, "reasonix-installer.exe")
|
||||
staged, err := os.OpenFile(stagedPath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o700)
|
||||
if err != nil {
|
||||
return fail(err)
|
||||
}
|
||||
hash := sha256.New()
|
||||
_, copyErr := io.Copy(io.MultiWriter(staged, hash), source)
|
||||
syncErr := staged.Sync()
|
||||
closeErr := staged.Close()
|
||||
switch {
|
||||
case copyErr != nil:
|
||||
return fail(copyErr)
|
||||
case syncErr != nil:
|
||||
return fail(syncErr)
|
||||
case closeErr != nil:
|
||||
return fail(closeErr)
|
||||
case !strings.EqualFold(hex.EncodeToString(hash.Sum(nil)), strings.TrimSpace(expectedSHA256)):
|
||||
return fail(fmt.Errorf("installer SHA-256 changed after desktop verification"))
|
||||
}
|
||||
if err := verifyInstallerSHA256(stagedPath, expectedSHA256); err != nil {
|
||||
return fail(err)
|
||||
}
|
||||
return stagedPath, cleanup, nil
|
||||
}
|
||||
|
||||
func verifyInstallerSHA256(path, expectedSHA256 string) error {
|
||||
info, err := os.Lstat(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !info.Mode().IsRegular() {
|
||||
return fmt.Errorf("installer is not a regular file")
|
||||
}
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
hash := sha256.New()
|
||||
if _, err := io.Copy(hash, f); err != nil {
|
||||
return err
|
||||
}
|
||||
if !strings.EqualFold(hex.EncodeToString(hash.Sum(nil)), strings.TrimSpace(expectedSHA256)) {
|
||||
return fmt.Errorf("installer SHA-256 mismatch")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func claimVerifiedInstallerForExecution(path, expectedSHA256 string) (func(), error) {
|
||||
if !validSHA256(expectedSHA256) {
|
||||
return nil, fmt.Errorf("installer SHA-256 is invalid")
|
||||
}
|
||||
pathUTF16, err := windows.UTF16PtrFromString(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
handle, err := windows.CreateFile(
|
||||
pathUTF16,
|
||||
windows.GENERIC_READ,
|
||||
windows.FILE_SHARE_READ,
|
||||
nil,
|
||||
windows.OPEN_EXISTING,
|
||||
windows.FILE_ATTRIBUTE_NORMAL|windows.FILE_FLAG_SEQUENTIAL_SCAN,
|
||||
0,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
file := os.NewFile(uintptr(handle), path)
|
||||
fail := func(err error) (func(), error) {
|
||||
_ = file.Close()
|
||||
return nil, err
|
||||
}
|
||||
info, err := file.Stat()
|
||||
if err != nil {
|
||||
return fail(err)
|
||||
}
|
||||
if !info.Mode().IsRegular() {
|
||||
return fail(fmt.Errorf("installer is not a regular file"))
|
||||
}
|
||||
hash := sha256.New()
|
||||
if _, err := io.Copy(hash, file); err != nil {
|
||||
return fail(err)
|
||||
}
|
||||
if !strings.EqualFold(hex.EncodeToString(hash.Sum(nil)), strings.TrimSpace(expectedSHA256)) {
|
||||
return fail(fmt.Errorf("installer SHA-256 mismatch"))
|
||||
}
|
||||
var once sync.Once
|
||||
return func() {
|
||||
once.Do(func() { _ = file.Close() })
|
||||
}, nil
|
||||
}
|
||||
|
||||
func installStagedWindowsReleaseUnit(
|
||||
claimed *repair.UpdateTransaction,
|
||||
stagingDir string,
|
||||
) (bool, []repair.FileUpdateInstallReceipt, error) {
|
||||
// v1.20+: versioned-v1 is the only publish path. Incomplete staged payloads
|
||||
// fail closed without mutating the live install or leaving flat pending state.
|
||||
if !preferVersionedWindowsActivation(stagingDir) {
|
||||
return false, nil, fmt.Errorf("staged payload is incomplete for versioned-v1 activation")
|
||||
}
|
||||
if err := activateVersionedWindowsFromStaging(claimed, stagingDir); err != nil {
|
||||
return false, nil, fmt.Errorf("versioned activate: %w", err)
|
||||
}
|
||||
return true, nil, nil
|
||||
}
|
||||
|
||||
func windowsReleaseUnitPaths(installDir string) []string {
|
||||
if installDir == "" {
|
||||
return nil
|
||||
}
|
||||
names := []string{
|
||||
"reasonix-desktop.exe",
|
||||
"reasonix-guard.exe",
|
||||
"reasonix-launcher.exe",
|
||||
"reasonix-update-helper.exe",
|
||||
"reasonix-cli.exe",
|
||||
"Reasonix.exe",
|
||||
}
|
||||
paths := make([]string, 0, len(names))
|
||||
for _, name := range names {
|
||||
paths = append(paths, filepath.Join(installDir, name))
|
||||
}
|
||||
return paths
|
||||
}
|
||||
|
||||
func newLogger() *log.Logger {
|
||||
dir, err := os.UserCacheDir()
|
||||
if err == nil {
|
||||
dir = filepath.Join(dir, "Reasonix", "updates")
|
||||
if err := os.MkdirAll(dir, 0o700); err == nil {
|
||||
if f, err := os.OpenFile(filepath.Join(dir, "update-helper.log"), os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600); err == nil {
|
||||
return log.New(f, "", log.LstdFlags)
|
||||
}
|
||||
}
|
||||
}
|
||||
return log.New(os.Stderr, "", log.LstdFlags)
|
||||
}
|
||||
|
||||
func waitForProcessExit(pid uint32, timeout time.Duration) error {
|
||||
h, err := windows.OpenProcess(windows.SYNCHRONIZE, false, pid)
|
||||
if err != nil {
|
||||
if errors.Is(err, windows.ERROR_INVALID_PARAMETER) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
defer windows.CloseHandle(h)
|
||||
waitMS := uint32(timeout / time.Millisecond)
|
||||
result, err := windows.WaitForSingleObject(h, waitMS)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
switch result {
|
||||
case windows.WAIT_OBJECT_0:
|
||||
return nil
|
||||
case uint32(windows.WAIT_TIMEOUT):
|
||||
return fmt.Errorf("timed out after %s", timeout)
|
||||
default:
|
||||
return fmt.Errorf("unexpected wait result %d", result)
|
||||
}
|
||||
}
|
||||
|
||||
func runInstaller(installer, installDir string) error {
|
||||
cmd := exec.Command(installer)
|
||||
// Keep the helper itself hidden, but let the NSIS update-progress window be
|
||||
// visible. /REASONIXSTAGE makes the signed installer extract only; the helper
|
||||
// performs every live replacement through the claimed transaction.
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{CmdLine: installerCommandLine(installer, installDir)}
|
||||
return cmd.Run()
|
||||
}
|
||||
|
||||
func cleanupOwnedWindowsUpdateDirectory(path string, owner os.FileInfo) error {
|
||||
if path == "" || owner == nil || !owner.IsDir() {
|
||||
return fmt.Errorf("Windows update cleanup identity is incomplete")
|
||||
}
|
||||
for attempt := range 16 {
|
||||
cleanup := fmt.Sprintf("%s.reasonix-cleanup-%d-%d", path, time.Now().UTC().UnixNano(), attempt)
|
||||
from, err := windows.UTF16PtrFromString(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
to, err := windows.UTF16PtrFromString(cleanup)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := windows.MoveFileEx(from, to, windows.MOVEFILE_WRITE_THROUGH); err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
if os.IsExist(err) {
|
||||
continue
|
||||
}
|
||||
return err
|
||||
}
|
||||
actual, err := os.Lstat(cleanup)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !os.SameFile(owner, actual) {
|
||||
restoreFrom, fromErr := windows.UTF16PtrFromString(cleanup)
|
||||
restoreTo, toErr := windows.UTF16PtrFromString(path)
|
||||
if fromErr != nil || toErr != nil {
|
||||
return fmt.Errorf("Windows update staging changed before cleanup; preserve replacement at %s", cleanup)
|
||||
}
|
||||
if restoreErr := windows.MoveFileEx(restoreFrom, restoreTo, windows.MOVEFILE_WRITE_THROUGH); restoreErr != nil {
|
||||
return fmt.Errorf("Windows update staging changed before cleanup; preserve replacement at %s: %w", cleanup, restoreErr)
|
||||
}
|
||||
return fmt.Errorf("Windows update staging changed before cleanup")
|
||||
}
|
||||
return os.RemoveAll(cleanup)
|
||||
}
|
||||
return fmt.Errorf("cannot allocate Windows update cleanup path")
|
||||
}
|
||||
|
||||
func startRelaunch(relaunch, installDir string) error {
|
||||
cmd := exec.Command(relaunch)
|
||||
cmd.Dir = installDir
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true}
|
||||
return cmd.Start()
|
||||
}
|
||||
@@ -1,563 +0,0 @@
|
||||
//go:build windows
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/platform/installlayout"
|
||||
"reasonix/internal/platform/repair"
|
||||
|
||||
"reasonix/internal/base/tempdir"
|
||||
)
|
||||
|
||||
const testInstallerSHA256 = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
|
||||
|
||||
func prepareLegacyWindowsUpdate(t *testing.T, installDir, toVersion string) *repair.UpdateTransaction {
|
||||
t.Helper()
|
||||
paths := windowsReleaseUnitPaths(installDir)
|
||||
for _, path := range paths {
|
||||
if err := os.WriteFile(path, []byte("old-"+filepath.Base(path)), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
tx, err := repair.PrepareFileUpdate("v1", toVersion, paths[0], paths[1:]...)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return tx
|
||||
}
|
||||
|
||||
func TestStageVerifiedInstallerFreezesExpectedBytes(t *testing.T) {
|
||||
source := filepath.Join(tempdir.New(t), "installer.exe")
|
||||
content := []byte("verified-installer")
|
||||
if err := os.WriteFile(source, content, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sum := sha256.Sum256(content)
|
||||
expected := hex.EncodeToString(sum[:])
|
||||
staged, cleanup, err := stageVerifiedInstaller(source, expected)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = cleanup() })
|
||||
if staged == source {
|
||||
t.Fatal("installer was not isolated from the mutable cache path")
|
||||
}
|
||||
if err := os.WriteFile(source, []byte("changed-after-handoff"), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := verifyInstallerSHA256(staged, expected); err != nil {
|
||||
t.Fatalf("isolated installer changed with source: %v", err)
|
||||
}
|
||||
if got, err := os.ReadFile(staged); err != nil || string(got) != string(content) {
|
||||
t.Fatalf("staged installer = %q, %v", got, err)
|
||||
}
|
||||
if err := os.WriteFile(staged, []byte("tampered"), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := verifyInstallerSHA256(staged, expected); err == nil {
|
||||
t.Fatal("tampered staged installer passed final verification")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStageVerifiedInstallerRejectsSourceHashDrift(t *testing.T) {
|
||||
source := filepath.Join(tempdir.New(t), "installer.exe")
|
||||
if err := os.WriteFile(source, []byte("different"), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, cleanup, err := stageVerifiedInstaller(source, testInstallerSHA256); err == nil {
|
||||
if cleanup != nil {
|
||||
_ = cleanup()
|
||||
}
|
||||
t.Fatal("installer bytes different from desktop verification were accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunRequiresTargetVersionBeforeStartingInstaller(t *testing.T) {
|
||||
if code := run([]string{
|
||||
"--installer", `C:\Temp\Reasonix-installer.exe`,
|
||||
"--installer-sha256", testInstallerSHA256,
|
||||
}); code != 2 {
|
||||
t.Fatalf("run without --to-version = %d, want 2", code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunVersionedLayoutDoesNotReadOrClaimLegacyPending(t *testing.T) {
|
||||
installDir := tempdir.New(t)
|
||||
seed := tempdir.New(t)
|
||||
for _, name := range []string{"reasonix-desktop.exe", "reasonix-cli.exe", "reasonix-update-helper.exe"} {
|
||||
if err := os.WriteFile(filepath.Join(seed, name), []byte("old-"+name), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := installlayout.ActivateVersion(installlayout.ActivationRequest{
|
||||
InstallRoot: installDir,
|
||||
Version: "v1.20.0",
|
||||
RequestID: "seed-windows-helper",
|
||||
Members: []installlayout.Member{
|
||||
{Name: "reasonix-desktop.exe", Path: filepath.Join(seed, "reasonix-desktop.exe")},
|
||||
{Name: "reasonix-cli.exe", Path: filepath.Join(seed, "reasonix-cli.exe")},
|
||||
{Name: "reasonix-update-helper.exe", Path: filepath.Join(seed, "reasonix-update-helper.exe")},
|
||||
},
|
||||
RequiredNames: []string{"reasonix-desktop.exe", "reasonix-cli.exe", "reasonix-update-helper.exe"},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
originalInstaller := runInstallerFn
|
||||
originalRelaunch := startRelaunchFn
|
||||
originalClaim := claimPendingFileUpdateFn
|
||||
originalStageInstaller := stageInstallerFn
|
||||
originalClaimInstallerExecution := claimInstallerExecutionFn
|
||||
originalReconcileUninstall := reconcileWindowsUninstallRegistrationFn
|
||||
t.Cleanup(func() {
|
||||
runInstallerFn = originalInstaller
|
||||
startRelaunchFn = originalRelaunch
|
||||
claimPendingFileUpdateFn = originalClaim
|
||||
stageInstallerFn = originalStageInstaller
|
||||
claimInstallerExecutionFn = originalClaimInstallerExecution
|
||||
reconcileWindowsUninstallRegistrationFn = originalReconcileUninstall
|
||||
})
|
||||
legacyClaimed := false
|
||||
claimPendingFileUpdateFn = func(string, string, string, string, []string, time.Duration) (*repair.UpdateTransaction, func(), error) {
|
||||
legacyClaimed = true
|
||||
return nil, func() {}, errors.New("legacy claim must not run")
|
||||
}
|
||||
stageInstallerFn = func(path, _ string) (string, func() error, error) {
|
||||
return path, func() error { return nil }, nil
|
||||
}
|
||||
claimInstallerExecutionFn = func(string, string) (func(), error) { return func() {}, nil }
|
||||
reconciledVersion := ""
|
||||
reconcileWindowsUninstallRegistrationFn = func(root, targetVersion string) (bool, error) {
|
||||
if root != installDir {
|
||||
t.Fatalf("reconcile root = %q, want %q", root, installDir)
|
||||
}
|
||||
reconciledVersion = targetVersion
|
||||
return true, nil
|
||||
}
|
||||
runInstallerFn = func(_ string, staging string) error {
|
||||
for _, name := range []string{"reasonix-desktop.exe", "reasonix-cli.exe", "reasonix-update-helper.exe", "reasonix-launcher.exe"} {
|
||||
if err := os.WriteFile(filepath.Join(staging, name), []byte("new-"+name), 0o700); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
relaunched := false
|
||||
startRelaunchFn = func(string, string) error { relaunched = true; return nil }
|
||||
|
||||
if code := run([]string{
|
||||
"--installer", filepath.Join(installDir, "installer.exe"),
|
||||
"--installer-sha256", testInstallerSHA256,
|
||||
"--install-dir", installDir,
|
||||
"--relaunch", filepath.Join(installDir, "reasonix-launcher.exe"),
|
||||
"--to-version", "v1.20.1",
|
||||
"--install-layout", "versioned-v1",
|
||||
}); code != 0 {
|
||||
t.Fatalf("run exit code = %d", code)
|
||||
}
|
||||
if legacyClaimed {
|
||||
t.Fatal("versioned update claimed legacy pending transaction")
|
||||
}
|
||||
if !relaunched {
|
||||
t.Fatal("versioned update did not relaunch")
|
||||
}
|
||||
if reconciledVersion != "v1.20.1" {
|
||||
t.Fatalf("reconciled version = %q, want v1.20.1", reconciledVersion)
|
||||
}
|
||||
ptr, err := installlayout.ReadCurrent(installDir)
|
||||
if err != nil || ptr.ActiveVersion != "v1.20.1" {
|
||||
t.Fatalf("pointer=%+v err=%v", ptr, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunHoldsReleaseUnitLockAcrossInstallerHandoff(t *testing.T) {
|
||||
t.Setenv("REASONIX_HOME", tempdir.New(t))
|
||||
installDir := tempdir.New(t)
|
||||
pending := prepareLegacyWindowsUpdate(t, installDir, "v2")
|
||||
var events []string
|
||||
originalWait := waitForProcessExitFn
|
||||
originalInstaller := runInstallerFn
|
||||
originalRelaunch := startRelaunchFn
|
||||
originalClaim := claimPendingFileUpdateFn
|
||||
originalInstallStaged := installStagedReleaseUnitFn
|
||||
originalRecord := recordInstalledUpdateFn
|
||||
originalStageInstaller := stageInstallerFn
|
||||
originalClaimInstallerExecution := claimInstallerExecutionFn
|
||||
t.Cleanup(func() {
|
||||
waitForProcessExitFn = originalWait
|
||||
runInstallerFn = originalInstaller
|
||||
startRelaunchFn = originalRelaunch
|
||||
claimPendingFileUpdateFn = originalClaim
|
||||
installStagedReleaseUnitFn = originalInstallStaged
|
||||
recordInstalledUpdateFn = originalRecord
|
||||
stageInstallerFn = originalStageInstaller
|
||||
claimInstallerExecutionFn = originalClaimInstallerExecution
|
||||
})
|
||||
waitForProcessExitFn = func(uint32, time.Duration) error {
|
||||
events = append(events, "wait")
|
||||
return nil
|
||||
}
|
||||
runInstallerFn = func(string, string) error {
|
||||
events = append(events, "installer")
|
||||
return nil
|
||||
}
|
||||
startRelaunchFn = func(string, string) error {
|
||||
events = append(events, "relaunch")
|
||||
return nil
|
||||
}
|
||||
claimPendingFileUpdateFn = func(toVersion, createdAt, transactionID, launcherPath string, paths []string, _ time.Duration) (*repair.UpdateTransaction, func(), error) {
|
||||
events = append(events, "claim:"+strings.Join([]string{toVersion, createdAt, transactionID, launcherPath, strings.Join(paths, "\x00")}, "\x01"))
|
||||
return pending, func() { events = append(events, "release") }, nil
|
||||
}
|
||||
installStagedReleaseUnitFn = func(*repair.UpdateTransaction, string) (bool, []repair.FileUpdateInstallReceipt, error) {
|
||||
events = append(events, "publish")
|
||||
return true, []repair.FileUpdateInstallReceipt{}, nil
|
||||
}
|
||||
recordInstalledUpdateFn = func(tx *repair.UpdateTransaction, _ ...repair.FileUpdateInstallReceipt) (*repair.UpdateTransaction, error) {
|
||||
events = append(events, "record-installed")
|
||||
return tx, nil
|
||||
}
|
||||
stageInstallerFn = func(path, sha256 string) (string, func() error, error) {
|
||||
events = append(events, "stage-installer:"+sha256)
|
||||
return path + ".claimed", func() error { return nil }, nil
|
||||
}
|
||||
claimInstallerExecutionFn = func(string, string) (func(), error) {
|
||||
events = append(events, "claim-installer")
|
||||
return func() { events = append(events, "release-installer") }, nil
|
||||
}
|
||||
|
||||
if code := run([]string{
|
||||
"--parent-pid", "1234",
|
||||
"--installer", filepath.Join(installDir, "installer.exe"),
|
||||
"--installer-sha256", testInstallerSHA256,
|
||||
"--install-dir", installDir,
|
||||
"--relaunch", filepath.Join(installDir, "reasonix-desktop.exe"),
|
||||
"--to-version", pending.ToVersion,
|
||||
"--created-at", pending.CreatedAt,
|
||||
"--transaction-id", repair.UpdateTransactionID(pending),
|
||||
}); code != 0 {
|
||||
t.Fatalf("run exit code = %d", code)
|
||||
}
|
||||
wantClaim := "claim:" + strings.Join([]string{
|
||||
pending.ToVersion,
|
||||
pending.CreatedAt,
|
||||
repair.UpdateTransactionID(pending),
|
||||
filepath.Join(installDir, "reasonix-desktop.exe"),
|
||||
strings.Join(windowsReleaseUnitPaths(installDir), "\x00"),
|
||||
}, "\x01")
|
||||
if len(events) != 10 || events[0] != "wait" || events[1] != wantClaim ||
|
||||
events[2] != "stage-installer:"+testInstallerSHA256 ||
|
||||
events[3] != "claim-installer" || events[4] != "installer" ||
|
||||
events[5] != "release-installer" || events[6] != "publish" ||
|
||||
events[7] != "record-installed" || events[8] != "relaunch" ||
|
||||
events[9] != "release" {
|
||||
t.Fatalf("handoff events = %#v", events)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunDoesNotClaimUpdateBeforeParentExits(t *testing.T) {
|
||||
installDir := tempdir.New(t)
|
||||
originalWait := waitForProcessExitFn
|
||||
originalInstaller := runInstallerFn
|
||||
originalClaim := claimPendingFileUpdateFn
|
||||
originalRecord := recordInstalledUpdateFn
|
||||
t.Cleanup(func() {
|
||||
waitForProcessExitFn = originalWait
|
||||
runInstallerFn = originalInstaller
|
||||
claimPendingFileUpdateFn = originalClaim
|
||||
recordInstalledUpdateFn = originalRecord
|
||||
})
|
||||
waitForProcessExitFn = func(uint32, time.Duration) error {
|
||||
return errors.New("parent still running")
|
||||
}
|
||||
claimed := false
|
||||
claimPendingFileUpdateFn = func(string, string, string, string, []string, time.Duration) (*repair.UpdateTransaction, func(), error) {
|
||||
claimed = true
|
||||
return &repair.UpdateTransaction{}, func() {}, nil
|
||||
}
|
||||
installerRan := false
|
||||
runInstallerFn = func(string, string) error {
|
||||
installerRan = true
|
||||
return nil
|
||||
}
|
||||
|
||||
if code := run([]string{
|
||||
"--parent-pid", "1234",
|
||||
"--installer", filepath.Join(installDir, "installer.exe"),
|
||||
"--installer-sha256", testInstallerSHA256,
|
||||
"--install-dir", installDir,
|
||||
"--to-version", "v2",
|
||||
"--created-at", "2026-07-29T00:00:00Z",
|
||||
"--transaction-id", "transaction-1",
|
||||
}); code != 1 {
|
||||
t.Fatalf("run exit code = %d, want 1", code)
|
||||
}
|
||||
if claimed || installerRan {
|
||||
t.Fatalf("claim=%v installer=%v before parent exit", claimed, installerRan)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunRelaunchesWhenLegacyPendingCannotBeClaimed(t *testing.T) {
|
||||
installDir := tempdir.New(t)
|
||||
originalWait := waitForProcessExitFn
|
||||
originalRelaunch := startRelaunchFn
|
||||
originalClaim := claimPendingFileUpdateFn
|
||||
t.Cleanup(func() {
|
||||
waitForProcessExitFn = originalWait
|
||||
startRelaunchFn = originalRelaunch
|
||||
claimPendingFileUpdateFn = originalClaim
|
||||
})
|
||||
|
||||
waitForProcessExitFn = func(uint32, time.Duration) error { return nil }
|
||||
relaunched := false
|
||||
startRelaunchFn = func(path, dir string) error {
|
||||
relaunched = path == filepath.Join(installDir, "reasonix-desktop.exe") && dir == installDir
|
||||
return nil
|
||||
}
|
||||
claimPendingFileUpdateFn = func(string, string, string, string, []string, time.Duration) (*repair.UpdateTransaction, func(), error) {
|
||||
return nil, nil, errors.New("pending lock unavailable")
|
||||
}
|
||||
|
||||
if code := run([]string{
|
||||
"--parent-pid", "1234",
|
||||
"--installer", filepath.Join(installDir, "installer.exe"),
|
||||
"--installer-sha256", testInstallerSHA256,
|
||||
"--install-dir", installDir,
|
||||
"--relaunch", filepath.Join(installDir, "reasonix-desktop.exe"),
|
||||
"--to-version", "v2",
|
||||
"--created-at", "2026-08-02T00:00:00Z",
|
||||
"--transaction-id", strings.Repeat("a", 64),
|
||||
}); code != 1 {
|
||||
t.Fatalf("run exit code = %d, want 1", code)
|
||||
}
|
||||
if !relaunched {
|
||||
t.Fatal("helper did not relaunch the existing desktop")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunCancelsTransactionWhenStagedExtractionFails(t *testing.T) {
|
||||
t.Setenv("REASONIX_HOME", tempdir.New(t))
|
||||
installDir := tempdir.New(t)
|
||||
originalWait := waitForProcessExitFn
|
||||
originalInstaller := runInstallerFn
|
||||
originalRelaunch := startRelaunchFn
|
||||
originalClaim := claimPendingFileUpdateFn
|
||||
originalInstallStaged := installStagedReleaseUnitFn
|
||||
originalRecord := recordInstalledUpdateFn
|
||||
originalStageInstaller := stageInstallerFn
|
||||
originalClaimInstallerExecution := claimInstallerExecutionFn
|
||||
t.Cleanup(func() {
|
||||
waitForProcessExitFn = originalWait
|
||||
runInstallerFn = originalInstaller
|
||||
startRelaunchFn = originalRelaunch
|
||||
claimPendingFileUpdateFn = originalClaim
|
||||
installStagedReleaseUnitFn = originalInstallStaged
|
||||
recordInstalledUpdateFn = originalRecord
|
||||
stageInstallerFn = originalStageInstaller
|
||||
claimInstallerExecutionFn = originalClaimInstallerExecution
|
||||
})
|
||||
waitForProcessExitFn = func(uint32, time.Duration) error { return nil }
|
||||
runInstallerFn = func(string, string) error { return errors.New("installer failed") }
|
||||
startRelaunchFn = func(string, string) error { return nil }
|
||||
claimed := prepareLegacyWindowsUpdate(t, installDir, "v2")
|
||||
claimPendingFileUpdateFn = func(string, string, string, string, []string, time.Duration) (*repair.UpdateTransaction, func(), error) {
|
||||
return claimed, func() {}, nil
|
||||
}
|
||||
stageInstallerFn = func(path, _ string) (string, func() error, error) {
|
||||
return path, func() error { return nil }, nil
|
||||
}
|
||||
claimInstallerExecutionFn = func(string, string) (func(), error) {
|
||||
return func() {}, nil
|
||||
}
|
||||
transactionID := repair.UpdateTransactionID(claimed)
|
||||
if code := run([]string{
|
||||
"--installer", filepath.Join(installDir, "installer.exe"),
|
||||
"--installer-sha256", testInstallerSHA256,
|
||||
"--install-dir", installDir,
|
||||
"--relaunch", filepath.Join(installDir, "reasonix-desktop.exe"),
|
||||
"--to-version", "v2",
|
||||
"--created-at", claimed.CreatedAt,
|
||||
"--transaction-id", transactionID,
|
||||
}); code != 1 {
|
||||
t.Fatalf("run exit code = %d, want 1", code)
|
||||
}
|
||||
if _, ok := repair.ReadUpdateApplyFailure(); ok {
|
||||
t.Fatal("staging-only installer failure left a recovery marker despite no live publish")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunTreatsInstalledReleaseUnitRecordingFailureAsApplyFailure(t *testing.T) {
|
||||
t.Setenv("REASONIX_HOME", tempdir.New(t))
|
||||
installDir := tempdir.New(t)
|
||||
originalWait := waitForProcessExitFn
|
||||
originalInstaller := runInstallerFn
|
||||
originalRelaunch := startRelaunchFn
|
||||
originalClaim := claimPendingFileUpdateFn
|
||||
originalRecord := recordInstalledUpdateFn
|
||||
originalStageInstaller := stageInstallerFn
|
||||
originalClaimInstallerExecution := claimInstallerExecutionFn
|
||||
t.Cleanup(func() {
|
||||
waitForProcessExitFn = originalWait
|
||||
runInstallerFn = originalInstaller
|
||||
startRelaunchFn = originalRelaunch
|
||||
claimPendingFileUpdateFn = originalClaim
|
||||
recordInstalledUpdateFn = originalRecord
|
||||
stageInstallerFn = originalStageInstaller
|
||||
claimInstallerExecutionFn = originalClaimInstallerExecution
|
||||
})
|
||||
waitForProcessExitFn = func(uint32, time.Duration) error { return nil }
|
||||
runInstallerFn = func(string, string) error { return nil }
|
||||
relaunched := false
|
||||
startRelaunchFn = func(string, string) error {
|
||||
relaunched = true
|
||||
return nil
|
||||
}
|
||||
claimed := prepareLegacyWindowsUpdate(t, installDir, "v2")
|
||||
claimPendingFileUpdateFn = func(string, string, string, string, []string, time.Duration) (*repair.UpdateTransaction, func(), error) {
|
||||
return claimed, func() {}, nil
|
||||
}
|
||||
stageInstallerFn = func(path, _ string) (string, func() error, error) {
|
||||
return path, func() error { return nil }, nil
|
||||
}
|
||||
claimInstallerExecutionFn = func(string, string) (func(), error) {
|
||||
return func() {}, nil
|
||||
}
|
||||
installStagedReleaseUnitFn = func(*repair.UpdateTransaction, string) (bool, []repair.FileUpdateInstallReceipt, error) {
|
||||
return true, []repair.FileUpdateInstallReceipt{{
|
||||
UpdateTransactionID: repair.UpdateTransactionID(claimed),
|
||||
TargetPath: claimed.TargetPath,
|
||||
InstalledStateID: "installed",
|
||||
}}, nil
|
||||
}
|
||||
recordInstalledUpdateFn = func(*repair.UpdateTransaction, ...repair.FileUpdateInstallReceipt) (*repair.UpdateTransaction, error) {
|
||||
return nil, errors.New("release unit drifted")
|
||||
}
|
||||
transactionID := repair.UpdateTransactionID(claimed)
|
||||
|
||||
if code := run([]string{
|
||||
"--installer", filepath.Join(installDir, "installer.exe"),
|
||||
"--installer-sha256", testInstallerSHA256,
|
||||
"--install-dir", installDir,
|
||||
"--relaunch", filepath.Join(installDir, "reasonix-desktop.exe"),
|
||||
"--to-version", "v2",
|
||||
"--created-at", claimed.CreatedAt,
|
||||
"--transaction-id", transactionID,
|
||||
}); code != 1 {
|
||||
t.Fatalf("run exit code = %d, want 1", code)
|
||||
}
|
||||
if !relaunched {
|
||||
t.Fatal("helper did not relaunch through recovery after post-install verification failure")
|
||||
}
|
||||
failure, ok := repair.ReadUpdateApplyFailure()
|
||||
if !ok || failure.UpdateTransactionID != transactionID {
|
||||
t.Fatalf("failure marker = %+v, present=%v", failure, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunRelaunchesWhenStagingIdentityCannotBeBound(t *testing.T) {
|
||||
t.Setenv("REASONIX_HOME", tempdir.New(t))
|
||||
installDir := tempdir.New(t)
|
||||
originalWait := waitForProcessExitFn
|
||||
originalInstaller := runInstallerFn
|
||||
originalRelaunch := startRelaunchFn
|
||||
originalClaim := claimPendingFileUpdateFn
|
||||
originalStageInstaller := stageInstallerFn
|
||||
originalLstatStaging := lstatUpdateStagingFn
|
||||
t.Cleanup(func() {
|
||||
waitForProcessExitFn = originalWait
|
||||
runInstallerFn = originalInstaller
|
||||
startRelaunchFn = originalRelaunch
|
||||
claimPendingFileUpdateFn = originalClaim
|
||||
stageInstallerFn = originalStageInstaller
|
||||
lstatUpdateStagingFn = originalLstatStaging
|
||||
})
|
||||
waitForProcessExitFn = func(uint32, time.Duration) error { return nil }
|
||||
installerRan := false
|
||||
runInstallerFn = func(string, string) error {
|
||||
installerRan = true
|
||||
return nil
|
||||
}
|
||||
relaunched := false
|
||||
startRelaunchFn = func(string, string) error {
|
||||
relaunched = true
|
||||
return nil
|
||||
}
|
||||
claimed := prepareLegacyWindowsUpdate(t, installDir, "v2")
|
||||
releases := 0
|
||||
claimPendingFileUpdateFn = func(string, string, string, string, []string, time.Duration) (*repair.UpdateTransaction, func(), error) {
|
||||
return claimed, func() { releases++ }, nil
|
||||
}
|
||||
stageInstallerFn = func(path, _ string) (string, func() error, error) {
|
||||
return path, func() error { return nil }, nil
|
||||
}
|
||||
lstatUpdateStagingFn = func(string) (os.FileInfo, error) {
|
||||
return nil, errors.New("staging identity unavailable")
|
||||
}
|
||||
|
||||
if code := run([]string{
|
||||
"--installer", filepath.Join(installDir, "installer.exe"),
|
||||
"--installer-sha256", testInstallerSHA256,
|
||||
"--install-dir", installDir,
|
||||
"--relaunch", filepath.Join(installDir, "reasonix-desktop.exe"),
|
||||
"--to-version", claimed.ToVersion,
|
||||
"--created-at", claimed.CreatedAt,
|
||||
"--transaction-id", repair.UpdateTransactionID(claimed),
|
||||
}); code != 1 {
|
||||
t.Fatalf("run exit code = %d, want 1", code)
|
||||
}
|
||||
if installerRan {
|
||||
t.Fatal("installer ran without a bound staging directory")
|
||||
}
|
||||
if !relaunched {
|
||||
t.Fatal("helper did not relaunch after staging identity failure")
|
||||
}
|
||||
if releases == 0 {
|
||||
t.Fatal("helper did not release the update claim")
|
||||
}
|
||||
}
|
||||
|
||||
func TestClaimVerifiedInstallerForExecutionFreezesPath(t *testing.T) {
|
||||
path := filepath.Join(tempdir.New(t), "installer.exe")
|
||||
content := []byte("verified-installer")
|
||||
if err := os.WriteFile(path, content, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sum := sha256.Sum256(content)
|
||||
release, err := claimVerifiedInstallerForExecution(path, hex.EncodeToString(sum[:]))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte("tampered"), 0o700); err == nil {
|
||||
release()
|
||||
t.Fatal("installer remained writable while execution claim was held")
|
||||
}
|
||||
if err := os.Rename(path, path+".replaced"); err == nil {
|
||||
release()
|
||||
t.Fatal("installer remained renameable while execution claim was held")
|
||||
}
|
||||
release()
|
||||
release()
|
||||
if err := os.WriteFile(path, []byte("replacement"), 0o700); err != nil {
|
||||
t.Fatalf("installer remained frozen after claim release: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClaimVerifiedInstallerForExecutionRejectsHashDrift(t *testing.T) {
|
||||
path := filepath.Join(tempdir.New(t), "installer.exe")
|
||||
if err := os.WriteFile(path, []byte("tampered"), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if release, err := claimVerifiedInstallerForExecution(path, testInstallerSHA256); err == nil {
|
||||
release()
|
||||
t.Fatal("installer with the wrong SHA-256 received an execution claim")
|
||||
}
|
||||
}
|
||||
@@ -1,238 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"reasonix/internal/platform/repair"
|
||||
"reasonix/internal/platform/update"
|
||||
)
|
||||
|
||||
const maxWindowsPayloadMetadataSize = 64 << 10
|
||||
|
||||
var verifyWindowsPayloadManifestFn = update.Verify
|
||||
|
||||
type stagedFileUpdateMember struct {
|
||||
targetPath string
|
||||
content []byte
|
||||
mode os.FileMode
|
||||
}
|
||||
|
||||
// loadWindowsStagedReleaseUnit validates and reads the complete NSIS payload
|
||||
// before any live release-unit member is moved. An existing Reasonix.exe is the
|
||||
// portable alias of reasonix-launcher.exe and reuses those staged bytes; an
|
||||
// installed package that did not have the alias remains unchanged.
|
||||
func loadWindowsStagedReleaseUnit(claimed *repair.UpdateTransaction, stagingDir string) ([]stagedFileUpdateMember, error) {
|
||||
if claimed == nil || claimed.TargetKind != "file" || len(claimed.Files) == 0 {
|
||||
return nil, fmt.Errorf("load staged release unit: transaction identity is incomplete")
|
||||
}
|
||||
if err := validateWindowsClaimedReleaseUnit(claimed); err != nil {
|
||||
return nil, fmt.Errorf("load staged release unit: %w", err)
|
||||
}
|
||||
stagingDir = filepath.Clean(strings.TrimSpace(stagingDir))
|
||||
if stagingDir == "" || stagingDir == "." || !filepath.IsAbs(stagingDir) {
|
||||
return nil, fmt.Errorf("load staged release unit: staging directory is invalid")
|
||||
}
|
||||
info, err := os.Lstat(stagingDir)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load staged release unit: inspect staging directory: %w", err)
|
||||
}
|
||||
if !info.IsDir() || info.Mode()&os.ModeSymlink != 0 {
|
||||
return nil, fmt.Errorf("load staged release unit: staging path is not a directory")
|
||||
}
|
||||
hashes, err := loadWindowsPayloadManifest(stagingDir, claimed.ToVersion)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load staged release unit: %w", err)
|
||||
}
|
||||
|
||||
contents := make(map[string][]byte)
|
||||
members := make([]stagedFileUpdateMember, 0, len(claimed.Files))
|
||||
seenTargets := make(map[string]struct{}, len(claimed.Files))
|
||||
for _, file := range claimed.Files {
|
||||
targetPath := filepath.Clean(strings.TrimSpace(file.TargetPath))
|
||||
targetKey := strings.ToLower(targetPath)
|
||||
if targetPath == "" || targetPath == "." {
|
||||
return nil, fmt.Errorf("load staged release unit: target path is invalid")
|
||||
}
|
||||
if _, ok := seenTargets[targetKey]; ok {
|
||||
return nil, fmt.Errorf("load staged release unit: duplicate target %s", filepath.Base(targetPath))
|
||||
}
|
||||
seenTargets[targetKey] = struct{}{}
|
||||
if strings.EqualFold(filepath.Base(targetPath), "Reasonix.exe") && file.MissingBefore {
|
||||
continue
|
||||
}
|
||||
|
||||
sourceName, err := windowsStagedSourceName(filepath.Base(targetPath))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sourcePath := filepath.Join(stagingDir, sourceName)
|
||||
content, ok := contents[sourceName]
|
||||
if !ok {
|
||||
sourceInfo, statErr := os.Lstat(sourcePath)
|
||||
if statErr != nil {
|
||||
return nil, fmt.Errorf("load staged release unit: inspect %s: %w", sourceName, statErr)
|
||||
}
|
||||
if !sourceInfo.Mode().IsRegular() {
|
||||
return nil, fmt.Errorf("load staged release unit: %s is not a regular file", sourceName)
|
||||
}
|
||||
content, err = readVerifiedWindowsStagedPayloadFn(sourcePath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load staged release unit: read %s: %w", sourceName, err)
|
||||
}
|
||||
if !strings.EqualFold(update.WindowsPayloadSHA256(content), hashes[sourceName]) {
|
||||
return nil, fmt.Errorf("load staged release unit: %s does not match the signed release manifest", sourceName)
|
||||
}
|
||||
contents[sourceName] = content
|
||||
}
|
||||
members = append(members, stagedFileUpdateMember{
|
||||
targetPath: targetPath,
|
||||
content: content,
|
||||
mode: 0o700,
|
||||
})
|
||||
}
|
||||
|
||||
// Publish the running desktop last. If an earlier member fails, the old
|
||||
// desktop remains the executable entry point that can report/retry recovery.
|
||||
sort.SliceStable(members, func(i, j int) bool {
|
||||
iPrimary := strings.EqualFold(members[i].targetPath, claimed.TargetPath)
|
||||
jPrimary := strings.EqualFold(members[j].targetPath, claimed.TargetPath)
|
||||
return !iPrimary && jPrimary
|
||||
})
|
||||
return members, nil
|
||||
}
|
||||
|
||||
func validateWindowsClaimedReleaseUnit(claimed *repair.UpdateTransaction) error {
|
||||
if claimed == nil ||
|
||||
!strings.EqualFold(filepath.Base(claimed.TargetPath), "reasonix-desktop.exe") {
|
||||
return fmt.Errorf("claimed release unit primary executable is invalid")
|
||||
}
|
||||
required := map[string]bool{
|
||||
"reasonix-desktop.exe": false,
|
||||
"reasonix-guard.exe": false,
|
||||
"reasonix-launcher.exe": false,
|
||||
"reasonix-update-helper.exe": false,
|
||||
"reasonix-cli.exe": false,
|
||||
"reasonix.exe": false,
|
||||
}
|
||||
installDir := filepath.Clean(filepath.Dir(claimed.TargetPath))
|
||||
for _, file := range claimed.Files {
|
||||
target := filepath.Clean(strings.TrimSpace(file.TargetPath))
|
||||
if target == "" || target == "." ||
|
||||
!strings.EqualFold(filepath.Dir(target), installDir) {
|
||||
return fmt.Errorf("claimed release unit target is outside the installation directory")
|
||||
}
|
||||
name := strings.ToLower(filepath.Base(target))
|
||||
seen, ok := required[name]
|
||||
if !ok {
|
||||
return fmt.Errorf("claimed release unit contains an unexpected target")
|
||||
}
|
||||
if seen {
|
||||
return fmt.Errorf("claimed release unit contains a duplicate target")
|
||||
}
|
||||
required[name] = true
|
||||
}
|
||||
for name, seen := range required {
|
||||
if !seen {
|
||||
return fmt.Errorf("claimed release unit omits %s", name)
|
||||
}
|
||||
}
|
||||
if len(claimed.Files) != len(required) {
|
||||
return fmt.Errorf("claimed release unit contains an unexpected target")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func loadWindowsPayloadManifest(stagingDir, expectedVersion string) (map[string]string, error) {
|
||||
manifest, err := readWindowsPayloadMetadata(filepath.Join(stagingDir, update.WindowsPayloadManifestName))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read signed release manifest: %w", err)
|
||||
}
|
||||
signature, err := readWindowsPayloadMetadata(filepath.Join(stagingDir, update.WindowsPayloadSignatureName))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read signed release manifest signature: %w", err)
|
||||
}
|
||||
if err := verifyWindowsPayloadManifestFn(manifest, signature); err != nil {
|
||||
return nil, fmt.Errorf("verify signed release manifest: %w", err)
|
||||
}
|
||||
hashes, err := update.DecodeWindowsPayloadManifest(manifest, expectedVersion)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return hashes, nil
|
||||
}
|
||||
|
||||
func readWindowsPayloadMetadata(path string) ([]byte, error) {
|
||||
pathInfo, err := os.Lstat(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !pathInfo.Mode().IsRegular() {
|
||||
return nil, fmt.Errorf("%s is not a regular file", filepath.Base(path))
|
||||
}
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer file.Close()
|
||||
info, err := file.Stat()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !info.Mode().IsRegular() || info.Size() <= 0 || info.Size() > maxWindowsPayloadMetadataSize {
|
||||
return nil, fmt.Errorf("%s is not a bounded regular file", filepath.Base(path))
|
||||
}
|
||||
if !os.SameFile(pathInfo, info) {
|
||||
return nil, fmt.Errorf("%s changed before it was opened", filepath.Base(path))
|
||||
}
|
||||
data, err := io.ReadAll(io.LimitReader(file, maxWindowsPayloadMetadataSize+1))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(data) == 0 || len(data) > maxWindowsPayloadMetadataSize {
|
||||
return nil, fmt.Errorf("%s changed size while it was read", filepath.Base(path))
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func windowsStagedSourceName(targetBase string) (string, error) {
|
||||
switch strings.ToLower(strings.TrimSpace(targetBase)) {
|
||||
case "reasonix-desktop.exe":
|
||||
return "reasonix-desktop.exe", nil
|
||||
case "reasonix-guard.exe":
|
||||
return "reasonix-guard.exe", nil
|
||||
case "reasonix-launcher.exe":
|
||||
return "reasonix-launcher.exe", nil
|
||||
case "reasonix-update-helper.exe":
|
||||
return "reasonix-update-helper.exe", nil
|
||||
case "reasonix-cli.exe":
|
||||
return "reasonix-cli.exe", nil
|
||||
case "reasonix.exe":
|
||||
return "reasonix-launcher.exe", nil
|
||||
default:
|
||||
return "", fmt.Errorf("load staged release unit: unsupported target %q", targetBase)
|
||||
}
|
||||
}
|
||||
|
||||
func publishLoadedFileUpdateReleaseUnit(
|
||||
claimed *repair.UpdateTransaction,
|
||||
members []stagedFileUpdateMember,
|
||||
publish func(*repair.UpdateTransaction, string, []byte, os.FileMode) (repair.FileUpdateInstallReceipt, error),
|
||||
) ([]repair.FileUpdateInstallReceipt, error) {
|
||||
if publish == nil || len(members) == 0 {
|
||||
return nil, fmt.Errorf("publish staged release unit: payload is incomplete")
|
||||
}
|
||||
receipts := make([]repair.FileUpdateInstallReceipt, 0, len(members))
|
||||
for _, member := range members {
|
||||
receipt, err := publish(claimed, member.targetPath, member.content, member.mode)
|
||||
if err != nil {
|
||||
return receipts, fmt.Errorf("publish staged release unit %s: %w", filepath.Base(member.targetPath), err)
|
||||
}
|
||||
receipts = append(receipts, receipt)
|
||||
}
|
||||
return receipts, nil
|
||||
}
|
||||
@@ -1,377 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/base/tempdir"
|
||||
"reasonix/internal/platform/repair"
|
||||
"reasonix/internal/platform/update"
|
||||
)
|
||||
|
||||
func TestLoadWindowsStagedReleaseUnitPreflightsAllMembersAndPublishesDesktopLast(t *testing.T) {
|
||||
staging := tempdir.New(t)
|
||||
for name, content := range map[string]string{
|
||||
"reasonix-desktop.exe": "desktop-v2",
|
||||
"reasonix-guard.exe": "guard-v2",
|
||||
"reasonix-launcher.exe": "launcher-v2",
|
||||
"reasonix-update-helper.exe": "helper-v2",
|
||||
"reasonix-cli.exe": "cli-v2",
|
||||
} {
|
||||
if err := os.WriteFile(filepath.Join(staging, name), []byte(content), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
useTestWindowsPayloadManifest(t, staging, "v2")
|
||||
installDir := tempdir.New(t)
|
||||
claimed := &repair.UpdateTransaction{
|
||||
SchemaVersion: 1,
|
||||
ToVersion: "v2",
|
||||
TargetKind: "file",
|
||||
TargetPath: filepath.Join(installDir, "reasonix-desktop.exe"),
|
||||
Files: []repair.UpdateTransactionFile{
|
||||
{TargetPath: filepath.Join(installDir, "reasonix-desktop.exe")},
|
||||
{TargetPath: filepath.Join(installDir, "reasonix-guard.exe")},
|
||||
{TargetPath: filepath.Join(installDir, "reasonix-launcher.exe")},
|
||||
{TargetPath: filepath.Join(installDir, "reasonix-update-helper.exe")},
|
||||
{TargetPath: filepath.Join(installDir, "reasonix-cli.exe")},
|
||||
{TargetPath: filepath.Join(installDir, "Reasonix.exe")},
|
||||
},
|
||||
}
|
||||
|
||||
members, err := loadWindowsStagedReleaseUnit(claimed, staging)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := filepath.Base(members[len(members)-1].targetPath); !strings.EqualFold(got, "reasonix-desktop.exe") {
|
||||
t.Fatalf("last published member = %q, want desktop", got)
|
||||
}
|
||||
var published []string
|
||||
receipts, err := publishLoadedFileUpdateReleaseUnit(claimed, members, func(_ *repair.UpdateTransaction, target string, content []byte, _ os.FileMode) (repair.FileUpdateInstallReceipt, error) {
|
||||
published = append(published, filepath.Base(target)+"="+string(content))
|
||||
return repair.FileUpdateInstallReceipt{TargetPath: target}, nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(receipts) != len(members) {
|
||||
t.Fatalf("publish receipts = %d, want %d", len(receipts), len(members))
|
||||
}
|
||||
if got := strings.Join(published, ","); !strings.Contains(got, "Reasonix.exe=launcher-v2") {
|
||||
t.Fatalf("portable alias did not reuse launcher payload: %s", got)
|
||||
}
|
||||
if !strings.HasPrefix(published[len(published)-1], "reasonix-desktop.exe=") {
|
||||
t.Fatalf("publish order = %v", published)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadWindowsStagedReleaseUnitRejectsIncompletePayloadBeforePublish(t *testing.T) {
|
||||
staging := tempdir.New(t)
|
||||
if err := os.WriteFile(filepath.Join(staging, "reasonix-desktop.exe"), []byte("desktop-v2"), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
useTestWindowsPayloadManifest(t, staging, "v2")
|
||||
installDir := tempdir.New(t)
|
||||
claimed := &repair.UpdateTransaction{
|
||||
SchemaVersion: 1,
|
||||
ToVersion: "v2",
|
||||
TargetKind: "file",
|
||||
TargetPath: filepath.Join(installDir, "reasonix-desktop.exe"),
|
||||
Files: []repair.UpdateTransactionFile{
|
||||
{TargetPath: filepath.Join(installDir, "reasonix-desktop.exe")},
|
||||
{TargetPath: filepath.Join(installDir, "reasonix-guard.exe")},
|
||||
},
|
||||
}
|
||||
if _, err := loadWindowsStagedReleaseUnit(claimed, staging); err == nil {
|
||||
t.Fatal("incomplete staged release unit was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateWindowsClaimedReleaseUnitRequiresExactTargets(t *testing.T) {
|
||||
_, complete := completeWindowsStagedReleaseUnitForTest(t, "v2")
|
||||
for i, file := range complete.Files {
|
||||
t.Run("missing-"+strings.ToLower(filepath.Base(file.TargetPath)), func(t *testing.T) {
|
||||
claimed := *complete
|
||||
claimed.Files = append([]repair.UpdateTransactionFile(nil), complete.Files[:i]...)
|
||||
claimed.Files = append(claimed.Files, complete.Files[i+1:]...)
|
||||
if err := validateWindowsClaimedReleaseUnit(&claimed); err == nil ||
|
||||
!strings.Contains(err.Error(), "omits") {
|
||||
t.Fatalf("missing target error = %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
t.Run("extra", func(t *testing.T) {
|
||||
claimed := *complete
|
||||
claimed.Files = append(append([]repair.UpdateTransactionFile(nil), complete.Files...),
|
||||
repair.UpdateTransactionFile{TargetPath: filepath.Join(filepath.Dir(complete.TargetPath), "other.exe")})
|
||||
if err := validateWindowsClaimedReleaseUnit(&claimed); err == nil ||
|
||||
!strings.Contains(err.Error(), "unexpected") {
|
||||
t.Fatalf("extra target error = %v", err)
|
||||
}
|
||||
})
|
||||
t.Run("duplicate", func(t *testing.T) {
|
||||
claimed := *complete
|
||||
claimed.Files = append(append([]repair.UpdateTransactionFile(nil), complete.Files...), complete.Files[0])
|
||||
if err := validateWindowsClaimedReleaseUnit(&claimed); err == nil ||
|
||||
!strings.Contains(err.Error(), "duplicate") {
|
||||
t.Fatalf("duplicate target error = %v", err)
|
||||
}
|
||||
})
|
||||
t.Run("outside", func(t *testing.T) {
|
||||
claimed := *complete
|
||||
claimed.Files = append([]repair.UpdateTransactionFile(nil), complete.Files...)
|
||||
claimed.Files[1].TargetPath = filepath.Join(tempdir.New(t), filepath.Base(claimed.Files[1].TargetPath))
|
||||
if err := validateWindowsClaimedReleaseUnit(&claimed); err == nil ||
|
||||
!strings.Contains(err.Error(), "outside") {
|
||||
t.Fatalf("outside target error = %v", err)
|
||||
}
|
||||
})
|
||||
t.Run("primary", func(t *testing.T) {
|
||||
claimed := *complete
|
||||
claimed.TargetPath = filepath.Join(filepath.Dir(complete.TargetPath), "reasonix-guard.exe")
|
||||
if err := validateWindowsClaimedReleaseUnit(&claimed); err == nil ||
|
||||
!strings.Contains(err.Error(), "primary") {
|
||||
t.Fatalf("primary target error = %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestLoadWindowsStagedReleaseUnitDoesNotCreateMissingPortableAlias(t *testing.T) {
|
||||
staging := tempdir.New(t)
|
||||
for _, name := range update.WindowsPayloadFileNames() {
|
||||
content := "payload:" + name
|
||||
if err := os.WriteFile(filepath.Join(staging, name), []byte(content), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
useTestWindowsPayloadManifest(t, staging, "v2")
|
||||
installDir := tempdir.New(t)
|
||||
claimed := &repair.UpdateTransaction{
|
||||
SchemaVersion: 1,
|
||||
ToVersion: "v2",
|
||||
TargetKind: "file",
|
||||
TargetPath: filepath.Join(installDir, "reasonix-desktop.exe"),
|
||||
Files: []repair.UpdateTransactionFile{
|
||||
{TargetPath: filepath.Join(installDir, "reasonix-desktop.exe")},
|
||||
{TargetPath: filepath.Join(installDir, "reasonix-guard.exe")},
|
||||
{TargetPath: filepath.Join(installDir, "reasonix-launcher.exe")},
|
||||
{TargetPath: filepath.Join(installDir, "reasonix-update-helper.exe")},
|
||||
{TargetPath: filepath.Join(installDir, "reasonix-cli.exe")},
|
||||
{TargetPath: filepath.Join(installDir, "Reasonix.exe"), MissingBefore: true},
|
||||
},
|
||||
}
|
||||
|
||||
members, err := loadWindowsStagedReleaseUnit(claimed, staging)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, member := range members {
|
||||
if strings.EqualFold(filepath.Base(member.targetPath), "Reasonix.exe") {
|
||||
t.Fatalf("missing portable alias was added to publish set: %+v", members)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublishLoadedFileUpdateReleaseUnitStopsOnFirstFailedCompareAndPublish(t *testing.T) {
|
||||
claimed := &repair.UpdateTransaction{TargetKind: "file"}
|
||||
members := []stagedFileUpdateMember{
|
||||
{targetPath: "guard.exe", content: []byte("guard"), mode: 0o700},
|
||||
{targetPath: "desktop.exe", content: []byte("desktop"), mode: 0o700},
|
||||
}
|
||||
var published []string
|
||||
receipts, err := publishLoadedFileUpdateReleaseUnit(claimed, members, func(_ *repair.UpdateTransaction, target string, _ []byte, _ os.FileMode) (repair.FileUpdateInstallReceipt, error) {
|
||||
published = append(published, target)
|
||||
return repair.FileUpdateInstallReceipt{}, errors.New("concurrent recreation")
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "concurrent recreation") {
|
||||
t.Fatalf("publish error = %v", err)
|
||||
}
|
||||
if len(published) != 1 {
|
||||
t.Fatalf("published members = %v, want one attempted member", published)
|
||||
}
|
||||
if len(receipts) != 0 {
|
||||
t.Fatalf("failed first publish returned receipts: %+v", receipts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadWindowsStagedReleaseUnitRejectsUnverifiedPayloadBeforePublish(t *testing.T) {
|
||||
staging := tempdir.New(t)
|
||||
for _, name := range []string{
|
||||
"reasonix-desktop.exe",
|
||||
"reasonix-guard.exe",
|
||||
"reasonix-launcher.exe",
|
||||
"reasonix-update-helper.exe",
|
||||
"reasonix-cli.exe",
|
||||
} {
|
||||
if err := os.WriteFile(filepath.Join(staging, name), []byte(name), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
writeWindowsPayloadManifestForTest(t, staging, "v2")
|
||||
acceptWindowsPayloadManifestForTest(t)
|
||||
_, claimed := completeWindowsStagedReleaseUnitForTest(t, "v2")
|
||||
original := readVerifiedWindowsStagedPayloadFn
|
||||
readVerifiedWindowsStagedPayloadFn = func(string) ([]byte, error) {
|
||||
return nil, errors.New("payload signature rejected")
|
||||
}
|
||||
t.Cleanup(func() { readVerifiedWindowsStagedPayloadFn = original })
|
||||
|
||||
if _, err := loadWindowsStagedReleaseUnit(claimed, staging); err == nil {
|
||||
t.Fatal("unsigned staged payload must be rejected before publication")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadWindowsStagedReleaseUnitReadsEachSourceThroughVerifier(t *testing.T) {
|
||||
staging := tempdir.New(t)
|
||||
for name, content := range map[string]string{
|
||||
"reasonix-desktop.exe": "desktop-v2",
|
||||
"reasonix-guard.exe": "guard-v2",
|
||||
"reasonix-launcher.exe": "launcher-v2",
|
||||
"reasonix-update-helper.exe": "helper-v2",
|
||||
"reasonix-cli.exe": "cli-v2",
|
||||
} {
|
||||
if err := os.WriteFile(filepath.Join(staging, name), []byte(content), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
writeWindowsPayloadManifestForTest(t, staging, "v2")
|
||||
acceptWindowsPayloadManifestForTest(t)
|
||||
_, claimed := completeWindowsStagedReleaseUnitForTest(t, "v2")
|
||||
var verified []string
|
||||
original := readVerifiedWindowsStagedPayloadFn
|
||||
readVerifiedWindowsStagedPayloadFn = func(path string) ([]byte, error) {
|
||||
verified = append(verified, filepath.Base(path))
|
||||
return os.ReadFile(path)
|
||||
}
|
||||
t.Cleanup(func() { readVerifiedWindowsStagedPayloadFn = original })
|
||||
|
||||
if _, err := loadWindowsStagedReleaseUnit(claimed, staging); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(verified) != len(update.WindowsPayloadFileNames()) {
|
||||
t.Fatalf("verified payload count = %d, want %d (%v)", len(verified), len(update.WindowsPayloadFileNames()), verified)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadWindowsStagedReleaseUnitRejectsMissingManifest(t *testing.T) {
|
||||
staging, claimed := completeWindowsStagedReleaseUnitForTest(t, "v2")
|
||||
useUnverifiedPayloadReaderForTest(t)
|
||||
if _, err := loadWindowsStagedReleaseUnit(claimed, staging); err == nil ||
|
||||
!strings.Contains(err.Error(), update.WindowsPayloadManifestName) {
|
||||
t.Fatalf("missing signed manifest error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadWindowsStagedReleaseUnitRejectsBadManifestSignature(t *testing.T) {
|
||||
staging, claimed := completeWindowsStagedReleaseUnitForTest(t, "v2")
|
||||
writeWindowsPayloadManifestForTest(t, staging, "v2")
|
||||
useUnverifiedPayloadReaderForTest(t)
|
||||
if _, err := loadWindowsStagedReleaseUnit(claimed, staging); err == nil ||
|
||||
!strings.Contains(err.Error(), "verify signed release manifest") {
|
||||
t.Fatalf("bad signed manifest error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadWindowsStagedReleaseUnitRejectsManifestVersionDrift(t *testing.T) {
|
||||
staging, claimed := completeWindowsStagedReleaseUnitForTest(t, "v2")
|
||||
writeWindowsPayloadManifestForTest(t, staging, "v3")
|
||||
acceptWindowsPayloadManifestForTest(t)
|
||||
useUnverifiedPayloadReaderForTest(t)
|
||||
if _, err := loadWindowsStagedReleaseUnit(claimed, staging); err == nil ||
|
||||
!strings.Contains(err.Error(), "identity does not match") {
|
||||
t.Fatalf("release version drift error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadWindowsStagedReleaseUnitRejectsManifestMemberHashDrift(t *testing.T) {
|
||||
staging, claimed := completeWindowsStagedReleaseUnitForTest(t, "v2")
|
||||
writeWindowsPayloadManifestForTest(t, staging, "v2")
|
||||
acceptWindowsPayloadManifestForTest(t)
|
||||
useUnverifiedPayloadReaderForTest(t)
|
||||
if err := os.WriteFile(filepath.Join(staging, "reasonix-guard.exe"), []byte("tampered"), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := loadWindowsStagedReleaseUnit(claimed, staging); err == nil ||
|
||||
!strings.Contains(err.Error(), "does not match the signed release manifest") {
|
||||
t.Fatalf("release member drift error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func completeWindowsStagedReleaseUnitForTest(t *testing.T, version string) (string, *repair.UpdateTransaction) {
|
||||
t.Helper()
|
||||
staging := tempdir.New(t)
|
||||
for _, name := range update.WindowsPayloadFileNames() {
|
||||
if err := os.WriteFile(filepath.Join(staging, name), []byte("payload:"+name), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
installDir := tempdir.New(t)
|
||||
files := make([]repair.UpdateTransactionFile, 0, len(update.WindowsPayloadFileNames())+1)
|
||||
for _, name := range update.WindowsPayloadFileNames() {
|
||||
files = append(files, repair.UpdateTransactionFile{TargetPath: filepath.Join(installDir, name)})
|
||||
}
|
||||
files = append(files, repair.UpdateTransactionFile{
|
||||
TargetPath: filepath.Join(installDir, "Reasonix.exe"),
|
||||
MissingBefore: true,
|
||||
})
|
||||
return staging, &repair.UpdateTransaction{
|
||||
SchemaVersion: 1,
|
||||
ToVersion: version,
|
||||
TargetKind: "file",
|
||||
TargetPath: filepath.Join(installDir, "reasonix-desktop.exe"),
|
||||
Files: files,
|
||||
}
|
||||
}
|
||||
|
||||
func writeWindowsPayloadManifestForTest(t *testing.T, staging, version string) {
|
||||
t.Helper()
|
||||
hashes := make(map[string]string)
|
||||
for _, name := range update.WindowsPayloadFileNames() {
|
||||
content, err := os.ReadFile(filepath.Join(staging, name))
|
||||
if os.IsNotExist(err) {
|
||||
content = []byte("missing:" + name)
|
||||
} else if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
hashes[name] = update.WindowsPayloadSHA256(content)
|
||||
}
|
||||
manifest, err := update.EncodeWindowsPayloadManifest(version, hashes)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(staging, update.WindowsPayloadManifestName), manifest, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(staging, update.WindowsPayloadSignatureName), []byte("test signature"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func acceptWindowsPayloadManifestForTest(t *testing.T) {
|
||||
t.Helper()
|
||||
original := verifyWindowsPayloadManifestFn
|
||||
verifyWindowsPayloadManifestFn = func(_, signature []byte) error {
|
||||
if string(signature) != "test signature" {
|
||||
return errors.New("unexpected test signature")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
t.Cleanup(func() { verifyWindowsPayloadManifestFn = original })
|
||||
}
|
||||
|
||||
func useTestWindowsPayloadManifest(t *testing.T, staging, version string) {
|
||||
t.Helper()
|
||||
writeWindowsPayloadManifestForTest(t, staging, version)
|
||||
acceptWindowsPayloadManifestForTest(t)
|
||||
useUnverifiedPayloadReaderForTest(t)
|
||||
}
|
||||
|
||||
func useUnverifiedPayloadReaderForTest(t *testing.T) {
|
||||
t.Helper()
|
||||
original := readVerifiedWindowsStagedPayloadFn
|
||||
readVerifiedWindowsStagedPayloadFn = os.ReadFile
|
||||
t.Cleanup(func() { readVerifiedWindowsStagedPayloadFn = original })
|
||||
}
|
||||
@@ -1,118 +0,0 @@
|
||||
//go:build windows
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"reasonix/internal/platform/installlayout"
|
||||
"reasonix/internal/platform/repair"
|
||||
)
|
||||
|
||||
// activateVersionedWindowsFromStaging publishes the versioned-v1 layout from a
|
||||
// staged NSIS payload:
|
||||
//
|
||||
// InstallRoot/
|
||||
// reasonix-launcher.exe
|
||||
// Reasonix.exe (launcher alias when present or portable)
|
||||
// reasonix-cli.exe (CLI entry; full binary for now)
|
||||
// current.json
|
||||
// versions/<version>/
|
||||
// reasonix-desktop.exe
|
||||
// reasonix-cli.exe
|
||||
// reasonix-update-helper.exe
|
||||
//
|
||||
// Any failure before the current.json pointer swap leaves the previous active
|
||||
// version unchanged. The helper never counts crashes or selects prior versions.
|
||||
func activateVersionedWindowsFromStaging(claimed *repair.UpdateTransaction, stagingDir string) error {
|
||||
if claimed == nil {
|
||||
return fmt.Errorf("versioned activate: transaction is nil")
|
||||
}
|
||||
installRoot := filepath.Clean(strings.TrimSpace(filepath.Dir(claimed.TargetPath)))
|
||||
// When the claimed primary is already under versions/<ver>/, climb to root.
|
||||
if root, err := installlayout.ResolveInstallRoot(claimed.TargetPath); err == nil && root != "" {
|
||||
installRoot = root
|
||||
}
|
||||
version := strings.TrimSpace(claimed.ToVersion)
|
||||
if err := installlayout.ValidateVersionName(version); err != nil {
|
||||
// Accept bare product versions from NSIS (1.20.0 → v1.20.0).
|
||||
if !strings.HasPrefix(version, "v") {
|
||||
version = "v" + version
|
||||
}
|
||||
if err := installlayout.ValidateVersionName(version); err != nil {
|
||||
return fmt.Errorf("versioned activate: %w", err)
|
||||
}
|
||||
}
|
||||
stagingDir = filepath.Clean(strings.TrimSpace(stagingDir))
|
||||
|
||||
desktopSrc := filepath.Join(stagingDir, "reasonix-desktop.exe")
|
||||
cliSrc := filepath.Join(stagingDir, "reasonix-cli.exe")
|
||||
helperSrc := filepath.Join(stagingDir, "reasonix-update-helper.exe")
|
||||
launcherSrc := filepath.Join(stagingDir, "reasonix-launcher.exe")
|
||||
for _, path := range []string{desktopSrc, cliSrc, helperSrc, launcherSrc} {
|
||||
info, err := os.Lstat(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("versioned activate: staged %s: %w", filepath.Base(path), err)
|
||||
}
|
||||
if !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 {
|
||||
return fmt.Errorf("versioned activate: staged %s is not a regular file", filepath.Base(path))
|
||||
}
|
||||
}
|
||||
|
||||
requestID := repair.UpdateTransactionID(claimed)
|
||||
if requestID == "" {
|
||||
requestID = "helper-" + version
|
||||
}
|
||||
if err := installlayout.ActivateVersion(installlayout.ActivationRequest{
|
||||
InstallRoot: installRoot,
|
||||
Version: version,
|
||||
RequestID: requestID,
|
||||
Members: []installlayout.Member{
|
||||
{Name: "reasonix-desktop.exe", Path: desktopSrc, Mode: 0o700},
|
||||
{Name: "reasonix-cli.exe", Path: cliSrc, Mode: 0o700},
|
||||
{Name: "reasonix-update-helper.exe", Path: helperSrc, Mode: 0o700},
|
||||
},
|
||||
RootMembers: []installlayout.Member{
|
||||
{Name: "reasonix-launcher.exe", Path: launcherSrc, Mode: 0o700},
|
||||
{Name: "Reasonix.exe", Path: launcherSrc, Mode: 0o700},
|
||||
{Name: "reasonix-cli.exe", Path: cliSrc, Mode: 0o700},
|
||||
},
|
||||
RequiredRootNames: []string{"reasonix-launcher.exe", "Reasonix.exe", "reasonix-cli.exe"},
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Remove flat release-unit leftovers so the install root is the thin layout.
|
||||
// Do not remove the launcher/CLI/alias we just wrote.
|
||||
for _, name := range []string{
|
||||
"reasonix-desktop.exe",
|
||||
"reasonix-guard.exe",
|
||||
"reasonix-update-helper.exe", // helper lives only under versions/
|
||||
} {
|
||||
_ = os.Remove(filepath.Join(installRoot, name))
|
||||
}
|
||||
// Best-effort retention GC of older version trees.
|
||||
_ = installlayout.RetainPreviousVersions(installRoot, 0)
|
||||
_ = installlayout.CleanupStaleStaging(installRoot, 0)
|
||||
return nil
|
||||
}
|
||||
|
||||
// preferVersionedWindowsActivation reports whether the staged payload is
|
||||
// complete enough for versioned-v1 activation.
|
||||
func preferVersionedWindowsActivation(stagingDir string) bool {
|
||||
for _, name := range []string{
|
||||
"reasonix-desktop.exe",
|
||||
"reasonix-cli.exe",
|
||||
"reasonix-update-helper.exe",
|
||||
"reasonix-launcher.exe",
|
||||
} {
|
||||
info, err := os.Lstat(filepath.Join(stagingDir, name))
|
||||
if err != nil || !info.Mode().IsRegular() {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -1,131 +0,0 @@
|
||||
//go:build windows
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/platform/installlayout"
|
||||
"reasonix/internal/platform/repair"
|
||||
|
||||
"reasonix/internal/base/tempdir"
|
||||
)
|
||||
|
||||
func TestPreferVersionedWindowsActivation(t *testing.T) {
|
||||
staging := tempdir.New(t)
|
||||
if preferVersionedWindowsActivation(staging) {
|
||||
t.Fatal("empty staging must not prefer versioned")
|
||||
}
|
||||
for _, name := range []string{
|
||||
"reasonix-desktop.exe",
|
||||
"reasonix-cli.exe",
|
||||
"reasonix-update-helper.exe",
|
||||
"reasonix-launcher.exe",
|
||||
} {
|
||||
if err := os.WriteFile(filepath.Join(staging, name), []byte(name), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if !preferVersionedWindowsActivation(staging) {
|
||||
t.Fatal("complete staging must prefer versioned")
|
||||
}
|
||||
}
|
||||
|
||||
func TestActivateVersionedWindowsFromStaging(t *testing.T) {
|
||||
installDir := tempdir.New(t)
|
||||
staging := tempdir.New(t)
|
||||
for _, name := range []string{
|
||||
"reasonix-desktop.exe",
|
||||
"reasonix-cli.exe",
|
||||
"reasonix-update-helper.exe",
|
||||
"reasonix-launcher.exe",
|
||||
"reasonix-guard.exe",
|
||||
} {
|
||||
if err := os.WriteFile(filepath.Join(staging, name), []byte("payload:"+name), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
// Seed a flat desktop so cleanup is observable.
|
||||
if err := os.WriteFile(filepath.Join(installDir, "reasonix-desktop.exe"), []byte("old"), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(installDir, "reasonix-guard.exe"), []byte("old-guard"), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
claimed := &repair.UpdateTransaction{
|
||||
SchemaVersion: 1,
|
||||
ToVersion: "v1.20.0",
|
||||
TargetKind: "file",
|
||||
TargetPath: filepath.Join(installDir, "reasonix-desktop.exe"),
|
||||
CreatedAt: "2026-01-01T00:00:00Z",
|
||||
}
|
||||
if err := activateVersionedWindowsFromStaging(claimed, staging); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !installlayout.HasCurrent(installDir) {
|
||||
t.Fatal("current.json missing")
|
||||
}
|
||||
ptr, err := installlayout.ReadCurrent(installDir)
|
||||
if err != nil || ptr.ActiveVersion != "v1.20.0" {
|
||||
t.Fatalf("pointer=%+v err=%v", ptr, err)
|
||||
}
|
||||
desktop, err := installlayout.ActiveDesktopPath(installDir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
raw, err := os.ReadFile(desktop)
|
||||
if err != nil || string(raw) != "payload:reasonix-desktop.exe" {
|
||||
t.Fatalf("desktop payload = %q err=%v", raw, err)
|
||||
}
|
||||
for _, name := range []string{"reasonix-launcher.exe", "Reasonix.exe", "reasonix-cli.exe"} {
|
||||
if _, err := os.Stat(filepath.Join(installDir, name)); err != nil {
|
||||
t.Fatalf("root entry %s: %v", name, err)
|
||||
}
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(installDir, "reasonix-desktop.exe")); !os.IsNotExist(err) {
|
||||
t.Fatal("flat desktop should be removed")
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(installDir, "reasonix-guard.exe")); !os.IsNotExist(err) {
|
||||
t.Fatal("flat guard should be removed")
|
||||
}
|
||||
if prefer := preferRelaunchPath("", installDir); filepath.Base(prefer) != "reasonix-launcher.exe" {
|
||||
t.Fatalf("prefer relaunch = %s", prefer)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallStagedWindowsReleaseUnitPrefersVersioned(t *testing.T) {
|
||||
installDir := tempdir.New(t)
|
||||
staging := tempdir.New(t)
|
||||
for _, name := range []string{
|
||||
"reasonix-desktop.exe",
|
||||
"reasonix-cli.exe",
|
||||
"reasonix-update-helper.exe",
|
||||
"reasonix-launcher.exe",
|
||||
"reasonix-guard.exe",
|
||||
} {
|
||||
if err := os.WriteFile(filepath.Join(staging, name), []byte("p:"+name), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
// Minimal claimed unit (versioned path does not need full flat file list).
|
||||
claimed := &repair.UpdateTransaction{
|
||||
SchemaVersion: 1,
|
||||
ToVersion: "v1.20.0",
|
||||
TargetKind: "file",
|
||||
TargetPath: filepath.Join(installDir, "reasonix-desktop.exe"),
|
||||
CreatedAt: "2026-01-01T00:00:00Z",
|
||||
}
|
||||
started, receipts, err := installStagedWindowsReleaseUnit(claimed, staging)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !started || receipts != nil {
|
||||
t.Fatalf("started=%v receipts=%v want started with nil receipts", started, receipts)
|
||||
}
|
||||
if !installlayout.HasCurrent(installDir) {
|
||||
t.Fatal("versioned activation did not write current.json")
|
||||
}
|
||||
}
|
||||
@@ -4,5 +4,14 @@
|
||||
"x64": "amd64",
|
||||
"arm64": "arm64",
|
||||
"universal": "universal"
|
||||
},
|
||||
"_fpm": "fpm spells the architecture its own way in an .rpm name (x86_64, aarch64) wherever artifactName uses ${arch}; the deb comes out as amd64 already. Each spelling is renamed to the release arch, and one not listed here fails the build.",
|
||||
"fpm": {
|
||||
"x64": [
|
||||
"x86_64"
|
||||
],
|
||||
"arm64": [
|
||||
"aarch64"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -96,6 +96,7 @@ mac:
|
||||
linux:
|
||||
target:
|
||||
- deb
|
||||
- rpm
|
||||
icon: assets/icon.png
|
||||
category: Development
|
||||
maintainer: esengine <[email protected]>
|
||||
@@ -129,3 +130,22 @@ deb:
|
||||
- root
|
||||
- linux/reasonix-studio-update-helper=/usr/lib/reasonix-studio/reasonix-studio-update-helper
|
||||
- ../packaging/linux/io.reasonix.studio.update.policy=/usr/share/polkit-1/actions/io.reasonix.studio.update.policy
|
||||
|
||||
rpm:
|
||||
# The same package name as the deb, so a distribution's package database
|
||||
# names one product under one identifier.
|
||||
packageName: reasonix-studio
|
||||
# electron-builder's own script removes the command link whatever $1 says, and
|
||||
# rpm runs it for the old package after the new one installed its link.
|
||||
afterRemove: ../packaging/linux/rpm-after-remove.sh
|
||||
# The same two system-path files as the deb, for the same reasons, with the
|
||||
# owner stated for the same reason: the rpm spec records whoever ran the build
|
||||
# unless told, and polkit will not execute a helper its invoker could have
|
||||
# written.
|
||||
fpm:
|
||||
- --rpm-user
|
||||
- root
|
||||
- --rpm-group
|
||||
- root
|
||||
- linux/reasonix-studio-update-helper=/usr/lib/reasonix-studio/reasonix-studio-update-helper
|
||||
- ../packaging/linux/io.reasonix.studio.update.policy=/usr/share/polkit-1/actions/io.reasonix.studio.update.policy
|
||||
@@ -16,12 +16,13 @@
|
||||
"smoke": "electron test/smoke.js",
|
||||
"browser-live": "electron test/browser_live.js",
|
||||
"freeze-live": "electron test/freeze_live.js",
|
||||
"windowstate-live": "electron test/windowstate_live.js",
|
||||
"computer-live": "electron test/computer_live.js"
|
||||
},
|
||||
"devDependencies": {
|
||||
"electron": "44.5.1",
|
||||
"electron": "44.7.0",
|
||||
"electron-builder": "^26.15.3",
|
||||
"playwright": "1.63.0"
|
||||
"playwright": "1.64.0"
|
||||
},
|
||||
"pnpm": {
|
||||
"onlyBuiltDependencies": [
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use strict";
|
||||
const path = require("node:path");
|
||||
const { map } = require("../arch.json");
|
||||
const { map, fpm } = require("../arch.json");
|
||||
|
||||
// What each artifact was built for, keyed by the path electron-builder wrote.
|
||||
// Taken from the Arch enum it hands the hook rather than read back out of the
|
||||
@@ -26,4 +26,9 @@ function canonical(arch) {
|
||||
return to;
|
||||
}
|
||||
|
||||
module.exports = { built, record, canonical, map };
|
||||
// Every spelling electron-builder or fpm may put in a file name for arch.
|
||||
function spellings(arch) {
|
||||
return [arch, ...(fpm[arch] ?? [])];
|
||||
}
|
||||
|
||||
module.exports = { built, record, canonical, spellings, map, fpm };
|
||||
@@ -1,12 +1,21 @@
|
||||
"use strict";
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const { built, canonical, map } = require("./arch");
|
||||
const { built, canonical, spellings, map, fpm } = require("./arch");
|
||||
|
||||
// The archs whose electron-builder spelling differs from the release one. A
|
||||
// name still carrying one of these got past the rename, which is the failure
|
||||
// this refuses to ship rather than report.
|
||||
const foreign = Object.keys(map).filter((from) => map[from] !== from);
|
||||
const foreign = [
|
||||
...Object.keys(map).filter((from) => map[from] !== from),
|
||||
...Object.values(fpm).flat(),
|
||||
];
|
||||
|
||||
// names reports whether a file name carries arch as a whole segment, which is
|
||||
// how both the rename and the leftover check recognise it.
|
||||
function names(name, arch) {
|
||||
return name.includes(`-${arch}.`) || name.includes(`-${arch}-`);
|
||||
}
|
||||
|
||||
// plan records the rename of one artifact and of everything written beside it
|
||||
// under its name — a .blockmap left behind points at a file that no longer
|
||||
@@ -38,7 +47,9 @@ module.exports = async function canonicalArtifactNames(buildResult) {
|
||||
const arch = built.get(path.resolve(file));
|
||||
if (!arch) continue;
|
||||
const to = canonical(arch);
|
||||
if (to !== arch) plan(path.resolve(file), arch, to, renames);
|
||||
const name = path.basename(file);
|
||||
const from = spellings(arch).find((s) => names(name, s));
|
||||
if (from && from !== to) plan(path.resolve(file), from, to, renames);
|
||||
}
|
||||
for (const [from, to] of renames) fs.renameSync(from, to);
|
||||
|
||||
@@ -53,7 +64,7 @@ module.exports = async function canonicalArtifactNames(buildResult) {
|
||||
}
|
||||
for (const file of out) {
|
||||
const name = path.basename(file);
|
||||
const stray = foreign.find((a) => name.includes(`-${a}.`) || name.includes(`-${a}-`));
|
||||
const stray = foreign.find((a) => names(name, a));
|
||||
if (stray) {
|
||||
throw new Error(`${name} still names ${stray}; studio-manifest reads artifact names as GOARCH`);
|
||||
}
|
||||
|
||||
Generated
+18
-18
@@ -9,14 +9,14 @@ importers:
|
||||
.:
|
||||
devDependencies:
|
||||
electron:
|
||||
specifier: 44.5.1
|
||||
version: 44.5.1([email protected])
|
||||
specifier: 44.7.0
|
||||
version: 44.7.0([email protected])
|
||||
electron-builder:
|
||||
specifier: ^26.15.3
|
||||
version: 26.15.3([email protected])([email protected])
|
||||
playwright:
|
||||
specifier: 1.63.0
|
||||
version: 1.63.0
|
||||
specifier: 1.64.0
|
||||
version: 1.64.0
|
||||
|
||||
packages:
|
||||
|
||||
@@ -374,8 +374,8 @@ packages:
|
||||
resolution: {integrity: sha512-bO3y10YikuUwUuDUQRM4KfwNkKhnpVO7IPdbsrejwN9/AABJzzTQ4GeHwyzNSrVO+tEH3/Np255a3sVZpZDjvg==}
|
||||
engines: {node: '>=8.0.0'}
|
||||
|
||||
electron@44.5.1:
|
||||
resolution: {integrity: sha512-lx7AYoFIiyvEvgvpyWO9kOC60xtNPpiPZAth6Fmg9NutzpCAMLwpUlaBY2EXfZyIgUdzEmLMj9KvK0Vrokl1yQ==}
|
||||
electron@44.7.0:
|
||||
resolution: {integrity: sha512-aUq9/zLAAFYc+a9gy6cd0C3Y/N/RmovLCD8TV8LhIzNhuND6HEgsu96jJz9cbAxvckSPv+Trf2PKEjktoZgFkw==}
|
||||
engines: {node: '>= 22.12.0'}
|
||||
hasBin: true
|
||||
|
||||
@@ -762,13 +762,13 @@ packages:
|
||||
resolution: {integrity: sha512-emEcLuomt2j03vxD54giVB4SxTjnsqkU692xZOZXHDVoYyypEm+b3jpiTcc+Cf+myooc+/Ly0z01jqeNHVgJGw==}
|
||||
engines: {node: '>=16.0.0'}
|
||||
|
||||
[email protected]3.0:
|
||||
resolution: {integrity: sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==}
|
||||
[email protected]4.0:
|
||||
resolution: {integrity: sha512-T9r+MZkTECl2+oUcZ26YgZzTrYidTsIhYYkJix+6iDOymOb1FB2RLLSOxRGnZGv5DStt+aduIVbt+E8WygXYpg==}
|
||||
engines: {node: '>=20'}
|
||||
hasBin: true
|
||||
|
||||
[email protected]3.0:
|
||||
resolution: {integrity: sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==}
|
||||
[email protected]4.0:
|
||||
resolution: {integrity: sha512-kVzTnYFYEyQ9RGzBjNCd2+yEqWmVbxl3AHPynv3qQ6JTjv4a+kwRcnqQwIpuP3NnrnIrE/Q6wWAIAvMfIE2/Yg==}
|
||||
engines: {node: '>=20'}
|
||||
hasBin: true
|
||||
|
||||
@@ -1551,7 +1551,7 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
electron@44.5.1([email protected]):
|
||||
electron@44.7.0([email protected]):
|
||||
dependencies:
|
||||
'@electron-internal/extract-zip': 1.0.5
|
||||
'@electron/get': 5.1.0([email protected])
|
||||
@@ -1696,7 +1696,7 @@ snapshots:
|
||||
es6-error: 4.1.1
|
||||
matcher: 3.0.0
|
||||
roarr: 2.15.4
|
||||
semver: 7.8.5
|
||||
semver: 7.7.4
|
||||
serialize-error: 7.0.1
|
||||
optional: true
|
||||
|
||||
@@ -1883,11 +1883,11 @@ snapshots:
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
semver: 7.8.5
|
||||
semver: 7.7.4
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
semver: 7.8.5
|
||||
semver: 7.7.4
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
@@ -1896,7 +1896,7 @@ snapshots:
|
||||
graceful-fs: 4.2.11
|
||||
nopt: 9.0.0
|
||||
proc-log: 6.1.0
|
||||
semver: 7.8.5
|
||||
semver: 7.7.4
|
||||
tar: 7.5.22
|
||||
tinyglobby: 0.2.17
|
||||
undici: 6.28.0
|
||||
@@ -1942,11 +1942,11 @@ snapshots:
|
||||
pvutils: 1.2.0
|
||||
tslib: 2.8.1
|
||||
|
||||
[email protected]3.0: {}
|
||||
[email protected]4.0: {}
|
||||
|
||||
[email protected]3.0:
|
||||
[email protected]4.0:
|
||||
dependencies:
|
||||
playwright-core: 1.63.0
|
||||
playwright-core: 1.64.0
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
"use strict";
|
||||
|
||||
// The acts the kernel asks of the application, because only this process can do
|
||||
// them. relaunch and quit are a handover and arrive in that order, so arming
|
||||
// the restart before ending is the sequence rather than a coincidence; show
|
||||
// brings the window forward for a clicked toast.
|
||||
//
|
||||
// Nothing is answered. Each is performed by ending or by showing, so an
|
||||
// acknowledgement would have to come from a process on its way out.
|
||||
function actHandler({ app, showWindow }) {
|
||||
return (act) => {
|
||||
if (act === "relaunch") {
|
||||
app.relaunch();
|
||||
return;
|
||||
}
|
||||
if (act === "quit") app.quit();
|
||||
if (act === "show") showWindow();
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { actHandler };
|
||||
@@ -152,6 +152,10 @@ class BrowserViews {
|
||||
if (id !== targetId) this.putAway(other.view, other);
|
||||
}
|
||||
this.shown = targetId;
|
||||
// A page that was parked comes back as a view that is attached but whose
|
||||
// widget never repaints until the window's geometry changes; attaching it
|
||||
// again is what wakes it, the same as after a minimize.
|
||||
if (!entry.at) this.win.contentView.removeChildView(entry.view);
|
||||
this.win.contentView.addChildView(entry.view);
|
||||
entry.view.setBounds(at);
|
||||
entry.at = at;
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
"use strict";
|
||||
|
||||
const PREF_KEY = "rx-hw-accel";
|
||||
|
||||
// A launch-only override for a window that will not draw: the environment
|
||||
// variable or the flag, neither of which is written back to the preferences.
|
||||
function shouldDisableGpu({ prefs, env, argv }) {
|
||||
return accelerationOff(prefs) || env?.REASONIX_DISABLE_GPU === "1" || (argv ?? []).includes("--disable-gpu");
|
||||
}
|
||||
|
||||
// Chromium reads this before the app is ready, so a saved "off" only reaches
|
||||
// the launch after the one that saved it.
|
||||
function accelerationOff(prefs) {
|
||||
return prefs?.[PREF_KEY] === "off";
|
||||
}
|
||||
|
||||
// launchedOff is what this launch applied (the saved choice or a launch-only
|
||||
// override); savedOff is the saved choice alone. compositing is Electron's raw
|
||||
// gpu_compositing string, which it documents as usable only after
|
||||
// gpu-info-update and does not enumerate, so it is reported, never interpreted.
|
||||
function graphicsReport(app, { launchedOff, savedOff }) {
|
||||
let compositing = "";
|
||||
try {
|
||||
compositing = String(app.getGPUFeatureStatus().gpu_compositing ?? "");
|
||||
} catch {
|
||||
// Not answerable yet; the page shows only what was applied.
|
||||
}
|
||||
return { launchedOff, savedOff, compositing };
|
||||
}
|
||||
|
||||
function graphicsHandler(app, fromWindow, launch) {
|
||||
return (event) => (fromWindow(event) ? graphicsReport(app, launch) : null);
|
||||
}
|
||||
|
||||
module.exports = { graphicsHandler, PREF_KEY, accelerationOff, shouldDisableGpu, graphicsReport };
|
||||
@@ -17,7 +17,7 @@ const { uiLanguage } = require("./uilang");
|
||||
const { installFullScreenKey } = require("./fullscreen");
|
||||
const { installReload } = require("./reload");
|
||||
const { externalTarget } = require("./links");
|
||||
const { reveal, revealWorkspace } = require("./reveal");
|
||||
const { reveal, revealWorkspace, revealLogs } = require("./reveal");
|
||||
const { appIcon } = require("./appicon");
|
||||
const layout = require("./layout");
|
||||
const { offerCleanup } = require("./legacy");
|
||||
@@ -27,8 +27,11 @@ const { BrowserViews } = require("./browserviews");
|
||||
const { startBrowserRelay } = require("./browserrelay");
|
||||
const { groundFor } = require("./ground");
|
||||
const { loadPrefs, prefsFile, registerPrefs } = require("./prefs");
|
||||
const { stateFile, openWindow } = require("./windowstate");
|
||||
const { accelerationOff, shouldDisableGpu, graphicsHandler } = require("./graphics");
|
||||
const { createPowerGuard, keepsAwake } = require("./powerguard");
|
||||
const { openLogs, redactArgv, failStartup } = require("./shelllog");
|
||||
const { actHandler } = require("./acts");
|
||||
const { crashDir, recordHostExit, pendingHostExit, clearHostExit, hostExitNotice } = require("./hostexit");
|
||||
|
||||
// A page in a minimized or fully covered window counts as hidden, and a hidden
|
||||
@@ -63,6 +66,7 @@ const hostBinary = layout.hostBinary(where);
|
||||
const computerHelper = layout.computerHelper(where);
|
||||
const pageDir = layout.pageDir(where);
|
||||
|
||||
const acts = actHandler({ app, showWindow: () => showWindow() });
|
||||
let kernel = null;
|
||||
let win = null;
|
||||
let origin = "";
|
||||
@@ -126,7 +130,7 @@ async function launchKernel(args) {
|
||||
app.quit();
|
||||
}
|
||||
},
|
||||
onAct: handOver,
|
||||
onAct: (act) => acts(act),
|
||||
});
|
||||
const current = kernel;
|
||||
current.child.on("error", (err) => logs.shell.line(`host: spawn failed: ${err.message}`));
|
||||
@@ -309,28 +313,34 @@ function createWindow() {
|
||||
const mac = process.platform === "darwin";
|
||||
const windows = process.platform === "win32";
|
||||
const chrome = mac || windows;
|
||||
return new BrowserWindow({
|
||||
...fitted(),
|
||||
...appIcon(),
|
||||
minWidth: MIN_SIZE.width,
|
||||
minHeight: MIN_SIZE.height,
|
||||
// Shown once it has been measured against the screen it landed on; sizing a
|
||||
// visible window makes the correction a flicker.
|
||||
show: false,
|
||||
backgroundColor: ground(),
|
||||
frame: !windows,
|
||||
titleBarStyle: mac ? "hiddenInset" : "default",
|
||||
...(mac ? { trafficLightPosition: LIGHTS } : {}),
|
||||
webPreferences: {
|
||||
preload: path.join(__dirname, "preload.js"),
|
||||
// Stated rather than left to the defaults: this is the list a reviewer
|
||||
// reads to know what the renderer may reach.
|
||||
nodeIntegration: false,
|
||||
contextIsolation: true,
|
||||
sandbox: true,
|
||||
webviewTag: false,
|
||||
webSecurity: true,
|
||||
additionalArguments: [`--reasonix-titlebar=${chrome ? "1" : "0"}`],
|
||||
return openWindow({
|
||||
BrowserWindow,
|
||||
screen,
|
||||
file: stateFile(app.getPath("userData")),
|
||||
fallback: fitted(),
|
||||
min: MIN_SIZE,
|
||||
options: {
|
||||
...appIcon(),
|
||||
minWidth: MIN_SIZE.width,
|
||||
minHeight: MIN_SIZE.height,
|
||||
// Shown once it has been measured against the screen it landed on; sizing a
|
||||
// visible window makes the correction a flicker.
|
||||
show: false,
|
||||
backgroundColor: ground(),
|
||||
frame: !windows,
|
||||
titleBarStyle: mac ? "hiddenInset" : "default",
|
||||
...(mac ? { trafficLightPosition: LIGHTS } : {}),
|
||||
webPreferences: {
|
||||
preload: path.join(__dirname, "preload.js"),
|
||||
// Stated rather than left to the defaults: this is the list a reviewer
|
||||
// reads to know what the renderer may reach.
|
||||
nodeIntegration: false,
|
||||
contextIsolation: true,
|
||||
sandbox: true,
|
||||
webviewTag: false,
|
||||
webSecurity: true,
|
||||
additionalArguments: [`--reasonix-titlebar=${chrome ? "1" : "0"}`],
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -363,6 +373,8 @@ nativeTheme.on("updated", () => {
|
||||
if (win && !win.isDestroyed()) win.setBackgroundColor(ground());
|
||||
});
|
||||
|
||||
ipcMain.handle("shell:graphics", (event) => graphicsHandler(app, fromWindow, gpuLaunch)(event));
|
||||
|
||||
ipcMain.handle("window:minimise", (event) => {
|
||||
fromWindow(event)?.minimize();
|
||||
});
|
||||
@@ -403,6 +415,9 @@ ipcMain.handle("shell:open-external", (event, raw) => {
|
||||
ipcMain.handle("shell:reveal-workspace", (event, root) =>
|
||||
fromWindow(event) && client ? revealWorkspace(client, shell, String(root)) : { code: "", error: "no window" },
|
||||
);
|
||||
ipcMain.handle("shell:reveal-logs", (event) =>
|
||||
fromWindow(event) ? revealLogs(shell, logs?.dir) : { error: "no window" },
|
||||
);
|
||||
ipcMain.handle("shell:reveal", (event, base, rel) =>
|
||||
fromWindow(event) && client ? reveal(client, shell, String(base), String(rel)) : { code: "", error: "no window" },
|
||||
);
|
||||
@@ -451,6 +466,11 @@ app.setName("Reasonix Studio");
|
||||
// already running owns those session files.
|
||||
const identity = instanceID(hostBinary);
|
||||
app.setPath("userData", profileFor(app.getPath("userData"), identity));
|
||||
// Chromium decides on the GPU before the app is ready, so this is the last
|
||||
// point a saved choice can still reach it.
|
||||
const savedPrefs = loadPrefs(prefsFile(app.getPath("userData")));
|
||||
const gpuLaunch = { launchedOff: shouldDisableGpu({ prefs: savedPrefs, env: process.env, argv: process.argv }), savedOff: accelerationOff(savedPrefs) };
|
||||
if (gpuLaunch.launchedOff) app.disableHardwareAcceleration();
|
||||
// A launch from a shortcut has no console, so this file is the only place a
|
||||
// failure before the window can be read back from.
|
||||
logs = openLogs(app.getPath("userData"));
|
||||
@@ -509,21 +529,6 @@ async function hostLastWords() {
|
||||
return logs.host.tail();
|
||||
}
|
||||
|
||||
// The acts a handover asks of the application. The kernel decides when: it has
|
||||
// downloaded and staged a replacement, and what is left is the part only this
|
||||
// process can do. They arrive in this order, so arming the restart before
|
||||
// ending is the sequence rather than a coincidence.
|
||||
//
|
||||
// Nothing is answered. Both are performed by ending, so an acknowledgement
|
||||
// would have to come from a process on its way out.
|
||||
function handOver(act) {
|
||||
if (act === "relaunch") {
|
||||
app.relaunch();
|
||||
return;
|
||||
}
|
||||
if (act === "quit") app.quit();
|
||||
}
|
||||
|
||||
// Before the handshake the only window is the starting one, and the launch
|
||||
// decides for itself whether to go on.
|
||||
app.on("window-all-closed", () => {
|
||||
|
||||
@@ -25,11 +25,14 @@ contextBridge.exposeInMainWorld("reasonixHost", {
|
||||
toggleMaximiseWindow: () => ipcRenderer.invoke("window:toggle-maximise"),
|
||||
isWindowMaximised: () => ipcRenderer.invoke("window:is-maximised"),
|
||||
closeWindow: () => ipcRenderer.invoke("window:close"),
|
||||
// What the shell's graphics are running as this launch.
|
||||
graphics: () => ipcRenderer.invoke("shell:graphics"),
|
||||
openExternal: (url) => ipcRenderer.invoke("shell:open-external", String(url)),
|
||||
// A pane and a path inside its workspace; main asks the kernel where that is.
|
||||
revealPath: (base, rel) => ipcRenderer.invoke("shell:reveal", String(base), String(rel)),
|
||||
// A project the sidebar lists, which may have no pane at all.
|
||||
revealWorkspace: (root) => ipcRenderer.invoke("shell:reveal-workspace", String(root)),
|
||||
revealLogs: () => ipcRenderer.invoke("shell:reveal-logs"),
|
||||
// Where a dropped file lives. Resolved here rather than in the page: the
|
||||
// renderer is handed a File and never a path, and a turn that has to work on
|
||||
// the file itself cannot do it on a copy of the bytes.
|
||||
|
||||
@@ -67,4 +67,13 @@ async function revealWorkspace(client, shell, root, platform = process.platform)
|
||||
return null;
|
||||
}
|
||||
|
||||
module.exports = { reveal, revealWorkspace };
|
||||
// The shell's own logs folder. The shell names it, never the page, and it is
|
||||
// opened rather than selected: it is a directory this app created. null means
|
||||
// it was shown.
|
||||
async function revealLogs(shell, dir) {
|
||||
if (!dir) return { error: "the logs folder is unknown" };
|
||||
const failed = await shell.openPath(String(dir));
|
||||
return failed ? { error: failed } : null;
|
||||
}
|
||||
|
||||
module.exports = { reveal, revealWorkspace, revealLogs };
|
||||
@@ -1,5 +1,4 @@
|
||||
"use strict";
|
||||
const { Menu, Tray, nativeImage, screen } = require("electron");
|
||||
const { trayImage } = require("./trayimage.js");
|
||||
|
||||
// How often the fold is re-read. It is a projection the kernel recomputes on
|
||||
@@ -10,18 +9,25 @@ const FOLD_INTERVAL_MS = 5000;
|
||||
// installTray brings the icon up and reports whether there is one. A shell with
|
||||
// no icon must never background its window: hiding it would leave a running
|
||||
// session with nothing left to bring it back.
|
||||
function installTray(host, { onOpen, onQuit }) {
|
||||
function installTray(host, { onOpen, onQuit }, electron = require("electron")) {
|
||||
const { Menu, Tray, nativeImage, screen } = electron;
|
||||
const image = trayImage(nativeImage, screen.getPrimaryDisplay().scaleFactor);
|
||||
if (image.isEmpty()) return null;
|
||||
const icon = new Tray(image);
|
||||
const state = { fold: null, prefs: null };
|
||||
|
||||
const paint = () => {
|
||||
// The menu is rebuilt only when what it says changed. Linux hands the menu to
|
||||
// the desktop's indicator over D-Bus, and replacing it every poll closes one
|
||||
// the person has open or leaves the icon without one.
|
||||
let painted = "";
|
||||
const paint = (force = false) => {
|
||||
// The sentence and the menu words come from the kernel with the numbers:
|
||||
// the language is the desktop's setting, and one surface should not need a
|
||||
// second catalogue to say what the first already said.
|
||||
const line = state.fold?.line ?? "";
|
||||
const words = state.fold?.labels ?? {};
|
||||
const shown = JSON.stringify([line, words, !!state.prefs?.closeToTray]);
|
||||
if (!force && shown === painted) return;
|
||||
icon.setToolTip(line ? `Reasonix Studio — ${line}` : "Reasonix Studio");
|
||||
icon.setContextMenu(
|
||||
Menu.buildFromTemplate([
|
||||
@@ -37,13 +43,16 @@ function installTray(host, { onOpen, onQuit }) {
|
||||
// switch has one implementation whichever control asked.
|
||||
const next = await host.setTrayPrefs(true, !state.prefs?.closeToTray);
|
||||
if (next) state.prefs = next;
|
||||
paint();
|
||||
// The native checkbox has already flipped itself; redraw from state
|
||||
// even when nothing changed, so a refused save puts it back.
|
||||
paint(true);
|
||||
},
|
||||
},
|
||||
{ type: "separator" },
|
||||
{ label: words.quit ?? "Quit", click: onQuit },
|
||||
]),
|
||||
);
|
||||
painted = shown;
|
||||
};
|
||||
|
||||
const refit = () => icon.setImage(trayImage(nativeImage, screen.getPrimaryDisplay().scaleFactor));
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
"use strict";
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
|
||||
// Where the window was when it last closed, so the next launch opens the same
|
||||
// rectangle instead of the default. Only a rectangle some display can still
|
||||
// show is reused: a monitor that has gone leaves the saved position pointing at
|
||||
// nothing, and a frameless window has no title bar to drag back from there.
|
||||
const SAVE_DELAY_MS = 400;
|
||||
const GRAB = { width: 120, height: 40 };
|
||||
|
||||
const stateFile = (userData) => path.join(userData, "window-state.json");
|
||||
|
||||
const whole = (v) => Number.isFinite(v) && Number.isInteger(v);
|
||||
|
||||
function loadState(file) {
|
||||
try {
|
||||
const raw = JSON.parse(fs.readFileSync(file, "utf8"));
|
||||
if (!raw || typeof raw !== "object" || !whole(raw.width) || !whole(raw.height) || raw.width <= 0 || raw.height <= 0) return null;
|
||||
const placed = whole(raw.x) && whole(raw.y);
|
||||
return { ...(placed ? { x: raw.x, y: raw.y } : {}), width: raw.width, height: raw.height, maximized: raw.maximized === true };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Written beside and renamed over, so a crash mid-write leaves the last good copy.
|
||||
function saveState(file, state) {
|
||||
fs.mkdirSync(path.dirname(file), { recursive: true });
|
||||
const tmp = `${file}.tmp`;
|
||||
fs.writeFileSync(tmp, JSON.stringify(state));
|
||||
fs.renameSync(tmp, file);
|
||||
}
|
||||
|
||||
// The strip a person grabs the window by must lie inside some display's work area.
|
||||
function grabbable(saved, areas) {
|
||||
if (saved.x === undefined) return null;
|
||||
return (
|
||||
areas.find((a) => {
|
||||
const across = Math.min(saved.x + saved.width, a.x + a.width) - Math.max(saved.x, a.x);
|
||||
return across >= Math.min(GRAB.width, saved.width) && saved.y >= a.y && saved.y + GRAB.height <= a.y + a.height;
|
||||
}) ?? null
|
||||
);
|
||||
}
|
||||
|
||||
/** placement answers the rectangle to open at. The size is cut to the display
|
||||
* that shows the saved position, and the position is moved in until the whole
|
||||
* window fits; a rectangle nothing shows keeps only its size, cut to the
|
||||
* primary display, and the platform picks the place. The layout's minimum
|
||||
* comes last. */
|
||||
function placement({ saved, areas, primary, fallback, min }) {
|
||||
if (!saved) return { ...fallback, maximized: false };
|
||||
const home = grabbable(saved, areas);
|
||||
const area = home ?? primary;
|
||||
const width = Math.max(min.width, Math.min(saved.width, area.width));
|
||||
const height = Math.max(min.height, Math.min(saved.height, area.height));
|
||||
if (!home) return { width, height, maximized: saved.maximized };
|
||||
const within = (v, lo, span, size) => Math.max(lo, Math.min(v, lo + span - size));
|
||||
return { x: within(saved.x, area.x, area.width, width), y: within(saved.y, area.y, area.height, height), width, height, maximized: saved.maximized };
|
||||
}
|
||||
|
||||
// A minimised or full-screen window reports the screen's rectangle as its normal
|
||||
// bounds on some platforms, so what it says then is not what to come back to.
|
||||
const settled = (win) => !win.isMinimized() && !win.isFullScreen();
|
||||
|
||||
const capture = (win) => ({ ...win.getNormalBounds(), maximized: win.isMaximized() });
|
||||
|
||||
/** track writes the window's rectangle shortly after it settles and once more as
|
||||
* it closes. A minimised or full-screen window keeps its normal bounds. */
|
||||
function track(win, file, { delay = SAVE_DELAY_MS, timer = setTimeout, untimer = clearTimeout } = {}) {
|
||||
let pending = null;
|
||||
const write = () => {
|
||||
pending = null;
|
||||
if (win.isDestroyed() || !settled(win)) return;
|
||||
try {
|
||||
saveState(file, capture(win));
|
||||
} catch {
|
||||
// A rectangle that did not save is one launch at the default size, not a reason to fail the window.
|
||||
}
|
||||
};
|
||||
const later = () => {
|
||||
if (pending) untimer(pending);
|
||||
pending = timer(write, delay);
|
||||
};
|
||||
for (const event of ["resize", "move", "maximize", "unmaximize"]) win.on(event, later);
|
||||
win.on("close", () => {
|
||||
if (pending) untimer(pending);
|
||||
write();
|
||||
});
|
||||
}
|
||||
|
||||
/** openWindow builds the window at its remembered rectangle and keeps that
|
||||
* record current. It is hidden until the caller shows it, so a maximise has
|
||||
* nothing to flicker against. */
|
||||
function openWindow({ BrowserWindow, screen, file, fallback, min, options }) {
|
||||
const { maximized, ...rect } = placement({
|
||||
saved: loadState(file),
|
||||
areas: screen.getAllDisplays().map((d) => d.workArea),
|
||||
primary: screen.getPrimaryDisplay().workArea,
|
||||
fallback,
|
||||
min,
|
||||
});
|
||||
const win = new BrowserWindow({ ...rect, ...options });
|
||||
if (maximized) win.maximize();
|
||||
track(win, file);
|
||||
return win;
|
||||
}
|
||||
|
||||
module.exports = { stateFile, loadState, saveState, placement, track, openWindow, GRAB };
|
||||
@@ -205,7 +205,7 @@ test("an unreachable kernel is an answer, not a crash", async () => {
|
||||
assert.equal(await dead.trayState(), null);
|
||||
});
|
||||
|
||||
const { reveal, revealWorkspace } = require("../src/reveal.js");
|
||||
const { reveal, revealWorkspace, revealLogs } = require("../src/reveal.js");
|
||||
|
||||
// A kernel that answers /workspace/locate as the test says, and a shell that
|
||||
// only records what it was asked to open.
|
||||
@@ -1722,6 +1722,57 @@ test("the window ground follows the stored theme and, for auto, the system", ()
|
||||
assert.equal(groundFor(undefined, true), GROUND.dark);
|
||||
});
|
||||
|
||||
const { accelerationOff, shouldDisableGpu, graphicsReport, graphicsHandler, PREF_KEY: GPU_PREF } = require("../src/graphics.js");
|
||||
|
||||
test("hardware acceleration is off only when the saved preference says exactly that", () => {
|
||||
assert.equal(GPU_PREF, "rx-hw-accel");
|
||||
assert.equal(accelerationOff({}), false);
|
||||
assert.equal(accelerationOff(undefined), false);
|
||||
assert.equal(accelerationOff({ "rx-hw-accel": "on" }), false);
|
||||
assert.equal(accelerationOff({ "rx-hw-accel": "" }), false);
|
||||
assert.equal(accelerationOff({ "rx-hw-accel": "off" }), true);
|
||||
});
|
||||
|
||||
test("the graphics report says what this launch applied and passes Electron's status through untouched", () => {
|
||||
const app = (compositing) => ({ getGPUFeatureStatus: () => ({ gpu_compositing: compositing }) });
|
||||
const auto = { launchedOff: false, savedOff: false };
|
||||
for (const value of ["enabled", "enabled_force", "enabled_on", "disabled_software", "unavailable_off", ""]) {
|
||||
assert.deepEqual(graphicsReport(app(value), auto), { ...auto, compositing: value });
|
||||
}
|
||||
assert.deepEqual(graphicsReport(app("disabled_software"), { launchedOff: true, savedOff: true }), { launchedOff: true, savedOff: true, compositing: "disabled_software" });
|
||||
assert.deepEqual(graphicsReport(app("enabled"), { launchedOff: true, savedOff: false }), { launchedOff: true, savedOff: false, compositing: "enabled" });
|
||||
assert.deepEqual(graphicsReport({ getGPUFeatureStatus: () => { throw new Error("not ready"); } }, auto), { ...auto, compositing: "" });
|
||||
assert.deepEqual(graphicsReport({ getGPUFeatureStatus: () => ({}) }, auto), { ...auto, compositing: "" });
|
||||
});
|
||||
|
||||
test("the GPU is disabled by the saved preference, the environment or the flag, and only then", () => {
|
||||
const off = (over) => shouldDisableGpu({ prefs: {}, env: {}, argv: [], ...over });
|
||||
assert.equal(off({}), false);
|
||||
assert.equal(off({ prefs: { "rx-hw-accel": "off" } }), true);
|
||||
assert.equal(off({ prefs: { "rx-hw-accel": "on" } }), false);
|
||||
assert.equal(off({ prefs: undefined }), false);
|
||||
assert.equal(off({ env: { REASONIX_DISABLE_GPU: "1" } }), true);
|
||||
assert.equal(off({ env: { REASONIX_DISABLE_GPU: "0" } }), false);
|
||||
assert.equal(off({ argv: ["studio", "--disable-gpu"] }), true);
|
||||
assert.equal(off({ argv: ["--disable-gpu-sandbox"] }), false);
|
||||
});
|
||||
|
||||
test("a prefs file that is missing or corrupt keeps hardware acceleration", () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "gpu-"));
|
||||
const file = path.join(dir, "window-prefs.json");
|
||||
assert.equal(shouldDisableGpu({ prefs: loadPrefs(file), env: {}, argv: [] }), false);
|
||||
fs.writeFileSync(file, "{ not json");
|
||||
assert.equal(shouldDisableGpu({ prefs: loadPrefs(file), env: {}, argv: [] }), false);
|
||||
});
|
||||
|
||||
test("the graphics report is refused to any sender that is not the Studio window", () => {
|
||||
const app = { getGPUFeatureStatus: () => ({ gpu_compositing: "enabled" }) };
|
||||
const win = { id: "studio" };
|
||||
const handle = graphicsHandler(app, (event) => (event.sender === "studio" ? win : null), { launchedOff: true, savedOff: false });
|
||||
assert.deepEqual(handle({ sender: "studio" }), { launchedOff: true, savedOff: false, compositing: "enabled" });
|
||||
assert.equal(handle({ sender: "agent-browser-page" }), null);
|
||||
});
|
||||
|
||||
test("a kernel that dies after its handshake leaves its exit and last words in shell.log and a marker for the next launch", () => {
|
||||
const { openLogs } = require("../src/shelllog.js");
|
||||
const { crashDir, recordHostExit, pendingHostExit, clearHostExit } = require("../src/hostexit.js");
|
||||
@@ -1772,3 +1823,279 @@ test("the notice names where the logs are in both languages and shows how it end
|
||||
assert.match(en.detail, /code 2/);
|
||||
assert.match(zh.detail, /2/);
|
||||
});
|
||||
|
||||
test("packaging: an rpm fpm spelled x86_64 leaves the build as the release arch", async () => {
|
||||
const arch = require("../packaging/arch.js");
|
||||
const hook = require("../packaging/canonical-artifact-names.js");
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rpm-name-"));
|
||||
const deb = path.join(dir, "ReasonixStudio-linux-amd64.deb");
|
||||
const rpm = path.join(dir, "ReasonixStudio-linux-x86_64.rpm");
|
||||
fs.writeFileSync(deb, "d");
|
||||
fs.writeFileSync(rpm, "r");
|
||||
arch.record(deb, "x64");
|
||||
arch.record(rpm, "x64");
|
||||
const out = await hook({ artifactPaths: [deb, rpm] });
|
||||
assert.deepEqual(out.map((f) => path.basename(f)), ["ReasonixStudio-linux-amd64.deb", "ReasonixStudio-linux-amd64.rpm"]);
|
||||
assert.ok(fs.existsSync(path.join(dir, "ReasonixStudio-linux-amd64.rpm")));
|
||||
assert.ok(!fs.existsSync(rpm));
|
||||
});
|
||||
|
||||
test("packaging: a name still carrying an fpm spelling is refused", async () => {
|
||||
const arch = require("../packaging/arch.js");
|
||||
const hook = require("../packaging/canonical-artifact-names.js");
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rpm-name-"));
|
||||
const stray = path.join(dir, "ReasonixStudio-linux-aarch64.rpm");
|
||||
fs.writeFileSync(stray, "r");
|
||||
arch.record(stray, "x64");
|
||||
await assert.rejects(hook({ artifactPaths: [stray] }), /aarch64/);
|
||||
});
|
||||
|
||||
test("packaging: a Windows installer named for x64 is renamed to amd64", async () => {
|
||||
const arch = require("../packaging/arch.js");
|
||||
const hook = require("../packaging/canonical-artifact-names.js");
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "win-name-"));
|
||||
const installer = path.join(dir, "ReasonixStudio-windows-x64-installer.exe");
|
||||
const zip = path.join(dir, "ReasonixStudio-windows-x64.zip");
|
||||
for (const f of [installer, zip]) {
|
||||
fs.writeFileSync(f, "x");
|
||||
arch.record(f, "x64");
|
||||
}
|
||||
const out = await hook({ artifactPaths: [installer, zip] });
|
||||
assert.deepEqual(
|
||||
out.map((f) => path.basename(f)),
|
||||
["ReasonixStudio-windows-amd64-installer.exe", "ReasonixStudio-windows-amd64.zip"],
|
||||
);
|
||||
});
|
||||
|
||||
const { stateFile, loadState, saveState, placement, track } = require("../src/windowstate.js");
|
||||
const { EventEmitter } = require("node:events");
|
||||
|
||||
const AREA = { x: 0, y: 0, width: 1920, height: 1040 };
|
||||
const SECOND = { x: 1920, y: 0, width: 1280, height: 1000 };
|
||||
const MIN = { width: 760, height: 480 };
|
||||
const FALLBACK = { width: 1440, height: 900 };
|
||||
const open = (saved, areas = [AREA, SECOND]) => placement({ saved, areas, primary: areas[0], fallback: FALLBACK, min: MIN });
|
||||
|
||||
test("window state: only a whole rectangle is read back, and it survives a round trip", () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "winstate-"));
|
||||
const file = stateFile(dir);
|
||||
assert.equal(loadState(file), null);
|
||||
fs.writeFileSync(file, "{not json");
|
||||
assert.equal(loadState(file), null);
|
||||
for (const bad of [{ width: 0, height: 500 }, { width: "900", height: 500 }, { width: 900.5, height: 500 }, [], null]) {
|
||||
fs.writeFileSync(file, JSON.stringify(bad));
|
||||
assert.equal(loadState(file), null, JSON.stringify(bad));
|
||||
}
|
||||
saveState(file, { x: 40, y: 60, width: 1000, height: 700, maximized: true });
|
||||
assert.deepEqual(loadState(file), { x: 40, y: 60, width: 1000, height: 700, maximized: true });
|
||||
fs.writeFileSync(file, JSON.stringify({ width: 1000, height: 700 }));
|
||||
assert.deepEqual(loadState(file), { width: 1000, height: 700, maximized: false });
|
||||
});
|
||||
|
||||
test("window state: a first launch opens at the default size and a saved rectangle is reopened as it was", () => {
|
||||
assert.deepEqual(open(null), { ...FALLBACK, maximized: false });
|
||||
const saved = { x: 300, y: 120, width: 1100, height: 760, maximized: false };
|
||||
assert.deepEqual(open(saved), saved);
|
||||
assert.deepEqual(open({ ...saved, x: 2100, y: 100 }), { ...saved, x: 2100, y: 100 });
|
||||
});
|
||||
|
||||
test("window state: a rectangle no display shows keeps its size and loses its place", () => {
|
||||
const gone = { x: 2400, y: 100, width: 1100, height: 760, maximized: false };
|
||||
assert.deepEqual(open(gone, [AREA]), { width: 1100, height: 760, maximized: false });
|
||||
const above = { x: 300, y: -500, width: 1100, height: 760, maximized: false };
|
||||
assert.deepEqual(open(above, [AREA]), { width: 1100, height: 760, maximized: false });
|
||||
const huge = { x: 5000, y: 0, width: 4000, height: 3000, maximized: true };
|
||||
assert.deepEqual(open(huge, [AREA]), { width: 1920, height: 1040, maximized: true });
|
||||
});
|
||||
|
||||
test("window state: a saved size is raised to the layout's floor", () => {
|
||||
const small = open({ x: 10, y: 10, width: 300, height: 200, maximized: false });
|
||||
assert.deepEqual([small.width, small.height], [760, 480]);
|
||||
});
|
||||
|
||||
function fakeWindow(normal) {
|
||||
const win = new EventEmitter();
|
||||
Object.assign(win, { maximized: false, destroyed: false, normal });
|
||||
win.getNormalBounds = () => win.normal;
|
||||
win.isMaximized = () => win.maximized;
|
||||
win.isDestroyed = () => win.destroyed;
|
||||
win.minimized = false;
|
||||
win.fullscreen = false;
|
||||
win.isMinimized = () => win.minimized;
|
||||
win.isFullScreen = () => win.fullscreen;
|
||||
return win;
|
||||
}
|
||||
|
||||
test("window state: moves are written once they settle, a close is written at once, and a maximised window keeps its normal bounds", () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "winstate-"));
|
||||
const file = stateFile(dir);
|
||||
const timers = [];
|
||||
const win = fakeWindow({ x: 10, y: 20, width: 900, height: 600 });
|
||||
track(win, file, { timer: (fn) => timers.push(fn) - 1, untimer: (id) => (timers[id] = () => {}) });
|
||||
win.emit("resize");
|
||||
win.emit("move");
|
||||
win.emit("resize");
|
||||
assert.equal(loadState(file), null);
|
||||
timers.at(-1)();
|
||||
assert.deepEqual(loadState(file), { x: 10, y: 20, width: 900, height: 600, maximized: false });
|
||||
win.normal = { x: 50, y: 60, width: 1000, height: 650 };
|
||||
win.maximized = true;
|
||||
win.emit("close");
|
||||
assert.deepEqual(loadState(file), { x: 50, y: 60, width: 1000, height: 650, maximized: true });
|
||||
win.destroyed = true;
|
||||
win.normal = { x: 1, y: 1, width: 5, height: 5 };
|
||||
win.emit("move");
|
||||
timers.at(-1)();
|
||||
assert.equal(loadState(file).width, 1000);
|
||||
});
|
||||
|
||||
test("window state: a minimised or full-screen close keeps the last settled record", () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "winstate-"));
|
||||
const file = stateFile(dir);
|
||||
const win = fakeWindow({ x: 10, y: 20, width: 900, height: 600 });
|
||||
track(win, file, { timer: () => 0, untimer: () => {} });
|
||||
win.emit("close");
|
||||
const kept = loadState(file);
|
||||
win.normal = { x: 0, y: 0, width: 1920, height: 1040 };
|
||||
win.minimized = true;
|
||||
win.emit("close");
|
||||
assert.deepEqual(loadState(file), kept);
|
||||
win.minimized = false;
|
||||
win.fullscreen = true;
|
||||
win.emit("close");
|
||||
assert.deepEqual(loadState(file), kept);
|
||||
});
|
||||
|
||||
test("window state: a saved size is cut to the display it opens on and the window is moved in", () => {
|
||||
const big = { x: 1700, y: 600, width: 1400, height: 900, maximized: false };
|
||||
const out = open(big, [AREA]);
|
||||
assert.deepEqual([out.width, out.height], [1400, 900].map((v, i) => Math.min(v, [1920, 1040][i])));
|
||||
assert.ok(out.x + out.width <= 1920 && out.y + out.height <= 1040, JSON.stringify(out));
|
||||
const small = { x: 100, y: 100, width: 1800, height: 1000, maximized: false };
|
||||
const cut = open(small, [{ x: 0, y: 0, width: 1366, height: 728 }]);
|
||||
assert.deepEqual(cut, { x: 0, y: 0, width: 1366, height: 728, maximized: false });
|
||||
});
|
||||
|
||||
test("kernel acts: show brings the window forward, relaunch arms the restart, quit ends, anything else is ignored", () => {
|
||||
const { actHandler } = require("../src/acts.js");
|
||||
const calls = [];
|
||||
const handle = actHandler({
|
||||
app: { relaunch: () => calls.push("relaunch"), quit: () => calls.push("quit") },
|
||||
showWindow: () => calls.push("show"),
|
||||
});
|
||||
for (const act of ["show", "relaunch", "quit", "unknown"]) handle(act);
|
||||
assert.deepEqual(calls, ["show", "relaunch", "quit"]);
|
||||
});
|
||||
|
||||
test("tray: the menu is replaced only when what it says changes, so an open menu is not torn down by the poll", async () => {
|
||||
const { installTray } = require("../src/tray.js");
|
||||
const menus = [];
|
||||
let tips = 0;
|
||||
class FakeTray {
|
||||
on() {}
|
||||
setImage() {}
|
||||
setToolTip() { tips++; }
|
||||
setContextMenu(menu) { menus.push(menu); }
|
||||
destroy() {}
|
||||
}
|
||||
const electron = {
|
||||
Menu: { buildFromTemplate: (template) => ({ template }) },
|
||||
Tray: FakeTray,
|
||||
nativeImage: { createFromPath: () => ({ isEmpty: () => false }), createFromBuffer: () => ({ isEmpty: () => false }) },
|
||||
screen: { getPrimaryDisplay: () => ({ scaleFactor: 1 }), on() {}, removeListener() {} },
|
||||
};
|
||||
let fold = { line: "1 running", labels: { open: "Open", quit: "Quit", closeToTray: "Keep running" } };
|
||||
const host = { trayPrefs: async () => ({ closeToTray: false }), trayState: async () => fold, setTrayPrefs: async () => ({ closeToTray: true }) };
|
||||
const tray = installTray(host, { onOpen() {}, onQuit() {} }, electron);
|
||||
await tray.refresh();
|
||||
await tray.refresh();
|
||||
await tray.refresh();
|
||||
assert.equal(menus.length, 1, "three identical reads rebuilt the menu");
|
||||
assert.equal(tips, 1);
|
||||
fold = { ...fold, line: "2 running" };
|
||||
await tray.refresh();
|
||||
assert.equal(menus.length, 2);
|
||||
assert.equal(menus[1].template.find((item) => item.enabled === false).label, "2 running");
|
||||
tray.close();
|
||||
});
|
||||
|
||||
test("tray: a refused close-to-tray save redraws the checkbox, and a menu that failed to draw is tried again", async () => {
|
||||
const { installTray } = require("../src/tray.js");
|
||||
const menus = [];
|
||||
let failNext = false;
|
||||
class FakeTray {
|
||||
on() {}
|
||||
setImage() {}
|
||||
setToolTip() {}
|
||||
setContextMenu(menu) { if (failNext) { failNext = false; throw new Error("indicator busy"); } menus.push(menu); }
|
||||
destroy() {}
|
||||
}
|
||||
const electron = {
|
||||
Menu: { buildFromTemplate: (template) => ({ template }) },
|
||||
Tray: FakeTray,
|
||||
nativeImage: { createFromPath: () => ({ isEmpty: () => false }), createFromBuffer: () => ({ isEmpty: () => false }) },
|
||||
screen: { getPrimaryDisplay: () => ({ scaleFactor: 1 }), on() {}, removeListener() {} },
|
||||
};
|
||||
const fold = { line: "idle", labels: { open: "Open", quit: "Quit", closeToTray: "Keep running" } };
|
||||
const host = { trayPrefs: async () => ({ closeToTray: false }), trayState: async () => fold, setTrayPrefs: async () => null };
|
||||
const tray = installTray(host, { onOpen() {}, onQuit() {} }, electron);
|
||||
failNext = true;
|
||||
await assert.rejects(tray.refresh());
|
||||
await tray.refresh();
|
||||
assert.equal(menus.length, 1, "a menu whose draw threw was never retried");
|
||||
const toggle = menus[0].template.find((item) => item.type === "checkbox");
|
||||
await toggle.click();
|
||||
assert.equal(menus.length, 2, "a refused save left the flipped checkbox on screen");
|
||||
assert.equal(menus[1].template.find((item) => item.type === "checkbox").checked, false);
|
||||
tray.close();
|
||||
});
|
||||
|
||||
test("revealLogs opens only the folder the shell itself names, and reports why it could not", async () => {
|
||||
const opened = [];
|
||||
const shell = { openPath: async (target) => (opened.push(target), "") };
|
||||
assert.equal(await revealLogs(shell, "/user/logs"), null);
|
||||
assert.deepEqual(opened, ["/user/logs"]);
|
||||
const refusing = { openPath: async () => "no application is associated with this folder" };
|
||||
assert.deepEqual(await revealLogs(refusing, "/user/logs"), { error: "no application is associated with this folder" });
|
||||
assert.deepEqual(await revealLogs(shell, ""), { error: "the logs folder is unknown" });
|
||||
});
|
||||
|
||||
// The built-in browser drew a white page after switching away from a tab and
|
||||
// back until the window was resized: a parked view moved onscreen with
|
||||
// setBounds alone does not repaint. Bringing it back attaches it again.
|
||||
test("a parked page is attached again when it is shown, and a page already on screen is left alone", () => {
|
||||
const Module = require("node:module");
|
||||
const load = Module._load;
|
||||
Module._load = function (request, ...rest) {
|
||||
if (request === "electron") return { WebContentsView: class {}, session: { fromPartition: () => ({}) } };
|
||||
return load.call(this, request, ...rest);
|
||||
};
|
||||
let BrowserViews;
|
||||
try {
|
||||
({ BrowserViews } = require("../src/browserviews.js"));
|
||||
} finally {
|
||||
Module._load = load;
|
||||
}
|
||||
const calls = [];
|
||||
const win = {
|
||||
on() {},
|
||||
isDestroyed: () => false,
|
||||
getContentBounds: () => ({ width: 1200, height: 800 }),
|
||||
contentView: { addChildView: () => calls.push("add"), removeChildView: () => calls.push("remove") },
|
||||
};
|
||||
const views = new BrowserViews({ win, kernelOrigin: "http://127.0.0.1:1" });
|
||||
const view = { setBounds: (b) => calls.push("bounds " + b.x), webContents: { isDestroyed: () => false } };
|
||||
views.entries.set("t1", { view, at: null });
|
||||
const rect = { x: 10, y: 20, width: 600, height: 400 };
|
||||
|
||||
views.show("t1", rect);
|
||||
assert.deepEqual(calls, ["remove", "add", "bounds 10"]);
|
||||
calls.length = 0;
|
||||
views.show("t1", rect);
|
||||
assert.deepEqual(calls, [], "the same rectangle on a page already shown does no work");
|
||||
views.hide();
|
||||
calls.length = 0;
|
||||
views.show("t1", rect);
|
||||
assert.deepEqual(calls, ["remove", "add", "bounds 10"], "a page put away and shown again is attached again");
|
||||
});
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
"use strict";
|
||||
// Opens, moves, maximises and closes real windows through the shell's own openWindow, then reopens them: the
|
||||
// rectangle a window had when it closed is the one the next launch opens at. Needs no kernel and no network.
|
||||
// Run with `pnpm windowstate-live`.
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const { app, BrowserWindow, screen } = require("electron");
|
||||
const { stateFile, openWindow } = require("../src/windowstate");
|
||||
|
||||
const wait = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
const fails = [];
|
||||
const check = (name, ok, got) => {
|
||||
console.log((ok ? "ok " : "FAIL ") + name + (ok ? "" : " — " + JSON.stringify(got)));
|
||||
if (!ok) fails.push(name);
|
||||
};
|
||||
|
||||
const file = stateFile(fs.mkdtempSync(path.join(os.tmpdir(), "windowstate-live-")));
|
||||
const launch = () =>
|
||||
openWindow({
|
||||
BrowserWindow,
|
||||
screen,
|
||||
file,
|
||||
fallback: { width: 1000, height: 640 },
|
||||
min: { width: 400, height: 300 },
|
||||
options: { show: true, minWidth: 400, minHeight: 300 },
|
||||
});
|
||||
const closeOne = async (win) => {
|
||||
win.close();
|
||||
await wait(100);
|
||||
};
|
||||
|
||||
// Closing the last window would otherwise end the run between launches.
|
||||
app.on("window-all-closed", () => {});
|
||||
app.whenReady().then(async () => {
|
||||
const first = launch();
|
||||
await wait(300);
|
||||
check("a first launch opens at the default size", first.getBounds().width === 1000 && first.getBounds().height === 640, first.getBounds());
|
||||
const area = screen.getPrimaryDisplay().workArea;
|
||||
const rect = { x: area.x + 60, y: area.y + 50, width: 820, height: 560 };
|
||||
first.setBounds(rect);
|
||||
await wait(700);
|
||||
await closeOne(first);
|
||||
const kept = JSON.parse(fs.readFileSync(file, "utf8"));
|
||||
check("closing records the rectangle", kept.width === rect.width && kept.height === rect.height && kept.x === rect.x && kept.y === rect.y && kept.maximized === false, kept);
|
||||
|
||||
const second = launch();
|
||||
await wait(300);
|
||||
const got = second.getBounds();
|
||||
check("the next launch reopens at that rectangle", got.x === rect.x && got.y === rect.y && got.width === rect.width && got.height === rect.height, got);
|
||||
|
||||
second.maximize();
|
||||
await wait(700);
|
||||
await closeOne(second);
|
||||
const maxed = JSON.parse(fs.readFileSync(file, "utf8"));
|
||||
check("a maximised close keeps the rectangle it will restore to", maxed.maximized === true && maxed.width === rect.width && maxed.height === rect.height, maxed);
|
||||
|
||||
const third = launch();
|
||||
await wait(500);
|
||||
check("the next launch is maximised again", third.isMaximized() === true, third.getBounds());
|
||||
third.unmaximize();
|
||||
await wait(600);
|
||||
const restored = third.getBounds();
|
||||
check("and restores to the rectangle it had before", restored.width === rect.width && restored.height === rect.height, restored);
|
||||
await closeOne(third);
|
||||
|
||||
const fourth = launch();
|
||||
await wait(400);
|
||||
fourth.maximize();
|
||||
await wait(900);
|
||||
fourth.minimize();
|
||||
await wait(900);
|
||||
await closeOne(fourth);
|
||||
const minimised = JSON.parse(fs.readFileSync(file, "utf8"));
|
||||
check("closing while minimised keeps the maximised record and its restore size", minimised.maximized === true && minimised.width === rect.width && minimised.height === rect.height, minimised);
|
||||
|
||||
fs.writeFileSync(file, JSON.stringify({ x: area.x + 40, y: area.y + 40, width: area.width + 800, height: area.height + 600, maximized: false }));
|
||||
const big = launch();
|
||||
await wait(400);
|
||||
const o = big.getBounds();
|
||||
check("a saved size larger than the display is cut to it and kept on screen", o.width <= area.width && o.height <= area.height && o.x >= area.x && o.y >= area.y && o.x + o.width <= area.x + area.width && o.y + o.height <= area.y + area.height, o);
|
||||
await closeOne(big);
|
||||
|
||||
fs.writeFileSync(file, JSON.stringify({ x: area.x + area.width + 5000, y: 10, width: 700, height: 500, maximized: false }));
|
||||
const lost = launch();
|
||||
await wait(300);
|
||||
const b = lost.getBounds();
|
||||
const shown = screen.getAllDisplays().some((d) => b.x >= d.workArea.x && b.x < d.workArea.x + d.workArea.width && b.y >= d.workArea.y);
|
||||
check("a rectangle on a display that is gone opens on one that is there", shown && b.width === 700, b);
|
||||
await closeOne(lost);
|
||||
|
||||
console.log(fails.length ? `${fails.length} failed` : "all checks passed");
|
||||
app.exit(fails.length ? 1 : 0);
|
||||
});
|
||||
@@ -25,13 +25,13 @@
|
||||
"@codemirror/lang-sql": "^6.10.0",
|
||||
"@codemirror/lang-xml": "^6.1.0",
|
||||
"@codemirror/lang-yaml": "^6.1.3",
|
||||
"@codemirror/language": "^6.12.4",
|
||||
"@codemirror/language": "^6.13.1",
|
||||
"@codemirror/search": "^6.7.2",
|
||||
"@codemirror/state": "^6.7.6",
|
||||
"@codemirror/view": "^6.43.13",
|
||||
"@codemirror/view": "^6.43.14",
|
||||
"@lezer/highlight": "^1.2.5",
|
||||
"codemirror": "^6.0.2",
|
||||
"katex": "^0.18.10",
|
||||
"katex": "^0.19.0",
|
||||
"react": "^19.3.0",
|
||||
"react-dom": "^19.3.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
@@ -44,16 +44,16 @@
|
||||
"remark-math": "^6.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/parser": "^8.0.6",
|
||||
"@babel/parser": "^8.0.7",
|
||||
"@testing-library/react": "^16.3.3",
|
||||
"@testing-library/user-event": "^14.6.7",
|
||||
"@types/react": "^19.3.0",
|
||||
"@types/react-dom": "^19.3.0",
|
||||
"@vitejs/plugin-react": "^6.1.1",
|
||||
"jsdom": "^30.1.1",
|
||||
"playwright": "^1.63.0",
|
||||
"@vitejs/plugin-react": "^6.1.2",
|
||||
"jsdom": "^30.1.2",
|
||||
"playwright": "^1.64.0",
|
||||
"typescript": "^7.0.2",
|
||||
"vite": "^8.3.2",
|
||||
"vite": "^8.3.3",
|
||||
"vitest": "^5.0.3"
|
||||
}
|
||||
}
|
||||
@@ -68,6 +68,13 @@ node perf/find.mjs # 对话内查找:快捷键穿过输入框、跳到
|
||||
node perf/focus.mjs # 专注的守卫:拿走外围之后,退出时世界是否原样
|
||||
node perf/tooltip.mjs # 控件提示的守卫:文字不溢出提示框、不在半透明祖先之下、对比度达 AA(深浅×中英×宽窄)
|
||||
node perf/navhint.mjs # Icon-rail hover hints: drawn, inside the window, clipped by no ancestor, painted over by nothing (light/dark × on/collapsed)
|
||||
node perf/runningtool.mjs # 还没输出的运行中调用:行能展开、写明仍在运行与已运行多久、写出命令自带的超时
|
||||
node perf/gutterhit.mjs # 分栏拖拽手柄不压在相邻滚动条上:滚动条拇指处按下命中的不是手柄,握把仍可拖开收起的栏
|
||||
node perf/dock.mjs # 停靠的浏览器列从 pane 顶部起、与导航行并排,取消停靠后行归还(浅/深 × 宽/窄 × 首开/重载)
|
||||
node perf/signals.mjs # 运行分析的信号轨说明:它是什么、为什么标记不能拖(浅/深 × 宽/手机)
|
||||
node perf/narrow.mjs # 窄窗口 + 1.8 倍缩放:标题栏与输入区的行互不重叠、不越出窗口(浅/深 × 390/320 × 1.8/1.3,另加宽窗口对照)
|
||||
node perf/sidebar.mjs # 侧栏开关只有一个且位置不变(图标栏 开/仅收起时/关 × 浅/深);同一模型连续回复只在首条带头
|
||||
node perf/start.mjs # 经工作台标签/边缘把手打开工作台先出入口页(文件/改动/浏览器),不替人开浏览器;标题栏 Browser 按钮仍直达浏览器(浅/深 × 宽/窄)
|
||||
```
|
||||
|
||||
`lang.mjs` 从仓库外跑时用 `PERF_SRC` 指向 `src/`(它要读固件源码来决定
|
||||
|
||||
@@ -165,7 +165,7 @@ const edges = () => col.evaluate(() => {
|
||||
};
|
||||
});
|
||||
const setMeasure = (full) => col.evaluate((f) => {
|
||||
if (f) document.documentElement.dataset.measure = "full";
|
||||
if (f) document.documentElement.dataset.measure = typeof f === "string" ? f : "full";
|
||||
else delete document.documentElement.dataset.measure;
|
||||
}, full);
|
||||
const composeAt = {};
|
||||
@@ -197,6 +197,31 @@ check("全宽:转录比输入框宽", full.flow.r - full.flow.l > full.compose
|
||||
check("全宽:卡片跟着转录到边", full.boxed.every((x) => Math.abs(x.l - full.flow.l) <= 1 && Math.abs(x.r - full.flow.r) <= 1),
|
||||
`转录 ${full.flow.l}–${full.flow.r},卡片 ${JSON.stringify(full.boxed)}`);
|
||||
|
||||
await col.setViewportSize({ width: 1440, height: 900 });
|
||||
await setMeasure("full-all");
|
||||
await col.waitForTimeout(300);
|
||||
const fullAll = await edges();
|
||||
for (const width of [1000, 720]) {
|
||||
await col.setViewportSize({ width, height: 900 });
|
||||
await setMeasure("full-all");
|
||||
await col.waitForTimeout(300);
|
||||
const n = await edges();
|
||||
const inside = await col.evaluate(() => {
|
||||
const b = document.querySelector(".compose").getBoundingClientRect();
|
||||
const pane = document.querySelector(".compose").parentElement.getBoundingClientRect();
|
||||
return { left: Math.round(b.left - pane.left), right: Math.round(pane.right - b.right), w: innerWidth };
|
||||
});
|
||||
check(`${width}px 连输入框一起铺满:输入框不超出所在栏`, inside.left >= 0 && inside.right >= 0, `左 ${inside.left},右 ${inside.right}`);
|
||||
check(`${width}px 连输入框一起铺满:输入框与转录左右对齐(容差一个槽)`, Math.abs(n.compose.l - n.flow.l) <= 40 && Math.abs(n.compose.r - n.flow.r) <= 40,
|
||||
`转录 ${n.flow.l}–${n.flow.r},输入框 ${n.compose.l}–${n.compose.r}`);
|
||||
}
|
||||
await col.setViewportSize({ width: 1440, height: 900 });
|
||||
await setMeasure(false);
|
||||
check("连输入框一起铺满:输入框与转录同宽", Math.abs(fullAll.compose.l - fullAll.flow.l) <= 40 && Math.abs(fullAll.compose.r - fullAll.flow.r) <= 40,
|
||||
`转录 ${fullAll.flow.l}–${fullAll.flow.r},输入框 ${fullAll.compose.l}–${fullAll.compose.r}`);
|
||||
check("连输入框一起铺满:输入框比适宜阅读宽", fullAll.compose.r - fullAll.compose.l > composeAt[1440].r - composeAt[1440].l + 100,
|
||||
`适宜阅读 ${composeAt[1440].r - composeAt[1440].l}px,铺满 ${fullAll.compose.r - fullAll.compose.l}px`);
|
||||
|
||||
await browser.close();
|
||||
if (fails.length) {
|
||||
console.error(`\n${fails.length} 项不合格:\n ` + fails.join("\n "));
|
||||
|
||||
@@ -146,6 +146,49 @@ for (const state of ["idle", "running"]) {
|
||||
}
|
||||
check("扫到的对话列覆盖 360–700px", [...swept].some((w) => w <= 360) && [...swept].some((w) => w >= 700), [...swept].sort((a, b) => a - b).slice(0, 1).concat([...swept].sort((a, b) => b - a).slice(0, 1)).join("–"));
|
||||
|
||||
// An untouched phone composer leaves room to read; focus or a draft restores
|
||||
// the editing controls without replacing the textarea or its caret.
|
||||
const phone = () => page.evaluate(() => {
|
||||
const box = document.querySelector('textarea[role="combobox"]');
|
||||
const shown = (selector) => document.querySelector(selector)?.getBoundingClientRect().height > 0;
|
||||
const tools = [...document.querySelectorAll(".turntools > *")]
|
||||
.filter((el) => el.getBoundingClientRect().height > 0);
|
||||
const rows = new Set(tools.map((el) => {
|
||||
const r = el.getBoundingClientRect();
|
||||
return Math.round((r.top + r.height / 2) / 2);
|
||||
}));
|
||||
return { height: box.getBoundingClientRect().height, line: parseFloat(getComputedStyle(box).lineHeight),
|
||||
refine: shown(".studio-refine"), branch: shown(".studio-branch-pop"), rows: rows.size };
|
||||
});
|
||||
await page.fill(BOX, "");
|
||||
await page.evaluate(() => { localStorage.clear(); sessionStorage.clear(); });
|
||||
await page.setViewportSize({ width: 390, height: 760 });
|
||||
await page.goto(PAGE, { waitUntil: "networkidle" });
|
||||
await page.waitForSelector(BOX);
|
||||
await page.click('[role="tab"][aria-selected="true"]');
|
||||
await frame();
|
||||
const quiet = await phone();
|
||||
check("手机:空置输入只占一行", quiet.height <= quiet.line + 1);
|
||||
check("手机:空置时收起优化提示,分支提示仍在", !quiet.refine && quiet.branch);
|
||||
check("手机:空置工具栏只占一行", quiet.rows === 1);
|
||||
await page.click(BOX);
|
||||
await frame();
|
||||
const focused = await phone();
|
||||
check("手机:聚焦恢复编辑空间与优化入口", focused.height > quiet.height && focused.refine);
|
||||
await page.fill(BOX, "保留草稿\n第二行\n第三行");
|
||||
await page.click('[role="tab"][aria-selected="true"]');
|
||||
await frame();
|
||||
const draft = await phone();
|
||||
check("手机:未聚焦草稿仍展开", draft.height >= draft.line * 3 - 1 && draft.refine);
|
||||
check("手机:展开后草稿原样保留", await page.inputValue(BOX) === "保留草稿\n第二行\n第三行");
|
||||
await page.fill(BOX, "");
|
||||
await page.getByRole("button", { name: "引用到输入框", exact: true }).last().click();
|
||||
await page.waitForSelector(".shots");
|
||||
await page.click('[role="tab"][aria-selected="true"]');
|
||||
await frame();
|
||||
const quoted = await phone();
|
||||
check("手机:仅有引用附件也保留编辑空间", quoted.height > quiet.height && quoted.refine);
|
||||
|
||||
await browser.close();
|
||||
if (fails.length) {
|
||||
console.error(`\n${fails.length} 项不合格:\n ` + fails.join("\n "));
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
// A docked browser column starts at the top of the pane, beside the navigation row, and gives the rows back when it
|
||||
// is undocked. Where things start is a layout question, so it is asked of a real browser.
|
||||
import { chromium } from "playwright";
|
||||
|
||||
const BASE = process.env.PERF_URL ?? "http://localhost:4399/perf.html";
|
||||
const SHOTS = process.env.DOCK_SHOTS;
|
||||
const fails = [];
|
||||
const check = (name, ok, detail = "") => {
|
||||
console.log(`${ok ? " ok" : "FAIL"} ${name}${detail ? " — " + detail : ""}`);
|
||||
if (!ok) fails.push(name);
|
||||
};
|
||||
|
||||
const geometry = (page) =>
|
||||
page.evaluate(() => {
|
||||
const box = (sel) => {
|
||||
const el = document.querySelector(sel);
|
||||
const r = el?.getBoundingClientRect();
|
||||
return r && r.width > 0 ? { left: r.left, right: r.right, top: r.top, bottom: r.bottom } : null;
|
||||
};
|
||||
return { pane: box(".pane"), tabs: box(".pane > .tabs"), side: box('.pane > .scroll[data-pane="browser"]'), body: box(".pane > .pbody") };
|
||||
});
|
||||
|
||||
const browser = await chromium.launch();
|
||||
for (const scheme of ["light", "dark"]) {
|
||||
for (const [label, width] of [["wide", 1440], ["narrow", 560]]) {
|
||||
const page = await browser.newPage({ viewport: { width, height: 800 }, colorScheme: scheme, reducedMotion: "reduce" });
|
||||
page.setDefaultTimeout(8000);
|
||||
page.on("pageerror", (e) => fails.push("page error: " + e.message));
|
||||
const tag = `${scheme}/${label}`;
|
||||
let docked = null;
|
||||
for (const round of ["first open", "after a reload"]) {
|
||||
await page.goto(`${BASE}?pref=en&turns=3`, { waitUntil: "networkidle" });
|
||||
await page.waitForSelector(".app");
|
||||
await page.locator('[data-action="browser.open"]').first().click();
|
||||
await page.waitForTimeout(700);
|
||||
const open = await geometry(page);
|
||||
docked = open;
|
||||
check(`${tag} ${round}: the browser column is drawn`, !!open.side);
|
||||
if (open.side) {
|
||||
if (label === "wide") {
|
||||
check(`${tag} ${round}: it starts at the top of the pane, beside the navigation row`, Math.abs(open.side.top - open.pane.top) <= 1 && Math.abs(open.tabs.top - open.pane.top) <= 1, `pane ${open.pane.top} tabs ${open.tabs.top} column ${open.side.top}`);
|
||||
check(`${tag} ${round}: it runs to the bottom of the pane`, Math.abs(open.side.bottom - open.pane.bottom) <= 1, `${open.side.bottom} against ${open.pane.bottom}`);
|
||||
check(`${tag} ${round}: the navigation row stays left of it`, open.tabs.right <= open.side.left + 1, `${open.tabs.right} / ${open.side.left}`);
|
||||
} else {
|
||||
check(`${tag} ${round}: the navigation row stays above the page`, !!open.tabs && open.side.top >= open.tabs.bottom - 1, JSON.stringify([open.tabs, open.side]));
|
||||
}
|
||||
}
|
||||
if (SHOTS && round === "first open") await page.screenshot({ path: `${SHOTS}/${process.env.DOCK_TAG ?? "shot"}-${tag.replace("/", "-")}.png` });
|
||||
}
|
||||
await page.locator('[data-action="browser.open"]').first().click();
|
||||
await page.waitForTimeout(700);
|
||||
const shut = await geometry(page);
|
||||
check(`${tag} undocked: the column is gone and the navigation row is as wide as the pane's body again`, (!shut.side || shut.side.left >= shut.pane.right - 1) && !!shut.tabs && shut.tabs.right - shut.tabs.left >= shut.pane.right - shut.pane.left - 24, JSON.stringify([shut.side, shut.tabs, shut.pane]));
|
||||
await page.close();
|
||||
}
|
||||
}
|
||||
// Every room between phone and wide, not two samples: the left column shrinks with the window, and the tabs it holds
|
||||
// must still fit and stay clear of the browser column.
|
||||
{
|
||||
const page = await browser.newPage({ viewport: { width: 1440, height: 800 } });
|
||||
const bad = [];
|
||||
for (let width = 560; width <= 1440; width += 20) {
|
||||
await page.setViewportSize({ width, height: 800 });
|
||||
await page.goto(`${BASE}?pref=en&turns=3`, { waitUntil: "networkidle" });
|
||||
await page.waitForSelector(".app");
|
||||
await page.locator('[data-action="browser.open"]').first().click();
|
||||
await page.waitForTimeout(400);
|
||||
const g = await page.evaluate(() => {
|
||||
const tabs = document.querySelector(".pane > .tabs");
|
||||
const side = document.querySelector('.pane > .scroll[data-pane="browser"]');
|
||||
const t = tabs.getBoundingClientRect();
|
||||
const last = [...tabs.querySelectorAll(".tab")].at(-1)?.getBoundingClientRect();
|
||||
const s = side?.getBoundingClientRect();
|
||||
const stacked = !!s && s.top >= t.bottom - 1;
|
||||
const first = tabs.querySelector(".tab")?.getBoundingClientRect();
|
||||
const pad = parseFloat(getComputedStyle(tabs).paddingLeft) * 2;
|
||||
const need = last && first ? last.right - first.left + pad : 0;
|
||||
const pane = document.querySelector(".pane").getBoundingClientRect().width;
|
||||
return { fits: tabs.scrollWidth <= tabs.clientWidth + 1, clear: stacked || !s || !last || last.right <= s.left + 1, stacked, need, pane };
|
||||
});
|
||||
const should = g.pane < g.need + 320 || width <= 640;
|
||||
if (!g.fits || !g.clear || g.stacked !== should) bad.push(`${width}:${g.fits ? "" : "overflow "}${g.clear ? "" : "overlap "}${g.stacked !== should ? `stacked=${g.stacked} pane=${Math.round(g.pane)} need=${Math.round(g.need)}` : ""}`);
|
||||
}
|
||||
check("from 560 to 1440 the tabs fit their row, stay clear of the browser column, and the column stacks exactly when tabs plus its 320px floor no longer fit", bad.length === 0, bad.join(" "));
|
||||
await page.close();
|
||||
}
|
||||
await browser.close();
|
||||
if (fails.length) {
|
||||
console.log(`\n${fails.length} failed`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log("\nall ok");
|
||||
@@ -0,0 +1,33 @@
|
||||
// The first screen is drawn from the core CJK face (about 1.9 MB), not the 7.8 MB full font. The full font is a
|
||||
// later family in the stack for characters the core lacks: it must not be fetched for ordinary UI text, and it
|
||||
// must still load when a character only it has is shown.
|
||||
import { chromium } from "playwright";
|
||||
|
||||
const PAGE = process.env.PERF_URL ?? "http://localhost:4399/perf.html";
|
||||
const browser = await chromium.launch();
|
||||
const page = await (await browser.newContext({ locale: "zh-CN", viewport: { width: 1440, height: 900 } })).newPage();
|
||||
const fetched = [];
|
||||
page.on("response", (r) => {
|
||||
const m = /\/fonts\/(noto-studio[^/?]*)/.exec(r.url());
|
||||
if (m) fetched.push(m[1]);
|
||||
});
|
||||
await page.goto(PAGE, { waitUntil: "networkidle" });
|
||||
await page.waitForSelector(".app", { timeout: 15000 });
|
||||
await page.waitForTimeout(1500);
|
||||
|
||||
const fails = [];
|
||||
if (fetched.includes("noto-studio.woff2")) fails.push("the first screen fetched the full 7.8 MB font");
|
||||
if (!fetched.includes("noto-studio-core.woff2")) fails.push("the first screen did not use the core font");
|
||||
console.log(fails.length ? `FAIL ${fails.join("; ")} (${fetched.join(", ")})` : ` ok first screen fetched ${fetched.join(", ")} only`);
|
||||
|
||||
await page.evaluate(() => {
|
||||
const d = document.createElement("div");
|
||||
d.style.cssText = "position:fixed;left:0;top:0;font:32px var(--ui)";
|
||||
d.textContent = "龘";
|
||||
document.body.append(d);
|
||||
});
|
||||
await page.waitForTimeout(1500);
|
||||
const rare = fetched.includes("noto-studio.woff2");
|
||||
console.log(rare ? " ok a character only the full font has still loads it" : "FAIL a rare character did not load the full font");
|
||||
await browser.close();
|
||||
process.exit(fails.length || !rare ? 1 : 0);
|
||||
@@ -0,0 +1,119 @@
|
||||
// A column's drag handle must not sit on top of the scrollbar beside it: a press on the thumb has to scroll, and a
|
||||
// press on the handle has to resize. Hit-testing is a layout question, so it is asked of a real browser.
|
||||
import { chromium } from "playwright";
|
||||
|
||||
const BASE = process.env.PERF_URL ?? "http://localhost:4399/perf.html";
|
||||
const SHOTS = process.env.GUTTER_SHOTS;
|
||||
const fails = [];
|
||||
const check = (name, ok, detail = "") => {
|
||||
console.log(`${ok ? " ok" : "FAIL"} ${name}${detail ? " — " + detail : ""}`);
|
||||
if (!ok) fails.push(name);
|
||||
};
|
||||
|
||||
// The scrollbar is 10px wide against the scroller's right edge, its thumb inset 3px on each side.
|
||||
const THUMB = 5;
|
||||
const hit = (page, x, y) =>
|
||||
page.evaluate(([x, y]) => {
|
||||
const el = document.elementFromPoint(x, y);
|
||||
const g = el?.closest(".gutter");
|
||||
return { gutter: g ? g.className : null, tag: el ? el.className || el.tagName : null };
|
||||
}, [x, y]);
|
||||
|
||||
// Every row of the scroller's right edge, not one sample: a handle that only covers the top or the middle is still a
|
||||
// handle on the thumb whenever the thumb is there.
|
||||
const sweep = (page, right, top, height) =>
|
||||
page.evaluate(([right, top, height, inset]) => {
|
||||
const hits = [];
|
||||
for (let y = top + 2; y < top + height - 2; y += 12) {
|
||||
for (const dx of inset) {
|
||||
const g = document.elementFromPoint(right - dx, y)?.closest(".gutter");
|
||||
if (g) hits.push(`${g.className.split(" ").at(-1)}@${Math.round(y)}`);
|
||||
}
|
||||
}
|
||||
return hits;
|
||||
}, [right, top, height, [4, 5, 6]]);
|
||||
|
||||
const flowBox = (page) =>
|
||||
page.evaluate(() => {
|
||||
const r = document.querySelector('[data-pane="flow"]').getBoundingClientRect();
|
||||
return { right: r.right, top: r.top, height: r.height };
|
||||
});
|
||||
|
||||
const browser = await chromium.launch();
|
||||
for (const scheme of ["light", "dark"]) {
|
||||
for (const width of [1280, 1440]) {
|
||||
const page = await browser.newPage({ viewport: { width, height: 800 }, colorScheme: scheme, reducedMotion: "reduce" });
|
||||
page.setDefaultTimeout(8000);
|
||||
page.on("pageerror", (e) => fails.push("page error: " + e.message));
|
||||
await page.goto(`${BASE}?pref=en&turns=30`, { waitUntil: "networkidle" });
|
||||
await page.waitForSelector(".app");
|
||||
await page.evaluate(() => document.fonts.ready);
|
||||
const tag = `${scheme} ${width}px`;
|
||||
const flow = await page.evaluate(() => {
|
||||
const r = document.querySelector('[data-pane="flow"]').getBoundingClientRect();
|
||||
return { right: r.right, top: r.top, height: r.height };
|
||||
});
|
||||
const dockShut = await page.evaluate(() => document.querySelector(".gutter-r")?.hasAttribute("data-shut"));
|
||||
check(`${tag}: the workbench starts shut`, dockShut === true);
|
||||
const covered = await sweep(page, flow.right, flow.top, flow.height);
|
||||
check(`${tag}: no row of the transcript's scrollbar is under the workbench handle`, covered.length === 0, covered.slice(0, 4).join(" "));
|
||||
await page.mouse.move(flow.right - 5, flow.top + 8);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(flow.right - 5, flow.top + 160, { steps: 6 });
|
||||
await page.mouse.up();
|
||||
await page.waitForTimeout(250);
|
||||
check(`${tag}: pressing the scrollbar's top and dragging does not open the workbench`, (await page.evaluate(() => document.querySelector(".gutter-r")?.hasAttribute("data-shut"))) === true);
|
||||
|
||||
const grip = await page.locator(".gutter-r .grip").boundingBox();
|
||||
const pane = await page.evaluate(() => {
|
||||
const r = document.querySelector(".pane").getBoundingClientRect();
|
||||
return { top: r.top, height: r.height };
|
||||
});
|
||||
const mid = pane.top + pane.height / 2;
|
||||
check(`${tag}: the shut handle sits mid-height`, Math.abs(grip.y + grip.height / 2 - mid) <= pane.height * 0.05, `${Math.round(grip.y + grip.height / 2)} against ${Math.round(mid)}`);
|
||||
const onGrip = await hit(page, grip.x + grip.width / 2, grip.y + grip.height / 2);
|
||||
check(`${tag}: the shut workbench still has a handle to press`, onGrip.gutter?.includes("gutter-r") === true, String(onGrip.gutter));
|
||||
|
||||
const rail = await page.evaluate(() => {
|
||||
const el = document.querySelector(".rail .scroll");
|
||||
const r = el?.getBoundingClientRect();
|
||||
return r && r.width > 0 ? { right: r.right, top: r.top, height: r.height } : null;
|
||||
});
|
||||
if (rail) {
|
||||
const edge = await hit(page, rail.right - THUMB, rail.top + rail.height * 0.25);
|
||||
check(`${tag}: the workspace column's scrollbar is not under its handle`, edge.gutter === null, String(edge.gutter));
|
||||
const seam = await hit(page, rail.right + 3, rail.top + rail.height * 0.75);
|
||||
check(`${tag}: the workspace column's seam still resizes`, seam.gutter?.includes("gutter-l") === true, String(seam.gutter));
|
||||
}
|
||||
|
||||
if (SHOTS) await page.screenshot({ path: `${SHOTS}/${process.env.GUTTER_TAG ?? "shot"}-shut-${scheme}-${width}.png` });
|
||||
await page.mouse.move(grip.x + grip.width / 2, grip.y + grip.height / 2);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(grip.x - 200, grip.y + grip.height / 2, { steps: 8 });
|
||||
await page.mouse.up();
|
||||
await page.waitForTimeout(300);
|
||||
const shut = await page.evaluate(() => document.querySelector(".gutter-r")?.hasAttribute("data-shut"));
|
||||
check(`${tag}: dragging the handle opens the workbench`, shut === false);
|
||||
const now = await flowBox(page);
|
||||
const openCovered = await sweep(page, now.right, now.top, now.height);
|
||||
check(`${tag}: nor is it once the workbench is open`, openCovered.length === 0, openCovered.slice(0, 4).join(" "));
|
||||
const seamHit = await hit(page, now.right + 2, now.top + now.height * 0.5);
|
||||
check(`${tag}: the open seam still resizes`, seamHit.gutter?.includes("gutter-r") === true, String(seamHit.gutter));
|
||||
const wide = () => page.evaluate(() => document.querySelector('.scroll[data-pane="browser"]').getBoundingClientRect().width);
|
||||
const before = await wide();
|
||||
await page.mouse.move(now.right - 5, now.top + 8);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(now.right - 5, now.top + 160, { steps: 6 });
|
||||
await page.mouse.up();
|
||||
await page.waitForTimeout(250);
|
||||
check(`${tag}: pressing the scrollbar and dragging leaves the open workbench's width alone`, Math.abs((await wide()) - before) < 1);
|
||||
if (SHOTS) await page.screenshot({ path: `${SHOTS}/${process.env.GUTTER_TAG ?? "shot"}-${scheme}-${width}.png` });
|
||||
await page.close();
|
||||
}
|
||||
}
|
||||
await browser.close();
|
||||
if (fails.length) {
|
||||
console.log(`\n${fails.length} failed`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log("\nall ok");
|
||||
@@ -3,8 +3,25 @@ import { boot as bootLang } from "../src/i18n";
|
||||
import { track as trackWidth } from "../src/ui/viewport";
|
||||
import "../src/styles/tokens.css";
|
||||
import "../src/styles/app.css";
|
||||
import "../src/styles/dock.css";
|
||||
import "../src/styles/studio.css";
|
||||
import "../src/styles/changedpill.css";
|
||||
import "../src/styles/chart.css";
|
||||
import "../src/styles/community.css";
|
||||
import "../src/styles/providerpresets.css";
|
||||
import "../src/styles/sessionnotes.css";
|
||||
import "../src/styles/sessionquick.css";
|
||||
import "../src/styles/sidebardrop.css";
|
||||
import "../src/styles/sponsor.css";
|
||||
import "../src/styles/versions.css";
|
||||
import "../src/styles/phonecomposer.css";
|
||||
import "../src/styles/tips.css";
|
||||
import "../src/styles/waitnote.css";
|
||||
import "../src/styles/narrow.css";
|
||||
import "../src/styles/workfold.css";
|
||||
import "../src/styles/workbench-launcher.css";
|
||||
import "../src/styles/feedback.css";
|
||||
import "../src/styles/themes.css";
|
||||
import { App } from "../src/ui/App";
|
||||
import { MockHub } from "../src/port/mock_hub";
|
||||
import { MockPort } from "../src/port/mock";
|
||||
@@ -78,6 +95,10 @@ class BenchPort extends MockPort {
|
||||
return ONBOARDING ? { required: true, provider: "", model: "" } : null;
|
||||
}
|
||||
|
||||
async foldWorkSettings() {
|
||||
return { ...(await super.foldWorkSettings()), fold: query.has("fold") };
|
||||
}
|
||||
|
||||
async workspaceGit() {
|
||||
const git = await super.workspaceGit();
|
||||
return query.get("git") === "none" ? { ...git, repo: false } : git;
|
||||
@@ -209,6 +230,11 @@ const NO_PANES = PANES === 0;
|
||||
|
||||
class BenchHub extends MockHub {
|
||||
readonly feeds = new Map<string, BenchPort>();
|
||||
private readonly settingsPort = new BenchPort();
|
||||
|
||||
machinePort(): AgentPort {
|
||||
return this.settingsPort;
|
||||
}
|
||||
|
||||
portFor(rt: RuntimeView): AgentPort {
|
||||
let port = this.feeds.get(rt.id);
|
||||
|
||||
@@ -1,85 +1,16 @@
|
||||
// The transcript skips layout for off-screen cards with content-visibility,
|
||||
// and each one holds an estimated height until it is first laid out. The
|
||||
// estimate need not be accurate, but its error must fall on both sides: below
|
||||
// the shortest card every unread card is underestimated, the error accumulates
|
||||
// in one direction, and the scroller lands short and then corrects — which is
|
||||
// the lurch. This measures the real distribution and checks the stylesheet's
|
||||
// number against it.
|
||||
// A transcript card's height is never an estimate. Rows near the viewport render, and Block unmounts the far ones
|
||||
// while holding the height it measured; a card that skipped layout with `content-visibility: auto` held a guess until
|
||||
// it first scrolled into view and the page then shrank under the wheel. The stylesheet must not bring that back.
|
||||
import { readFileSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { chromium } from "playwright";
|
||||
|
||||
const HERE = dirname(fileURLToPath(import.meta.url));
|
||||
const SRC = process.env.PERF_SRC ?? join(HERE, "..", "src");
|
||||
const PAGE = process.env.PERF_URL ?? "http://localhost:4399/perf.html?ws=1&sess=1&turns=40&pref=zh";
|
||||
|
||||
const css = readFileSync(join(SRC, "styles", "app.css"), "utf8");
|
||||
const declared = Number(css.match(/contain-intrinsic-size:\s*auto\s+(\d+(?:\.\d+)?)px/)?.[1] ?? NaN);
|
||||
if (!Number.isFinite(declared)) {
|
||||
console.log("未在样式表中找到 contain-intrinsic-size 的兜底值:该检查将始终通过。");
|
||||
process.exit(1);
|
||||
}
|
||||
const css = ["app.css", "studio.css"].map((f) => readFileSync(join(SRC, "styles", f), "utf8")).join("\n");
|
||||
|
||||
const fails = [];
|
||||
const check = (name, ok, detail = "") => {
|
||||
console.log(`${ok ? " ok" : "FAIL"} ${name}${detail ? " — " + detail : ""}`);
|
||||
if (!ok) fails.push(name);
|
||||
};
|
||||
|
||||
const browser = await chromium.launch();
|
||||
const page = await browser.newPage({ viewport: { width: 1440, height: 900 }, colorScheme: "dark" });
|
||||
await page.goto(PAGE, { waitUntil: "networkidle" });
|
||||
await page.waitForSelector(".compose");
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// Walk the whole transcript so every card lays out at least once: only
|
||||
// then is the distribution the one a reader meets.
|
||||
const stats = await page.evaluate(async () => {
|
||||
const sc = [...document.querySelectorAll("*")]
|
||||
.filter((el) => {
|
||||
const s = getComputedStyle(el);
|
||||
return /auto|scroll/.test(s.overflowY) && el.scrollHeight > el.clientHeight + 4 && el.querySelector(".call");
|
||||
})
|
||||
.pop();
|
||||
if (sc) {
|
||||
for (let y = 0; y <= sc.scrollHeight; y += 400) {
|
||||
sc.scrollTop = y;
|
||||
await new Promise((r) => requestAnimationFrame(r));
|
||||
}
|
||||
}
|
||||
const hs = [...document.querySelectorAll(".chunk > .enterbox > .call")]
|
||||
.map((c) => c.offsetHeight)
|
||||
.filter((x) => x > 0)
|
||||
.sort((a, b) => a - b);
|
||||
if (!hs.length) return null;
|
||||
const q = (p) => hs[Math.floor((hs.length - 1) * p)];
|
||||
return {
|
||||
n: hs.length,
|
||||
min: hs[0],
|
||||
median: q(0.5),
|
||||
mean: Math.round(hs.reduce((a, b) => a + b, 0) / hs.length),
|
||||
max: hs[hs.length - 1],
|
||||
};
|
||||
});
|
||||
|
||||
if (!stats) {
|
||||
console.log("未量到卡片:该检查将始终通过,请先确认页面存在转录内容。");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`样式表兜底值 ${declared}px 实测 n=${stats.n} 最小 ${stats.min} 中位数 ${stats.median} 均值 ${stats.mean} 最大 ${stats.max}`);
|
||||
check(
|
||||
"占位估算不低于最小卡片高度",
|
||||
declared >= stats.min,
|
||||
declared < stats.min ? `${declared} < ${stats.min}:每张未读卡片均被低估` : "",
|
||||
);
|
||||
check(
|
||||
"占位估算处于实测分布区间内",
|
||||
declared <= stats.max,
|
||||
declared > stats.max ? `${declared} > ${stats.max}:误差将反向累加` : "",
|
||||
);
|
||||
|
||||
await browser.close();
|
||||
console.log(fails.length ? `\n${fails.length} 项未通过` : "\n全部通过");
|
||||
const bad = [...css.matchAll(/([^{}]*\.call[^{}]*)\{([^{}]*)\}/g)].filter((m) => /\.chunk|\.enterbox/.test(m[1]) && /content-visibility\s*:\s*auto/.test(m[2]));
|
||||
if (bad.length) fails.push("estimated height");
|
||||
console.log(bad.length ? `FAIL ${bad.map((m) => m[1].trim()).join(" | ")} uses content-visibility: auto` : " ok no .call row in the transcript flow estimates its height");
|
||||
process.exit(fails.length ? 1 : 0);
|
||||
@@ -0,0 +1,133 @@
|
||||
// Under a high interface scale the layout's room is the window divided by the zoom, so a phone-width window can leave
|
||||
// the shell a couple of hundred CSS pixels. Rows that cannot fit must give something up, never paint over each other.
|
||||
import { chromium } from "playwright";
|
||||
|
||||
const BASE = process.env.PERF_URL ?? "http://localhost:4399/perf.html";
|
||||
const SHOTS = process.env.NARROW_SHOTS;
|
||||
const fails = [];
|
||||
const check = (name, ok, detail = "") => {
|
||||
console.log(`${ok ? " ok" : "FAIL"} ${name}${detail ? " — " + detail : ""}`);
|
||||
if (!ok) fails.push(name);
|
||||
};
|
||||
|
||||
const probe = (page) =>
|
||||
page.evaluate(() => {
|
||||
const live = (el) => {
|
||||
const r = el.getBoundingClientRect();
|
||||
return r.width > 0 && r.height > 0 && getComputedStyle(el).visibility !== "hidden" ? r : null;
|
||||
};
|
||||
const name = (el) => (el.className && String(el.className).split(/\s+/)[0]) || el.tagName;
|
||||
const overlaps = (parent) => {
|
||||
const boxes = [...parent.children].map((el) => [el, live(el)]).filter(([, r]) => r);
|
||||
const hits = [];
|
||||
for (let i = 0; i < boxes.length; i++)
|
||||
for (let j = i + 1; j < boxes.length; j++) {
|
||||
const [a, ra] = boxes[i];
|
||||
const [b, rb] = boxes[j];
|
||||
const x = Math.min(ra.right, rb.right) - Math.max(ra.left, rb.left);
|
||||
const y = Math.min(ra.bottom, rb.bottom) - Math.max(ra.top, rb.top);
|
||||
if (x > 1 && y > 1) hits.push(`${name(a)}/${name(b)}`);
|
||||
}
|
||||
return hits;
|
||||
};
|
||||
const outside = (parent) =>
|
||||
[...parent.children].filter((el) => live(el) && live(el).right > innerWidth + 1).map(name);
|
||||
const bar = document.querySelector(".chrome");
|
||||
const row = document.querySelector(".compose .row");
|
||||
const tools = document.querySelector(".compose .turntools");
|
||||
const crumb = bar.querySelector(".crumb");
|
||||
const title = crumb.querySelector("b");
|
||||
const clip = crumb.getBoundingClientRect();
|
||||
const t = title.getBoundingClientRect();
|
||||
return {
|
||||
bar: { overlaps: overlaps(bar).concat(overlaps(bar.querySelector(".r"))), outside: outside(bar).concat(outside(bar.querySelector(".r"))) },
|
||||
row: { overlaps: overlaps(row), outside: outside(row) },
|
||||
toolsOverflow: [...tools.children].filter((el) => live(el) && live(el).right > tools.getBoundingClientRect().right + 1).map(name),
|
||||
titleShown: t.width,
|
||||
titleOutsideCrumb: t.width > 0 && (t.left < clip.left - 1 || t.right > clip.right + 1),
|
||||
crumbWidth: clip.width,
|
||||
room: document.body.clientWidth,
|
||||
};
|
||||
});
|
||||
|
||||
const CONTROLS = [".phone-action", ".find-action", ".theme-toggle", ".top-action", ".browser-action"];
|
||||
|
||||
// Opens the fold when there is one, then presses nothing: asks whether each control is on screen with nothing over it.
|
||||
async function reachable(page) {
|
||||
const more = page.locator(".chrome .r-more");
|
||||
if (await more.isVisible()) {
|
||||
await more.click();
|
||||
await page.waitForTimeout(150);
|
||||
}
|
||||
return page.evaluate((controls) => {
|
||||
const out = [];
|
||||
for (const sel of controls) {
|
||||
const el = document.querySelector(`.chrome ${sel}`);
|
||||
if (!el) {
|
||||
out.push(`${sel} missing`);
|
||||
continue;
|
||||
}
|
||||
const r = el.getBoundingClientRect();
|
||||
const top = document.elementFromPoint(r.left + r.width / 2, r.top + r.height / 2);
|
||||
const inside = r.width > 0 && r.left >= 0 && r.right <= innerWidth + 1;
|
||||
if (!inside || !(el === top || el.contains(top))) out.push(`${sel}${inside ? " covered" : " off screen"}`);
|
||||
}
|
||||
return out;
|
||||
}, CONTROLS);
|
||||
}
|
||||
|
||||
const browser = await chromium.launch();
|
||||
|
||||
// The fold's threshold is derived, not chosen: wherever the bar is not folded the title keeps its floor.
|
||||
{
|
||||
const page = await browser.newPage({ viewport: { width: 1280, height: 800 } });
|
||||
const bad = [];
|
||||
for (let room = 190; room <= 460; room += 10) {
|
||||
await page.setViewportSize({ width: room, height: 800 });
|
||||
await page.goto(`${BASE}?pref=en&turns=2`, { waitUntil: "networkidle" });
|
||||
await page.waitForSelector(".app");
|
||||
await page.waitForTimeout(200);
|
||||
const w = await page.evaluate(() => document.querySelector(".chrome .crumb").getBoundingClientRect().width);
|
||||
if (w < 48) bad.push(`${room}:${Math.round(w)}`);
|
||||
}
|
||||
check("at every room from 190 to 460 the title keeps its 4em floor", bad.length === 0, bad.join(" "));
|
||||
await page.close();
|
||||
}
|
||||
for (const scheme of ["light", "dark"]) {
|
||||
for (const [width, zoom] of [[390, 1.8], [390, 1.3], [320, 1.8], [1280, 1]]) {
|
||||
const page = await browser.newPage({ viewport: { width, height: 800 }, colorScheme: scheme, reducedMotion: "reduce" });
|
||||
page.setDefaultTimeout(8000);
|
||||
page.on("pageerror", (e) => fails.push("page error: " + e.message));
|
||||
await page.goto(`${BASE}?pref=en&turns=2&zoom=${zoom}`, { waitUntil: "networkidle" });
|
||||
await page.waitForSelector(".app");
|
||||
await page.evaluate(() => document.fonts.ready);
|
||||
await page.waitForTimeout(500);
|
||||
const tag = `${scheme} ${width}px zoom ${zoom}`;
|
||||
const m = await probe(page);
|
||||
check(`${tag}: title bar controls do not overlap`, m.bar.overlaps.length === 0, m.bar.overlaps.join(", "));
|
||||
check(`${tag}: title bar stays inside the window`, m.bar.outside.length === 0, m.bar.outside.join(", "));
|
||||
check(`${tag}: composer row does not overlap`, m.row.overlaps.length === 0, m.row.overlaps.join(", "));
|
||||
check(`${tag}: composer toolbar controls stay in the toolbar`, m.toolsOverflow.length === 0, m.toolsOverflow.join(", "));
|
||||
check(`${tag}: the title keeps its 4em floor inside its own box`, m.crumbWidth >= 48 && !m.titleOutsideCrumb, `crumb ${Math.round(m.crumbWidth)}px`);
|
||||
if (SHOTS) await page.screenshot({ path: `${SHOTS}/${process.env.NARROW_TAG ?? "shot"}-${scheme}-${width}-z${zoom}.png` });
|
||||
const hint = await page.evaluate(() => {
|
||||
const el = document.querySelector(".compose textarea");
|
||||
const cs = getComputedStyle(el);
|
||||
const ctx = document.createElement("canvas").getContext("2d");
|
||||
ctx.font = `${cs.fontStyle} ${cs.fontWeight} ${cs.fontSize} ${cs.fontFamily}`;
|
||||
const room = el.clientWidth - parseFloat(cs.paddingLeft) - parseFloat(cs.paddingRight);
|
||||
return { text: el.placeholder, width: ctx.measureText(el.placeholder).width, room };
|
||||
});
|
||||
check(`${tag}: the composer's placeholder is on one line and not empty`, hint.text.length > 0 && hint.width <= hint.room + 1, JSON.stringify(hint));
|
||||
const reach = await reachable(page);
|
||||
check(`${tag}: every title-bar control can be reached, folded or not`, reach.length === 0, reach.join(", "));
|
||||
if (SHOTS) await page.screenshot({ path: `${SHOTS}/${process.env.NARROW_TAG ?? "shot"}-open-${scheme}-${width}-z${zoom}.png` });
|
||||
await page.close();
|
||||
}
|
||||
}
|
||||
await browser.close();
|
||||
if (fails.length) {
|
||||
console.log(`\n${fails.length} failed`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log("\nall ok");
|
||||
@@ -1,4 +1,4 @@
|
||||
// Icon-rail hover hints: each must be drawn, fully inside the window, and not clipped by any ancestor.
|
||||
// Icon-rail hover hints (drawn after a short hover delay): each must be drawn, fully inside the window, and not clipped by any ancestor.
|
||||
// Clipping is a layout question jsdom cannot answer. Modes are the stored rail choice; "collapsed" also closes the workspace rail.
|
||||
import { chromium } from "playwright";
|
||||
|
||||
@@ -58,7 +58,12 @@ for (const scheme of ["light", "dark"]) {
|
||||
for (let i = 0; i < count; i++) {
|
||||
const btn = page.locator(".nav .navbtn").nth(i);
|
||||
await btn.hover();
|
||||
await page.waitForTimeout(300);
|
||||
if (i === 0) {
|
||||
await page.waitForTimeout(120);
|
||||
const early = await measure(page, i);
|
||||
check(`${scheme}/${mode}: the hint is not drawn the instant the pointer arrives`, early && early.opacity < 1, `opacity ${early?.opacity}`);
|
||||
}
|
||||
await page.waitForTimeout(700);
|
||||
const m = await measure(page, i);
|
||||
const tag = `${scheme}/${mode}/#${i} ${m?.text ?? "?"}`;
|
||||
if (!m) { check(`${tag}: hint exists`, false); continue; }
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
// A long runtime notice has to be readable whole and dismissible without scrolling.
|
||||
// Reads geometry: the bar's sentence is not clipped by .composenotes and its dismiss
|
||||
// button lies inside the visible part of it.
|
||||
import { chromium } from "playwright";
|
||||
|
||||
const PAGE = process.env.PERF_URL ?? "http://localhost:4399/perf.html";
|
||||
const fails = [];
|
||||
const check = (name, ok, detail = "") => {
|
||||
console.log(`${ok ? " ok" : "FAIL"} ${name}${detail ? " — " + detail : ""}`);
|
||||
if (!ok) fails.push(name);
|
||||
};
|
||||
|
||||
const browser = await chromium.launch();
|
||||
const page = await browser.newPage({ viewport: { width: 1440, height: 900 } });
|
||||
page.on("pageerror", (e) => fails.push("page error: " + e.message));
|
||||
await page.goto(`${PAGE}?turns=2`, { waitUntil: "networkidle" });
|
||||
await page.waitForSelector(".app", { timeout: 20000 });
|
||||
await page.evaluate(() => {
|
||||
window.__feed({
|
||||
kind: "notice",
|
||||
level: "warn",
|
||||
audience: "operator",
|
||||
code: "permission_rules_dormant",
|
||||
text: "1 permission rule matches no tool",
|
||||
detail: JSON.stringify({ rules: [{ list: "ask", rule: "Foo(bar)" }] }),
|
||||
});
|
||||
});
|
||||
await page.waitForSelector(".rtbar", { timeout: 5000 });
|
||||
await page.waitForTimeout(1500);
|
||||
|
||||
for (const [w, h] of [[1440, 900], [400, 800], [1100, 640]]) {
|
||||
await page.setViewportSize({ width: w, height: h });
|
||||
await page.waitForTimeout(900);
|
||||
const g = await page.evaluate(() => {
|
||||
const notes = document.querySelector(".composenotes");
|
||||
const nr = notes.getBoundingClientRect();
|
||||
const btn = document.querySelector(".rtbar button").getBoundingClientRect();
|
||||
return {
|
||||
clipped: document.querySelector(".rtbar").getBoundingClientRect().bottom > nr.bottom + 1,
|
||||
btnIn: btn.top >= nr.top - 1 && btn.bottom <= nr.bottom + 1 && btn.right <= innerWidth + 1,
|
||||
sh: notes.scrollHeight, ch: notes.clientHeight,
|
||||
text: document.querySelector(".rtbar .t").textContent,
|
||||
};
|
||||
});
|
||||
// A short window has no room for a long sentence; it scrolls, and the button stays on the first row.
|
||||
if (h >= 720) check(`${w}x${h}: sentence is not clipped`, !g.clipped, `scroll ${g.sh} / client ${g.ch}`);
|
||||
check(`${w}x${h}: dismiss button is visible without scrolling`, g.btnIn);
|
||||
check(`${w}x${h}: no raw placeholder`, !/\{\w+\}/.test(g.text), g.text.slice(0, 30));
|
||||
}
|
||||
await browser.close();
|
||||
if (fails.length) {
|
||||
console.log(`\n${fails.length} failed`);
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -36,9 +36,11 @@ for (const scheme of ["light", "dark"]) {
|
||||
const g = await page.evaluate(() => {
|
||||
const el = document.querySelector(".reask textarea");
|
||||
if (!el) return null;
|
||||
return { h: el.getBoundingClientRect().height, scroll: el.scrollHeight, client: el.clientHeight, view: innerHeight };
|
||||
const row = el.closest(".call").getBoundingClientRect();
|
||||
return { h: el.getBoundingClientRect().height, w: el.getBoundingClientRect().width, row: row.width, scroll: el.scrollHeight, client: el.clientHeight, view: innerHeight };
|
||||
});
|
||||
check(`${scheme}/${label}: the rewrite box opened`, !!g);
|
||||
if (g) check(`${scheme}/${label}: the rewrite box spans its row, not the width a bare textarea starts at`, g.w >= g.row * 0.9 - 2, `box ${Math.round(g.w)}px of a ${Math.round(g.row)}px row`);
|
||||
if (g) {
|
||||
const room = Math.min(g.scroll, g.view * 0.6, 448);
|
||||
check(`${scheme}/${label}: long single-line text is visible without scrolling the box`, g.client >= room - 2, `box ${Math.round(g.h)}px, content ${g.scroll}px`);
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
// A running call with no output yet: its row opens, says it is still running and for how long, and names its own timeout.
|
||||
import { chromium } from "playwright";
|
||||
const fails = [];
|
||||
const check = (name, ok, detail = "") => { console.log(`${ok ? " ok" : "FAIL"} ${name}${detail ? " — " + detail : ""}`); if (!ok) fails.push(name); };
|
||||
const [,, out, w] = process.argv;
|
||||
const b = await chromium.launch();
|
||||
const p = await b.newPage({ viewport: { width: +w || 1100, height: 700 } });
|
||||
await p.goto((process.env.PERF_URL ?? "http://localhost:4521/perf.html") + "?pref=zh", { waitUntil: "networkidle" }); await p.waitForSelector(".app"); await p.waitForTimeout(400);
|
||||
await p.evaluate(async () => {
|
||||
window.__feed({ kind: "turn_started", authoredTurn: 1, msgIndex: 0, text: "list them" });
|
||||
window.__feed({ kind: "text", text: "我来看一下目录。" });
|
||||
const cmd = 'cd /Users/me/Documents/AI项目/音频搜索 && for f in *.md; do echo "=== $f ($(wc -l < "$f") 行)"; head -20 "$f"; done; ls -la /Users/me/Documents/ ; echo ---; find /Users/me/Documents -maxdepth 3 -name "*.md" | head -50';
|
||||
window.__feed({ kind: "tool_dispatch", tool: { id: "b1", name: "bash", args: JSON.stringify({ command: cmd, timeout_seconds: 900 }) } });
|
||||
await new Promise((r) => setTimeout(r, 300));
|
||||
});
|
||||
await p.waitForTimeout(600);
|
||||
if (out) await p.screenshot({ path: out + "-1.png" });
|
||||
const row = p.locator(".activity-group .call").first();
|
||||
check("the running row is a disclosure", (await row.locator("details.tool-disclosure").count()) === 1);
|
||||
await row.locator("summary").click().catch(() => {});
|
||||
await p.waitForTimeout(1500);
|
||||
const g = await p.evaluate(() => ({ note: document.querySelector(".activity-call-wait")?.textContent ?? "", input: !!document.querySelector(".activity-call-input"), title: document.querySelector(".activity-group .arg")?.getAttribute("title") ?? "" }));
|
||||
check("opened, it shows the whole command", g.input && g.title.includes("maxdepth 3"));
|
||||
check("and says it is still running with no output, and for how long", /命令仍在运行/.test(g.note) && /已运行/.test(g.note), g.note);
|
||||
check("and names the command's own timeout", /超时上限 900 秒/.test(g.note), g.note);
|
||||
if (out) await p.screenshot({ path: out + "-2.png" });
|
||||
await b.close();
|
||||
console.log(fails.length ? `\n${fails.length} failed` : "\nall ok");
|
||||
process.exit(fails.length ? 1 : 0);
|
||||
@@ -0,0 +1,83 @@
|
||||
// One sidebar toggle, drawn at one place whatever the sidebar and the icon rail are doing, never under another control;
|
||||
// and a run of replies from one model is headed once. Position and hit-testing are questions for a real browser.
|
||||
import { chromium } from "playwright";
|
||||
|
||||
const BASE = process.env.PERF_URL ?? "http://localhost:4399/perf.html";
|
||||
const SHOTS = process.env.SIDEBAR_SHOTS;
|
||||
const fails = [];
|
||||
const check = (name, ok, detail = "") => {
|
||||
console.log(`${ok ? " ok" : "FAIL"} ${name}${detail ? " — " + detail : ""}`);
|
||||
if (!ok) fails.push(name);
|
||||
};
|
||||
|
||||
const browser = await chromium.launch();
|
||||
for (const scheme of ["light", "dark"]) {
|
||||
for (const mode of ["on", "collapsed", "off"]) {
|
||||
const page = await browser.newPage({ viewport: { width: 1440, height: 900 }, colorScheme: scheme, reducedMotion: "reduce" });
|
||||
page.setDefaultTimeout(8000);
|
||||
page.on("pageerror", (e) => fails.push("page error: " + e.message));
|
||||
await page.addInitScript((m) => localStorage.setItem("rx-nav-rail", m), mode);
|
||||
await page.goto(`${BASE}?pref=en&turns=3`, { waitUntil: "networkidle" });
|
||||
await page.waitForSelector(".app");
|
||||
await page.evaluate(() => document.fonts.ready);
|
||||
const tag = `${scheme}/${mode}`;
|
||||
const spot = async () => {
|
||||
await page.waitForTimeout(700);
|
||||
return page.evaluate(() => {
|
||||
const toggles = [...document.querySelectorAll('[data-action="chrome.rail"]')].filter((b) => b.tabIndex !== -1 && b.getBoundingClientRect().width > 0 && b.getBoundingClientRect().right > 0);
|
||||
const b = toggles[0]?.getBoundingClientRect();
|
||||
if (!b) return { count: toggles.length };
|
||||
const x = b.left + b.width / 2;
|
||||
const y = b.top + b.height / 2;
|
||||
const top = document.elementFromPoint(x, y);
|
||||
return { count: toggles.length, x: Math.round(x), y: Math.round(y), reached: toggles[0].contains(top) };
|
||||
});
|
||||
};
|
||||
const open = await spot();
|
||||
check(`${tag}: exactly one sidebar toggle is drawn while the sidebar is open`, open.count === 1, String(open.count));
|
||||
check(`${tag}: nothing lies over it`, open.reached === true);
|
||||
if (SHOTS) await page.screenshot({ path: `${SHOTS}/${process.env.SIDEBAR_TAG ?? "shot"}-open-${tag.replace("/", "-")}.png` });
|
||||
await page.locator('[data-action="chrome.rail"]').filter({ visible: true }).first().click({ position: { x: 15, y: 15 } });
|
||||
const shut = await spot();
|
||||
check(`${tag}: exactly one toggle after collapsing`, shut.count === 1, String(shut.count));
|
||||
check(`${tag}: it has not moved`, shut.x === open.x && shut.y === open.y, `${open.x},${open.y} -> ${shut.x},${shut.y}`);
|
||||
check(`${tag}: nothing lies over it once collapsed`, shut.reached === true);
|
||||
if (SHOTS) await page.screenshot({ path: `${SHOTS}/${process.env.SIDEBAR_TAG ?? "shot"}-shut-${tag.replace("/", "-")}.png` });
|
||||
await page.close();
|
||||
}
|
||||
}
|
||||
for (const scheme of ["light", "dark"]) {
|
||||
const page = await browser.newPage({ viewport: { width: 1280, height: 900 }, colorScheme: scheme, reducedMotion: "reduce" });
|
||||
await page.goto(`${BASE}?pref=en`, { waitUntil: "networkidle" });
|
||||
await page.waitForSelector(".app");
|
||||
await page.evaluate(async () => {
|
||||
const say = (text) => {
|
||||
window.__feed({ kind: "text", text });
|
||||
window.__feed({ kind: "message" });
|
||||
};
|
||||
window.__feed({ kind: "turn_started" });
|
||||
say("First reply.");
|
||||
window.__feed({ kind: "tool_dispatch", tool: { id: "t1", name: "bash", args: "{}" } });
|
||||
window.__feed({ kind: "tool_result", tool: { id: "t1", name: "bash", args: "{}", output: "ok", durationMs: 5 } });
|
||||
say("Second reply, same model.");
|
||||
say("Third reply, same model.");
|
||||
window.__feed({ kind: "turn_done" });
|
||||
});
|
||||
await page.waitForTimeout(600);
|
||||
const heads = await page.evaluate(() =>
|
||||
[...document.querySelectorAll('.flow .call[data-k="say"]')].map((c) => {
|
||||
const hl = c.querySelector(":scope > .c > .hl");
|
||||
const sym = c.querySelector(":scope > .g");
|
||||
return { quiet: c.hasAttribute("data-quiet"), head: !!hl && getComputedStyle(hl).display !== "none", mark: !!sym && getComputedStyle(sym).display !== "none" };
|
||||
}),
|
||||
);
|
||||
check(`${scheme}: three replies in a turn, one header`, heads.length === 3 && heads.filter((h) => h.head && h.mark).length === 1 && heads[0].head && heads[0].mark, JSON.stringify(heads));
|
||||
if (SHOTS) await page.screenshot({ path: `${SHOTS}/${process.env.SIDEBAR_TAG ?? "shot"}-replies-${scheme}.png` });
|
||||
await page.close();
|
||||
}
|
||||
await browser.close();
|
||||
if (fails.length) {
|
||||
console.log(`\n${fails.length} failed`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log("\nall ok");
|
||||
@@ -0,0 +1,46 @@
|
||||
// The signal rail of the run analysis says what it is and why its marks cannot be moved.
|
||||
import { chromium } from "playwright";
|
||||
|
||||
const BASE = process.env.PERF_URL ?? "http://localhost:4399/perf.html";
|
||||
const SHOTS = process.env.SIGNALS_SHOTS;
|
||||
const fails = [];
|
||||
const check = (name, ok, detail = "") => {
|
||||
console.log(`${ok ? " ok" : "FAIL"} ${name}${detail ? " — " + detail : ""}`);
|
||||
if (!ok) fails.push(name);
|
||||
};
|
||||
|
||||
const browser = await chromium.launch();
|
||||
for (const scheme of ["light", "dark"]) {
|
||||
for (const [label, width] of [["wide", 1280], ["phone", 390]]) {
|
||||
const page = await browser.newPage({ viewport: { width, height: 900 }, colorScheme: scheme, reducedMotion: "reduce" });
|
||||
page.on("pageerror", (e) => fails.push("page error: " + e.message));
|
||||
await page.goto(`${BASE}?pref=en`, { waitUntil: "networkidle" });
|
||||
await page.waitForSelector(".app");
|
||||
await page.evaluate(() => {
|
||||
window.__feed({ kind: "turn_started" });
|
||||
window.__feed({ kind: "retrying", retryAttempt: 1, retryMax: 2, retryScope: "stream" });
|
||||
window.__feed({ kind: "tool_dispatch", tool: { id: "t1", name: "bash", args: "{}" } });
|
||||
window.__feed({ kind: "tool_result", tool: { id: "t1", name: "bash", args: "{}", output: "ok", durationMs: 40 } });
|
||||
window.__feed({ kind: "turn_done" });
|
||||
});
|
||||
await page.locator('[data-action="pane.view"][data-value="analysis"]').click();
|
||||
await page.waitForSelector(".run-signals");
|
||||
const tag = `${scheme}/${label}`;
|
||||
const g = await page.evaluate(() => {
|
||||
const note = document.querySelector(".run-signals .run-section-note");
|
||||
const r = note?.getBoundingClientRect();
|
||||
const box = document.querySelector(".run-signals").getBoundingClientRect();
|
||||
return { text: note?.textContent ?? "", inside: !!r && r.left >= box.left - 1 && r.right <= box.right + 1 && r.width > 0 };
|
||||
});
|
||||
check(`${tag}: the rail says what its marks are and why they stay put`, /位置就是时间|position is their time/.test(g.text), g.text);
|
||||
check(`${tag}: the explanation sits inside the section`, g.inside);
|
||||
if (SHOTS) await page.screenshot({ path: `${SHOTS}/${process.env.SIGNALS_TAG ?? "shot"}-${tag.replace("/", "-")}.png` });
|
||||
await page.close();
|
||||
}
|
||||
}
|
||||
await browser.close();
|
||||
if (fails.length) {
|
||||
console.log(`\n${fails.length} failed`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log("\nall ok");
|
||||
Loaded 100 of 1071 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user