Compare commits

..
Author SHA1 Message Date
esengine 5207d6f89b docs: draft code signing policy page (not in force) 2026-09-30 01:46:32 -07:00
1756 changed files with 6499 additions and 118625 deletions

No files matched your search

+1 -1
View File
@@ -10,7 +10,6 @@
# User-facing guides and references.
/docs/ @SivanCola @esengine
/docs/THEME_AUTHOR_GUIDE.md @esengine @SivanCola
/sdk/ @SivanCola @esengine
/README.md @SivanCola @esengine
/README.zh-CN.md @SivanCola @esengine
@@ -23,6 +22,7 @@
/docs/*_CONTRACT.md @esengine @SivanCola
/docs/TOOL_APPROVAL_MODES.md @esengine @SivanCola
/docs/CAPABILITY_DIAGNOSTICS.md @esengine @SivanCola
/docs/CODE_SIGNING_POLICY.md @esengine @SivanCola
/docs/CHECKPOINTS.md @esengine @SivanCola
/docs/EXTENSION_*.md @esengine @SivanCola
/docs/SUBAGENT_*.md @esengine @SivanCola
-6
View File
@@ -6,9 +6,3 @@ contact_links:
- name: Questions & discussions
url: https://github.com/esengine/DeepSeek-Reasonix/discussions
about: For usage questions, ideas, and general discussion — please use Discussions instead of opening an issue.
- name: Discord
url: https://discord.gg/XF78rEME2D
about: Bilingual community chat for setup help, workflow showcases, and feature ideas.
- name: QQ group (DeepSeek-Reasonix官方群, 1093562660)
url: https://qm.qq.com/q/i59b0z2R8s
about: Chinese-language community chat for setup help and everyday questions.
-20
View File
@@ -8,22 +8,6 @@ only, on `main-v2`.
-
## Cause
-
## Blast radius
-
## Neighbouring behaviours tested
-
## Why this layer
-
## Issues
<!--
@@ -42,7 +26,3 @@ the source-to-target contract here: easing syntax, time units, callbacks,
cancellation, reduced-motion behavior, and failure fallback. Verification must
assert that the target API was actually called; a mock that silently skips it
does not count.
## Disclosure
- [ ] AI assistance was used for this change (optional; for transparency only, it does not change how the change is reviewed)
Binary file not shown.

Before

Width:  |  Height:  |  Size: 138 KiB

+10 -14
View File
@@ -62,7 +62,7 @@ jobs:
# both paths must trigger the sdk job.
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
if echo "$files" | grep -qE '^(scripts/|npm/|release-notes/studio/)'; then scripts=true; fi
fi
fi
{ echo "code=$code"; echo "desktop=$desktop"; echo "sdk=$sdk"; echo "scripts=$scripts"; } >> "$GITHUB_OUTPUT"
@@ -80,11 +80,7 @@ jobs:
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # 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/markdown-refs.test.mjs scripts/studio-release-notes.test.mjs scripts/publish-cli-pointer.test.mjs scripts/studio-release-flags.test.mjs scripts/brew-cask-guard.test.mjs scripts/feedback-sync.test.mjs npm/publish.test.mjs
- run: node --test scripts/release-credits.test.mjs scripts/markdown-refs.test.mjs scripts/studio-release-notes.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
@@ -179,12 +175,12 @@ jobs:
# budget above normal 7-9 minute runner variance; each package test binary
# remains independently bounded by Go's per-package timeout below.
#
# That timeout is 12m, not 3m: across four consecutive runs of the same
# That timeout is 8m, not 3m: across four consecutive runs of the same
# code, internal/assembly/boot measured 133s, 146s, 162s and then hit 180s, and
# internal/runtime/agent 82s, 92s, 144s and 180s — a 2.2x spread from runner
# variance alone, with four heavy packages sharing four vCPUs. The boot package
# has since grown to 422s and then ran past 480s on two consecutive runs of the
# same code, so 8m no longer leaves room. A hang still trips the job budget.
# variance alone, with four heavy packages sharing four vCPUs. At 3m the
# step failed on slow runners without a code change; a hang still trips
# the 15 minute job budget above.
#
# Every root-module `go test` that runs on Windows passes -count=1. With
# the result cache on, go test records each file and environment access
@@ -195,11 +191,11 @@ jobs:
# internal/base/testenv holds every such step to it.
- name: test (Windows smoke)
if: env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name == 'pull_request'
timeout-minutes: 20
timeout-minutes: 15
env:
REASONIX_RELEASE_CACHE_GUARD: "1"
WINDOWS_SANDBOX_WAIT_MS: "20000"
run: go test -count=1 -p 4 -timeout=12m ./internal/runtime/agent/... ./internal/platform/appidentity/... ./internal/assembly/boot/... ./internal/state/checkpoint/... ./internal/frontend/cli/... ./internal/session/control/... ./internal/base/filelock/... ./internal/base/fileutil/... ./internal/ext/hook/... ./internal/state/instruction/... ./internal/ext/mcplaunch/... ./internal/platform/notify/... ./internal/base/proc/... ./internal/platform/remote/... ./internal/platform/repair/... ./internal/safety/sandbox/... ./internal/base/sysproxy/... ./internal/state/workspacelease/... ./cmd/...
run: go test -count=1 -p 4 -timeout=8m ./internal/runtime/agent/... ./internal/platform/appidentity/... ./internal/assembly/boot/... ./internal/state/checkpoint/... ./internal/frontend/cli/... ./internal/session/control/... ./internal/base/filelock/... ./internal/base/fileutil/... ./internal/ext/hook/... ./internal/state/instruction/... ./internal/ext/mcplaunch/... ./internal/platform/notify/... ./internal/base/proc/... ./internal/platform/remote/... ./internal/platform/repair/... ./internal/safety/sandbox/... ./internal/base/sysproxy/... ./internal/state/workspacelease/... ./cmd/...
# The general Windows PR smoke list intentionally omits internal/contract/tool.
# Keep the session-temp portability contract covered without widening the
@@ -218,7 +214,7 @@ jobs:
timeout-minutes: 5
run: go test -count=1 -timeout=3m -run '^TestBashPowerShellExecuteDetailedContract$|^TestBashPowerShell51PreflightRejectsAndAndDetailed$|^TestBashPowerShellOutputIsUTF8$|^TestBashPowerShellSurfacesNonZeroExit$|^TestBashPowerShellRejectsChaining$' ./internal/tools/builtin
# Full push suite still uses the same 12m package budget as Windows smoke:
# Full push suite still uses the same 8m package budget as Windows smoke:
# agent/boot/control already hit 180s package timeouts under runner load,
# so 3m fails on slow machines without a code regression. It runs with
# -count=1 for the reason given at the smoke step. A hang still dies here
@@ -234,7 +230,7 @@ jobs:
# Bound sandbox helper children in Windows tests so a failed OS-level
# launch cannot pin the Actions step after Go's package timeout fires.
WINDOWS_SANDBOX_WAIT_MS: "20000"
run: go test -count=1 -p 4 -timeout=12m ./...
run: go test -count=1 -p 4 -timeout=8m ./...
race:
needs: changes
-44
View File
@@ -1,44 +0,0 @@
name: Deploy ops events relay
on:
workflow_dispatch:
permissions:
contents: read
concurrency:
group: deploy-ops-events
cancel-in-progress: false
jobs:
deploy:
if: github.ref == 'refs/heads/platform'
runs-on: ubuntu-latest
environment: production
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
with:
node-version: '22'
cache: npm
cache-dependency-path: workers/ops-events/package-lock.json
- name: Validate
working-directory: workers/ops-events
run: |
npm ci
npm run typecheck
npm test
- name: Deploy
working-directory: workers/ops-events
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
run: npx wrangler deploy
- name: Sync secrets
working-directory: workers/ops-events
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
GITHUB_WEBHOOK_SECRET: ${{ secrets.OPS_GITHUB_WEBHOOK_SECRET }}
OPS_LISTEN_TOKEN: ${{ secrets.OPS_LISTEN_TOKEN }}
OPS_EMIT_TOKEN: ${{ secrets.OPS_EMIT_TOKEN }}
run: |
node -e 'process.stdout.write(JSON.stringify({GITHUB_WEBHOOK_SECRET:process.env.GITHUB_WEBHOOK_SECRET||null,OPS_LISTEN_TOKEN:process.env.OPS_LISTEN_TOKEN||null,OPS_EMIT_TOKEN:process.env.OPS_EMIT_TOKEN||null}))' | npx wrangler secret bulk
+1 -1
View File
@@ -23,7 +23,7 @@ jobs:
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
with:
version: 10
run_install: false
-77
View File
@@ -1,77 +0,0 @@
name: Sync in-app feedback
# Turns feedback submitted from Reasonix Studio into issues and reports issue
# state (in progress, fixed in a tag, won't fix, duplicate) back to the worker,
# and mirrors the reporter's replies onto their issue as bot comments.
# Text from the public reaches the script only through the worker's JSON and
# the GitHub API, never through a shell command line or an expression.
on:
schedule:
- cron: '*/10 * * * *'
- cron: '5 3 * * *'
workflow_dispatch:
inputs:
dry_run:
description: Print what would happen without creating issues or writing back
required: false
default: false
type: boolean
full:
description: Re-read every open entry instead of only recently changed issues
required: false
default: false
type: boolean
permissions:
contents: read
issues: write
concurrency:
group: feedback-sync
cancel-in-progress: false
jobs:
sync:
if: github.repository == 'esengine/DeepSeek-Reasonix'
runs-on: ubuntu-latest
timeout-minutes: 8
steps:
- name: Check secret
id: gate
env:
FEEDBACK_ADMIN_TOKEN: ${{ secrets.FEEDBACK_ADMIN_TOKEN }}
run: |
if [ -z "$FEEDBACK_ADMIN_TOKEN" ]; then
echo "::notice::FEEDBACK_ADMIN_TOKEN is not set; feedback sync skipped"
echo "run=false" >> "$GITHUB_OUTPUT"
else
echo "run=true" >> "$GITHUB_OUTPUT"
fi
- if: steps.gate.outputs.run == 'true'
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
# git tag --contains needs full history; the tag API would cost a request per entry.
fetch-depth: 0
persist-credentials: false
- if: steps.gate.outputs.run == 'true'
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
with:
node-version: '22'
- name: Sync
if: steps.gate.outputs.run == 'true'
env:
FEEDBACK_ADMIN_TOKEN: ${{ secrets.FEEDBACK_ADMIN_TOKEN }}
# crash.reasonix.io answers runner IPs with a managed challenge; workers.dev serves only the converter's admin paths.
FEEDBACK_BASE_URL: https://reasonix-crash-report.reasonix.workers.dev
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
DRY_RUN: ${{ github.event.inputs.dry_run || 'false' }}
FULL: ${{ github.event.inputs.full || 'false' }}
SCHEDULE: ${{ github.event.schedule }}
run: |
set -euo pipefail
args=()
if [ "$DRY_RUN" = "true" ]; then args+=(--dry-run); fi
# The daily schedule sweeps everything the change window could have missed.
if [ "$FULL" = "true" ] || [ "$SCHEDULE" = "5 3 * * *" ]; then args+=(--full); fi
node scripts/feedback-sync.mjs "${args[@]}"
+47 -190
View File
@@ -5,12 +5,10 @@ run-name: Release Studio ${{ inputs.tag || github.ref_name }}
# (studio-v*), its own environment gate, and its own rollback catalog at
# dl.reasonix.io/studio/versions.json.
#
# What it must never touch: the root dl.reasonix.io/versions.json, which is the
# desktop line's catalog. Studio artifacts land under the tag prefix, which
# cannot collide because the tag name itself differs. The highest stable tag
# is the repository's GitHub latest and a candidate is a prerelease; the GitHub
# release carries no root latest.json, because the 1.x updaters' last-resort
# URL is releases/latest/download/latest.json and must keep answering 404.
# What it must never touch: the repository-wide "latest" release (every Studio
# release is a prerelease) and the root dl.reasonix.io/versions.json, which is
# the desktop line's catalog. Studio artifacts land under the tag prefix, which
# cannot collide because the tag name itself differs.
#
# Windows artifacts are Authenticode-signed with a Certum SimplySign cloud
# certificate in two stages: every PE file in the application tree, then the
@@ -76,7 +74,8 @@ jobs:
fi
version="${tag#studio-}"
# Semver's prerelease segment, group 4, read from the grammar rather
# than the spelling.
# than the spelling. Every Studio release carries GitHub's prerelease
# flag, so the tag-level distinction needs a name of its own.
candidate=false
if [[ "${version#v}" =~ $semver ]] && [ -n "${BASH_REMATCH[4]}" ]; then
candidate=true
@@ -758,18 +757,14 @@ jobs:
- name: Cross-compile and archive
env:
VERSION: ${{ needs.resolve.outputs.version }}
SHA: ${{ needs.resolve.outputs.sha }}
run: |
set -euo pipefail
mkdir -p dist
built="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
pkg=reasonix/internal/tools/productdocs
for target in darwin/amd64 darwin/arm64 linux/amd64 linux/arm64 windows/amd64 windows/arm64; do
os="${target%/*}"; arch="${target#*/}"; ext=""
[ "$os" = windows ] && ext=".exe"
CGO_ENABLED=0 GOOS="$os" GOARCH="$arch" go build -trimpath \
-ldflags "-s -w -X main.version=$VERSION -X main.gitCommit=${SHA:0:12} -X main.buildTimeUTC=$built -X $pkg.linkedVersion=$VERSION -X $pkg.linkedRevision=$SHA" \
-o "reasonix$ext" ./cmd/reasonix
-ldflags "-s -w -X main.version=$VERSION" -o "reasonix$ext" ./cmd/reasonix
if [ "$os" = windows ]; then
zip -qj "dist/reasonix-$os-$arch.zip" "reasonix$ext"
else
@@ -995,14 +990,12 @@ jobs:
if [ "$HAS_R2" = "true" ]; then delta_dir=../delta; fi
go run ./cmd/studio-manifest ../dist "${{ needs.resolve.outputs.version }}" "${{ needs.resolve.outputs.tag }}" $delta_dir
# The manifest stays out of the GitHub release (it is mirrored to R2
# under <tag>/ below), and a stable release takes the repository's latest
# only when it is the highest stable studio tag.
# Always a prerelease: this line is not the public product line, and
# GitHub never promotes a prerelease to the repository-wide "latest".
- name: Publish GitHub release
env:
GH_TOKEN: ${{ github.token }}
TAG: ${{ needs.resolve.outputs.tag }}
CANDIDATE: ${{ needs.resolve.outputs.candidate }}
VERSION: ${{ needs.resolve.outputs.version }}
SIGNED_WINDOWS: ${{ needs.windows-sign-installer.result == 'success' }}
run: |
@@ -1043,20 +1036,15 @@ jobs:
printf "**macOS** 已用 Developer ID 签名并公证,打开时不需要清除隔离属性。%s**Linux** 从 \`.deb\` 安装。\n\n" "$windows"
printf '%s\n' '三个平台都能自更新:Linux 走自己的包,Windows 运行下一个安装器,macOS 替换自身 bundle。'
} >> "$notes"
assets=()
for f in dist/*; do
[ "$(basename "$f")" = latest.json ] || assets+=("$f")
done
if gh release view "$TAG" >/dev/null 2>&1; then
gh release edit "$TAG" --notes-file "$notes"
gh release upload "$TAG" "${assets[@]}" --clobber
gh release upload "$TAG" dist/* --clobber
else
decided="$(git tag -l 'studio-v*' | bash scripts/studio-release-flags.sh "$TAG" "$CANDIDATE")"
mapfile -t flags <<<"$decided"
gh release create "$TAG" "${assets[@]}" \
gh release create "$TAG" dist/* \
--title "Reasonix Studio $VERSION" \
--notes-file "$notes" \
"${flags[@]}"
--prerelease \
--latest=false
fi
- name: Mirror to R2 and update the Studio catalog
@@ -1119,125 +1107,50 @@ jobs:
--cache-control "public, max-age=300, stale-if-error=86400"
echo "Studio catalog -> $VERSION"
# A CLI channel that moves while 1.x still publishes would have two lines
# writing it, so every CLI job waits for this check. It runs no repository code.
cli-gate:
name: require the 1.x line to be frozen
needs: resolve
if: vars.STUDIO_PUBLISHES_CLI == 'true'
runs-on: ubuntu-latest
permissions: {}
steps:
- name: Require CLI_PUBLISH_FROZEN
env:
CLI_PUBLISH_FROZEN: ${{ vars.CLI_PUBLISH_FROZEN }}
run: |
if [ "$CLI_PUBLISH_FROZEN" != true ]; then
echo "::error::STUDIO_PUBLISHES_CLI is on but CLI_PUBLISH_FROZEN is not: freeze the 1.x line first, or two lines would write the CLI channels"
exit 1
fi
# The v<version> tag the update pointer needs is created here, by the release
# identity the 1.x line also uses, because only that identity may create v*
# tags. This job checks out nothing and runs no repository code; the one step
# that reads RELEASE_TAG_TOKEN is written inline and only runs on the studio
# tag or branch, through the studio-release environment's ref policy.
cli-tag:
name: create the CLI tag
needs: [resolve, publish, cli-gate]
if: vars.STUDIO_PUBLISHES_CLI == 'true'
runs-on: ubuntu-latest
environment: studio-release
permissions:
contents: read
steps:
- name: Create v<version> on the studio commit
env:
TAG_TOKEN: ${{ secrets.RELEASE_TAG_TOKEN }}
RELEASE_TAG_ACTOR: ${{ vars.RELEASE_TAG_ACTOR }}
READ_TOKEN: ${{ github.token }}
CLI_PUBLISH_FROZEN: ${{ vars.CLI_PUBLISH_FROZEN }}
STUDIO_TAG: ${{ needs.resolve.outputs.tag }}
CLI_VERSION: ${{ needs.resolve.outputs.version }}
APPROVED_SHA: ${{ needs.resolve.outputs.sha }}
run: |
set -euo pipefail
repo=esengine/DeepSeek-Reasonix
stable_re='^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$'
preview_re='^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)-preview\.(0|[1-9][0-9]*)$'
tag="v${CLI_VERSION#v}"
if [[ ! "$tag" =~ $stable_re && ! "$tag" =~ $preview_re ]]; then
echo "$tag publishes no CLI pointer; no tag is created"
exit 0
fi
[ "$CLI_PUBLISH_FROZEN" = true ] || { echo "::error::CLI_PUBLISH_FROZEN must be true"; exit 1; }
[ "$GITHUB_REPOSITORY" = "$repo" ] || { echo "::error::the release tag identity is only used in $repo"; exit 1; }
case "$GITHUB_REF" in
refs/tags/studio-v* | refs/heads/studio) ;;
*) echo "::error::the release tag identity is only used on a studio tag or the studio branch, not $GITHUB_REF"; exit 1 ;;
esac
[[ "$APPROVED_SHA" =~ ^[0-9a-f]{40}$ ]] || { echo "::error::approved SHA is not a full commit SHA"; exit 1; }
[[ "$STUDIO_TAG" == "studio-$CLI_VERSION" ]] || { echo "::error::studio tag does not match the version"; exit 1; }
[ -n "$TAG_TOKEN" ] || { echo "::error::RELEASE_TAG_TOKEN is not configured; the default Actions token cannot create v* tags"; exit 1; }
[[ "$RELEASE_TAG_ACTOR" =~ ^[A-Za-z0-9][A-Za-z0-9-]*$ ]] || { echo "::error::configure RELEASE_TAG_ACTOR with the tag publisher's login"; exit 1; }
read_api() { GH_TOKEN="$READ_TOKEN" gh api "$@"; }
write_api() { GH_TOKEN="$TAG_TOKEN" gh api "$@"; }
actor="$(write_api user)"
jq -e --arg login "$RELEASE_TAG_ACTOR" '.type == "User" and (.login | ascii_downcase) == ($login | ascii_downcase)' <<<"$actor" >/dev/null ||
{ echo "::error::the release tag credential does not identify RELEASE_TAG_ACTOR"; exit 1; }
[ "$(write_api "repos/$repo" --jq .permissions.push)" = true ] ||
{ echo "::error::the release tag identity cannot push to $repo"; exit 1; }
[ "$(read_api "repos/$repo/commits/$STUDIO_TAG" --jq .sha)" = "$APPROVED_SHA" ] ||
{ echo "::error::$STUDIO_TAG does not resolve to $APPROVED_SHA"; exit 1; }
case "$(read_api "repos/$repo/compare/studio...$APPROVED_SHA" --jq .status)" in
behind | identical) ;;
*) echo "::error::$APPROVED_SHA is not on studio history"; exit 1 ;;
esac
# Prints the commit the tag points at; status 1 only when the tag does not exist.
tag_commit() {
local ref kind sha
ref="$(read_api "repos/$repo/git/ref/tags/$tag" --jq '.object.type + " " + .object.sha' 2>"$RUNNER_TEMP/tag.error")" || {
grep -Eiq '404|Not Found' "$RUNNER_TEMP/tag.error" && return 1
cat "$RUNNER_TEMP/tag.error" >&2
exit 1
}
read -r kind sha <<<"$ref"
[ "$kind" != tag ] || sha="$(read_api "repos/$repo/git/tags/$sha" --jq .object.sha)"
echo "$sha"
}
if existing="$(tag_commit)"; then
[ "$existing" = "$APPROVED_SHA" ] || { echo "::error::$tag already exists on $existing, not $APPROVED_SHA"; exit 1; }
echo "$tag already points at $APPROVED_SHA"
exit 0
fi
if ! write_api "repos/$repo/git/refs" -f "ref=refs/tags/$tag" -f "sha=$APPROVED_SHA" >/dev/null 2>"$RUNNER_TEMP/create.error"; then
# A concurrent run may have created the same tag in the meantime.
if existing="$(tag_commit)" && [ "$existing" = "$APPROVED_SHA" ]; then
echo "$tag already points at $APPROVED_SHA"
exit 0
fi
cat "$RUNNER_TEMP/create.error" >&2
exit 1
fi
echo "created $tag at $APPROVED_SHA"
# The CLI's install channels, npm's `reasonix` and the Homebrew tap. The 1.x
# line owns them until this one takes the CLI over; the repository variable
# STUDIO_PUBLISHES_CLI=true hands them here. They run once cli-pointer has
# created the CLI release, because the cask points at its archives. A candidate
# goes to npm's `next` tag and leaves the tap alone: brew has no prerelease
# channel, so a candidate cask would become the default install.
# STUDIO_PUBLISHES_CLI=true hands them here. They run once the release
# exists, because the cask points at its archives. A candidate goes to npm's
# `next` tag and leaves the tap alone: brew has no prerelease channel, so a
# candidate cask would become the default install.
cli-channels:
name: publish the CLI to npm and Homebrew
needs: [resolve, publish, cli-gate, cli-tag, cli-pointer]
needs: [resolve, publish]
if: vars.STUDIO_PUBLISHES_CLI == 'true'
runs-on: ubuntu-latest
# Owner approval before anything reaches npm or the tap: the switch alone
# would otherwise publish on every studio tag.
environment: studio-cli-channels
# id-token lets npm publish --provenance attach a Sigstore attestation; the
# registry token still authenticates the publish itself.
permissions:
contents: read
id-token: write
steps:
# A job naming a missing environment gets an unprotected one, so the
# gate is verified rather than assumed; the tokens below exist only in it.
- name: Require the approval gate on studio-cli-channels
env:
GH_TOKEN: ${{ github.token }}
REPO: ${{ github.repository }}
run: |
set -uo pipefail
resp="$(gh api -i "repos/$REPO/environments/studio-cli-channels" 2>&1)"
status="$(printf '%s\n' "$resp" | sed -n '1s/^HTTP\/[0-9.]* \([0-9]*\).*/\1/p')"
case "$status" in
200) ;;
404)
echo "::error::environment studio-cli-channels does not exist"
exit 1 ;;
*)
echo "::error::reading environment studio-cli-channels as ${GITHUB_ACTOR} failed with HTTP ${status:-unknown}"
exit 1 ;;
esac
reviewers="$(printf '%s\n' "$resp" | sed '1,/^\r\{0,1\}$/d' | jq '[.protection_rules[] | select(.type == "required_reviewers") | .reviewers | length] | add // 0')"
if ! [[ "$reviewers" =~ ^[1-9][0-9]*$ ]]; then
echo "::error::environment studio-cli-channels has no required reviewers (or its protection rules were unreadable: '${reviewers}'); refusing to publish"
exit 1
fi
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
ref: ${{ needs.resolve.outputs.sha }}
@@ -1251,7 +1164,7 @@ jobs:
registry-url: https://registry.npmjs.org
- name: Publish to npm
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
NODE_AUTH_TOKEN: ${{ secrets.STUDIO_NPM_TOKEN }}
VERSION: ${{ needs.resolve.outputs.version }}
run: node npm/build.mjs "$VERSION" --publish
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
@@ -1262,24 +1175,12 @@ jobs:
- name: Update the Homebrew cask
if: needs.resolve.outputs.candidate != 'true'
env:
TAP_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }}
TAP_TOKEN: ${{ secrets.STUDIO_HOMEBREW_TAP_TOKEN }}
VERSION: ${{ needs.resolve.outputs.version }}
TAG: ${{ needs.resolve.outputs.tag }}
run: |
set -euo pipefail
git clone --depth 1 "https://x-access-token:${TAP_TOKEN}@github.com/esengine/homebrew-reasonix.git" tap
decision="$(node scripts/brew-cask-guard.mjs tap/Casks/reasonix.rb "${VERSION#v}")"
case "$decision" in
write) ;;
skip)
echo "the tap's cask is newer than ${VERSION#v}; leaving it"
exit 0
;;
*)
echo "::error::unexpected cask guard decision: '$decision'"
exit 1
;;
esac
bash scripts/render-brew-cask.sh "${VERSION#v}" "$TAG" cli-archives/SHA256SUMS > tap/Casks/reasonix.rb
cd tap
git add Casks/reasonix.rb
@@ -1289,47 +1190,3 @@ jobs:
fi
git -c user.name=reasonix -c [email protected] commit -m "reasonix ${VERSION#v}"
git push origin HEAD:main
# The 1.x updater accepts only a release tagged vMAJOR.MINOR.PATCH whose assets
# sit under that tag, so the archives go to a v<version> release. Only a
# -preview.N version moves preview; other prereleases publish nothing.
cli-pointer:
name: publish the CLI update pointer
needs: [resolve, publish, cli-gate, cli-tag]
if: vars.STUDIO_PUBLISHES_CLI == 'true'
runs-on: ubuntu-latest
concurrency:
group: studio-cli-pointer-${{ contains(needs.resolve.outputs.version, '-') && 'preview' || 'stable' }}
cancel-in-progress: false
permissions:
contents: write # create the CLI release; cli-tag has already made its tag
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
ref: ${{ needs.resolve.outputs.sha }}
persist-credentials: false
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: cli-archives
path: cli-archives
- name: Publish the release, its record and the pointer
env:
GH_TOKEN: ${{ github.token }}
CLI_VERSION: ${{ needs.resolve.outputs.version }}
CLI_PUBLISH_FROZEN: ${{ vars.CLI_PUBLISH_FROZEN }}
APPROVED_SHA: ${{ needs.resolve.outputs.sha }}
STUDIO_TAG: ${{ needs.resolve.outputs.tag }}
REPOSITORY: ${{ github.repository }}
ARCHIVES_DIR: cli-archives
AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
AWS_DEFAULT_REGION: auto
R2_ACCOUNT_ID: ${{ secrets.R2_ACCOUNT_ID }}
R2_BUCKET: ${{ secrets.R2_BUCKET }}
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; the CLI pointer was not written"
exit 1
fi
bash scripts/publish-cli-pointer.sh
-1
View File
@@ -15,7 +15,6 @@
/runtime-resume
/extension-protocol-gen
/reasonix-plugin-example
/mcp-line-counter-kit
/reasonix-studio-host
/signpath-contract
/studio-delta
-22
View File
@@ -181,28 +181,6 @@ porting to `studio`; do not open the same PR against both.
5. Ensure `gofmt -l .` shows no changes
6. Submit a pull request to that same branch
## Pull request policy
**Related changes.** If you have several related changes of one pattern, a
tracking issue plus one pull request is easiest to review.
**Issue first for features.** A change that adds behaviour rather than fixing a
defect should link an issue the maintainers have agreed to before the work
starts. A fix for a reproducible defect may open directly, with the failing test
in the PR. Documentation-only and test-only PRs are reviewed at lower priority.
**Review tiers.**
- Changes under the `sensitive:` paths declared in `REASONIX.md` get a full
review plus a security review, regardless of author.
- Every PR carries the four sections of the PR template: Cause, Blast radius,
Neighbouring behaviours tested, Why this layer.
- The new test must fail without the change.
**Transparency.** The PR template has an optional checkbox for disclosing AI
assistance. It is for transparency only and does not change how a change is
reviewed.
## Reporting issues
Open an issue on GitHub with:
+5 -13
View File
@@ -18,8 +18,6 @@
<a href="https://esengine.github.io/DeepSeek-Reasonix/">Website</a>
&nbsp;·&nbsp;
<strong><a href="https://discord.gg/XF78rEME2D">Discord</a></strong>
&nbsp;·&nbsp;
<strong><a href="https://qm.qq.com/q/i59b0z2R8s">QQ</a></strong>
</p>
<p align="center">
@@ -33,7 +31,6 @@
<a href="https://github.com/esengine/DeepSeek-Reasonix/graphs/contributors"><img src="https://img.shields.io/github/contributors/esengine/DeepSeek-Reasonix.svg?style=flat-square&color=bc8cff&labelColor=161b22&logo=github&logoColor=white" alt="contributors"/></a>
<a href="https://github.com/esengine/DeepSeek-Reasonix/discussions"><img src="https://img.shields.io/github/discussions/esengine/DeepSeek-Reasonix.svg?style=flat-square&color=58a6ff&labelColor=161b22&logo=github&logoColor=white" alt="Discussions"/></a>
<a href="https://discord.gg/XF78rEME2D"><img src="https://img.shields.io/badge/discord-join-5865F2.svg?style=flat-square&labelColor=161b22&logo=discord&logoColor=white" alt="Discord"/></a>
<a href="https://qm.qq.com/q/i59b0z2R8s"><img src="https://img.shields.io/badge/QQ%20group-1093562660-12B7F5.svg?style=flat-square&labelColor=161b22&logo=tencentqq&logoColor=white" alt="QQ group 1093562660"/></a>
</p>
<p align="center">
@@ -44,16 +41,12 @@
<br/>
<p align="center"><strong>Open source · MIT · a single Go binary</strong></p>
<h3 align="center">A reliable coding agent for complex software engineering tasks.</h3>
<h3 align="center">A coding agent you can leave running.</h3>
<p align="center">One local engine, four ways in — terminal, desktop app, browser, or your editor over ACP. Plan mode, permissions, a workspace sandbox and per-turn checkpoints keep a long autonomous run something you can still read and undo.</p>
<p align="center">Maintained by <strong>Huahui Yu</strong>.</p>
> [!IMPORTANT]
> **Community · 加入社区** — bilingual Discord for setup help (`#help` / `#求助`), workflow showcases, and feature ideas. → **<https://discord.gg/XF78rEME2D>**
>
> QQ group **DeepSeek-Reasonix官方群** (`1093562660`) → **<https://qm.qq.com/q/i59b0z2R8s>** ([QR code](./docs/assets/qq-group.svg))
> Douyin: **做游戏的小鱼** (Douyin ID `22703872788`) · [QR code](./docs/assets/douyin.png)
<br/>
@@ -63,7 +56,7 @@ Reasonix ships on two lines. See the [version roadmap announcement](https://gith
| Line | Branch | Status | Get it |
| --- | --- | --- | --- |
| **Reasonix 2.x** | `studio` (this branch) | Active development | [Studio releases](https://github.com/esengine/DeepSeek-Reasonix/releases?q=studio-v&expanded=true) |
| **Reasonix 2.x** | `studio` (this branch) | Active development, pre-release | [Studio releases](https://github.com/esengine/DeepSeek-Reasonix/releases?q=studio-v&expanded=true) |
| **Reasonix 1.x** | [`main-v2`](https://github.com/esengine/DeepSeek-Reasonix/tree/main-v2) | Maintenance / stable | `npm i -g reasonix` · [desktop download](https://reasonix.io/?download=desktop#start) |
- **Want something stable?** Stay on 1.x. It keeps receiving bug fixes,
@@ -106,8 +99,8 @@ Studio, and editor integrations all run the same local Reasonix engine.
Download the package for your platform from the latest
[Studio release](https://github.com/esengine/DeepSeek-Reasonix/releases?q=studio-v&expanded=true)
(tagged `studio-v2.*`; candidates such as `-rc.N` are marked as
pre-releases):
(tagged `studio-v2.*`, published as pre-releases while 2.x is in active
development):
| Platform | Package | Architecture |
| --- | --- | --- |
@@ -272,9 +265,8 @@ coffee, not a contract — donations don't buy feature priority or change how
issues get triaged.
- **International** — PayPal: [paypal.me/yuhuahui](https://paypal.me/yuhuahui)
- **国内** — 微信支付 / 支付宝(扫码)
- **国内** — 微信支付(扫码)
<p align="center">
<img src=".github/sponsor/wechat-pay.jpg" alt="WeChat Pay QR code" width="180"/>
<img src=".github/sponsor/alipay.jpg" alt="Alipay QR code" width="180"/>
</p>
+4 -12
View File
@@ -18,8 +18,6 @@
<a href="https://esengine.github.io/DeepSeek-Reasonix/">官方网站</a>
&nbsp;·&nbsp;
<strong><a href="https://discord.gg/XF78rEME2D">Discord</a></strong>
&nbsp;·&nbsp;
<strong><a href="https://qm.qq.com/q/i59b0z2R8s">QQ</a></strong>
</p>
<p align="center">
@@ -33,7 +31,6 @@
<a href="https://github.com/esengine/DeepSeek-Reasonix/graphs/contributors"><img src="https://img.shields.io/github/contributors/esengine/DeepSeek-Reasonix.svg?style=flat-square&color=bc8cff&labelColor=161b22&logo=github&logoColor=white" alt="contributors"/></a>
<a href="https://github.com/esengine/DeepSeek-Reasonix/discussions"><img src="https://img.shields.io/github/discussions/esengine/DeepSeek-Reasonix.svg?style=flat-square&color=58a6ff&labelColor=161b22&logo=github&logoColor=white" alt="Discussions"/></a>
<a href="https://discord.gg/XF78rEME2D"><img src="https://img.shields.io/badge/discord-join-5865F2.svg?style=flat-square&labelColor=161b22&logo=discord&logoColor=white" alt="Discord"/></a>
<a href="https://qm.qq.com/q/i59b0z2R8s"><img src="https://img.shields.io/badge/QQ%20group-1093562660-12B7F5.svg?style=flat-square&labelColor=161b22&logo=tencentqq&logoColor=white" alt="QQ group 1093562660"/></a>
</p>
<p align="center">
@@ -44,16 +41,12 @@
<br/>
<p align="center"><strong>开源 · MIT · 单个 Go 二进制</strong></p>
<h3 align="center">面向复杂软件工程任务的可靠编码 Agent。</h3>
<h3 align="center">可以一直开着跑的编码 Agent。</h3>
<p align="center">一套本地引擎,四个入口——终端、桌面端、浏览器,或通过 ACP 接入你的编辑器。计划模式、权限、工作区沙箱与逐轮 checkpoint,让长时间自治运行始终可读、可撤销。</p>
<p align="center">维护者:<strong>Huahui Yu</strong>。</p>
> [!IMPORTANT]
> **加入社区 · Community** — 双语 Discord,提供安装答疑(`#help` / `#求助`)、工作流展示与功能想法。→ **<https://discord.gg/XF78rEME2D>**
>
> QQ 群 **DeepSeek-Reasonix官方群**(群号 `1093562660`)→ **<https://qm.qq.com/q/i59b0z2R8s>**([二维码](./docs/assets/qq-group.svg))
> 抖音:**做游戏的小鱼**(抖音号 `22703872788`)· [二维码](./docs/assets/douyin.png)
## 版本
@@ -61,7 +54,7 @@ Reasonix 分为两条版本线,调整的原因见[版本路线公告](https://
| 版本线 | 分支 | 状态 | 获取方式 |
| --- | --- | --- | --- |
| **Reasonix 2.x** | `studio`(当前分支) | 活跃开发 | [Studio 发布页](https://github.com/esengine/DeepSeek-Reasonix/releases?q=studio-v&expanded=true) |
| **Reasonix 2.x** | `studio`(当前分支) | 活跃开发,预发布 | [Studio 发布页](https://github.com/esengine/DeepSeek-Reasonix/releases?q=studio-v&expanded=true) |
| **Reasonix 1.x** | [`main-v2`](https://github.com/esengine/DeepSeek-Reasonix/tree/main-v2) | 维护 / 稳定 | `npm i -g reasonix` · [桌面端下载](https://reasonix.io/?download=desktop#start) |
- **想要稳定**:继续使用 1.x。它会持续收到 Bug 修复、Provider / API 兼容、
@@ -95,7 +88,7 @@ Reasonix 分为两条版本线,调整的原因见[版本路线公告](https://
### Reasonix Studio(2.x)
从最新的 [Studio 发布](https://github.com/esengine/DeepSeek-Reasonix/releases?q=studio-v&expanded=true)
下载对应平台的安装包(标签为 `studio-v2.*`;`-rc.N` 等候选版本标为预发布):
下载对应平台的安装包(标签为 `studio-v2.*`,2.x 活跃开发期间以预发布形式发布):
| 平台 | 安装包 | 架构 |
| --- | --- | --- |
@@ -252,10 +245,9 @@ CLI 进阶用法和详细配置见 **[CLI 命令参考](./docs/CLI.zh-CN.md)**
如果 Reasonix 帮你省了时间或 token,欢迎请杯咖啡。捐助不会换来 feature
优先级,也不会影响 issue 的处理顺序——就是「谢谢」。
- **国内** — 微信支付 / 支付宝(扫下方二维码)
- **国内** — 微信支付(扫下方二维码)
- **海外** — PayPal: [paypal.me/yuhuahui](https://paypal.me/yuhuahui)
<p align="center">
<img src=".github/sponsor/wechat-pay.jpg" alt="微信支付收款码" width="180"/>
<img src=".github/sponsor/alipay.jpg" alt="支付宝收款码" width="180"/>
</p>
-25
View File
@@ -320,31 +320,6 @@ Use `go test ./path/to/target/` to detect cycles **before** pushing. A `[setup f
- **Keep the PR diff minimal.** Only the files relevant to the PR's purpose — no stray changes from other branches.
- **Amend, don't add commits, for review feedback** — keeps the commit history clean.
## Change discipline
**Assess before editing.** For every change, humans and agents list callers,
consumers of the same state, sibling features, reload/replay/restart paths,
error and edge states, TUI/CLI and desktop equivalents, and wire mirrors.
A fix that breaks a neighbouring behaviour is a failed fix.
**Test the neighbourhood.** UI changes add or extend interaction tests for
adjacent functions of the same component or flow (composer: submit, clear,
queue, withdraw, restore, steer, failure, replay after reload) and run the whole
affected suite.
Write tests first; they must fail without the change.
**Fix at the owning layer.** Use the existing unified pattern: no special-case
flags, duplicated paths, or workarounds at the symptom site.
If the design is duplicated or wrong, make the smallest unifying change:
one path instead of two, deleting what becomes dead. Use current language and
framework idioms; do not add a new pattern where one exists.
**PR evidence.** Every PR description carries: Cause, Blast radius,
Neighbouring behaviours tested, and Why this layer.
## Reasonix host checks
The paths below decide what the agent is allowed to do — a wrong change here
+11 -82
View File
@@ -3,12 +3,12 @@
Two questions about long-horizon memory, and the harness that makes their
answers trustworthy.
-mode=preflight build all 18 tasks under every arm and check the contract
-mode=preflight build all 12 tasks under every arm and check the contract
-mode=snippet does a rank-1 hit hand over the whole answer?
-mode=adversarial send a model after the answer through every host surface
-mode=run-search Experiment R: is searchable canonical a real capability?
-mode=run-boundary Experiment I, paired: the 6 efficiency tasks across their boundary
-mode=run-index Experiment I, full: the 6 efficiency tasks x 4 budgets
-mode=run-boundary Experiment I, paired: 6 tasks across their own cue boundary
-mode=run-index Experiment I, full: 6 tasks x 4 index budgets
-mode=calibrate find each index task's PlantAfterGen for its cue tier
`-dry` drives any run mode with a scripted provider, for nothing. Real runs need
@@ -31,8 +31,8 @@ The most likely reason is that two of the six index tasks carry heavy-tail
stopping behaviour that swamps the effect being measured.
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.
direction. Reopening this wants a new corpus, not more samples: see
"Index corpus v2" below.
**Things that turned out not to be problems**, each after being measured rather
than argued about:
@@ -74,83 +74,11 @@ scanned for answer literals before the first provider request and again after
the turn. Any answer reaching the model through a tool that is not `recall`
marks the run contaminated and takes it out of every statistic.
## Index corpus v2
## Index corpus v2, if this is reopened
The efficiency substrate is six tasks that hold on both arms: `i37-dispatchlag`
and `i118-fencelag` (quarter), `i106-budgetseal` and `i70-ingestfloor` (half),
`i47-tenantlease` and `i112-lagquota` (default).
They came out of five batches of candidates, each screened the same way: one
`-mode=run-index` pass over the batch, then two more only for the candidates that
were clean on both boundary arms, and only the candidates that held on both arms
in all three passes stayed. A candidate that does not hold is dropped, not
promoted. The numbers are candidates, per tier:
| batch | quarter | half | default |
| --- | --- | --- | --- |
| first pool, `i31`-`i54` | 1 of 8 | 0 of 8 | 2 of 8 |
| second pool, `i61`-`i84` | 1 of 4 screened | 4 of 8 | 0 of 8 |
| third pool, `i85`-`i108` | 2 of 8 | 8 of 16 | none authored |
| replacement pool, `i109`-`i116` | - | - | 4 of 8 |
| replacement pool, `i92`, `i117`-`i124` | 3 of 9 | - | - |
That table counts candidates that held on both arms in all three passes, which is
not the size of the substrate. 89 candidates were authored, 77 were screened (the
second pool's quarter tier stopped at 4 of 8 and its default tier was never run),
and 25 held: 7 quarter, 12 half, 6 default. The substrate keeps two per tier, so
the other 19 survivors are spares rather than corpus members. Candidates from the
earlier round are not counted here.
| task | tier | cue present | cue absent |
| --- | --- | ---: | ---: |
| `i37-dispatchlag` | quarter | 3/3 | 3/3 |
| `i118-fencelag` | quarter | 3/3 | 3/3 |
| `i106-budgetseal` | half | 3/3 | 3/3 |
| `i70-ingestfloor` | half | 3/3 | 3/3 |
| `i47-tenantlease` | default | 3/3 | 3/3 |
| `i112-lagquota` | default | 3/3 | 3/3 |
Across their 36 judged cells: `SnippetStop` 23, `SnippetThenRead` 10 and
`DefensiveRead` 3 - every cell clean, nothing post-sufficient and nothing
`NeverSufficient`.
Four of the first six were flagged in review for sharing answer values, probe
queries and prompt wording within a tier. Separating them and re-screening those
four changed two of them: `i37` and `i47` held, `i62` and `i51` did not, so they
were replaced - `i90-recoverylag` was tried first and did not hold either, and
`i118-fencelag` and `i112-lagquota` are what did. The separation was not
cosmetic: the shared wording had been carrying two of the six.
Nothing in the task shape separates the survivors from the rest: within the half
tier, plain-number answers survived 0 of 8 in the first pool and 4 of 8 in the
second, and codename answers survived 8 of 16 in the third. An earlier claim here
that the answer kind decides whether a candidate holds does not survive this
table and is withdrawn. The dirty class was `PostSufficientRetrieval` in most
failures - the model re-checking a value it already had - but which tasks hit it
was not predictable from their shape at this sample.
`i01`-`i06` are the frozen study's six tasks, kept as `roleStopping`. Measured
once each with `-mode=run-index -task <id>`, per task and per arm:
| task | tier | cue present | cue absent |
| --- | --- | --- | --- |
| `i01-transport-fallback` | quarter | SnippetStop | DefensiveRead |
| `i02-scheduler-handoff` | quarter | PostSufficientRetrieval | PostSufficientRetrieval |
| `i03-coalescing` | half | PostSufficientRetrieval | PostSufficientRunaway |
| `i04-recovery-fence` | half | DefensiveRead | PostSufficientRunaway |
| `i05-cache-probe` | default | SnippetThenRead | PostSufficientRetrieval |
| `i06-dispatch-handoff` | default | SnippetStop | PostSufficientRetrieval |
Five of the six show post-sufficient behaviour in at least one arm (`i02`-`i06`),
and `i01` is clean on both. The cue-present arm is clean for four of them
(`i01`, `i04`, `i05`, `i06`), which is why they stay in the corpus as the
stopping side rather than being retitled. All six are excluded from `run-index`
and `run-boundary` batches and stay measurable one at a time with `-task <id>`. A
task added to the corpus declares a role, so it cannot arrive without one.
A task qualifies only if it is clean on both of its boundary arms - the
cue-present run and the cue-absent run - in every run of a screening batch. Clean
means a `stopping_class` other than `PostSufficientRetrieval` or
The present index tasks are not a fit substrate: two of six carry stopping
behaviour that dominates the signal. A task should qualify only if a run-index
run reports a `stopping_class` other than `PostSufficientRetrieval` or
`PostSufficientRunaway`: `SnippetStop`, `SnippetThenRead` and `DefensiveRead`
all qualify, and a defensive read is deliberately not counted as waste. The
class comes from the three counts in that report — `searches_after_sufficient`,
@@ -158,7 +86,8 @@ class comes from the three counts in that report — `searches_after_sufficient`
sufficiency was reached and how many rounds followed it. The remaining
requirements still apply — successful tool call, answer in the output rather
than the cue, rank 1, `FirstHitCoverage` complete, one or two simple values,
nothing in the workspace.
nothing in the workspace. Keep i03 and i04 — as a stopping corpus, not an
efficiency one.
`-mode=calibrate` is not a screening tool: it builds and reads back the
fixtures, and reads nothing else, so its cue-visibility profile is the same for
+7 -132
View File
@@ -26,12 +26,6 @@ const (
tierDefault = "default" // visible at default only
)
// role places an index task in the efficiency substrate or the stopping corpus.
const (
roleEfficiency = "efficiency"
roleStopping = "stopping"
)
// plantKind is how a task's target enters the transcript.
const (
plantAssistant = "assistant" // the model's own account: never index-addressed
@@ -51,8 +45,6 @@ type contextTask struct {
AnswerVars []string
CueVar string
CueTier string
// Role is roleEfficiency or roleStopping; it decides the batch a task enters.
Role string
// PlantAfterGen ages the target: mergeFoldIndex trims from its oldest end,
// so what follows a cue decides which budget addresses it. Frozen, because
// a run that searched for it would let the tested code move its goalposts.
@@ -129,20 +121,6 @@ func num(name string, lo, hi int) varSpec {
return varSpec{Name: name, Kind: varInt, Min: lo, Max: hi}
}
// efficiencyTasks is the substrate a study measures on. Every task in it
// qualifies under the corpus bar; the stopping corpus is deliberately excluded,
// because a task carrying post-sufficient stopping behaviour swamps the effect
// an efficiency batch exists to measure.
func efficiencyTasks() []contextTask {
var out []contextTask
for _, t := range indexTasks() {
if t.Role == roleEfficiency {
out = append(out, t)
}
}
return out
}
func searchTasks() []contextTask {
return []contextTask{
{
@@ -214,104 +192,10 @@ func searchTasks() []contextTask {
}
}
// efficiencyIndexTasks is what a study measures on: two tasks per cue tier.
func efficiencyIndexTasks() []contextTask {
out := indexQuarterTasks()
out = append(out, indexHalfTasks()...)
out = append(out, indexDefaultTasks()...)
return out
}
// indexQuarterTasks holds the quarter-tier literals.
func indexQuarterTasks() []contextTask {
func indexTasks() []contextTask {
return []contextTask{
{
ID: "i37-dispatchlag", Experiment: experimentIndex, TargetKind: "tool_input", Role: roleEfficiency,
Prompt: "早前会话里执行过 ./scripts/dispatchlag,它打印出的 dispatch lag epoch 是多少?",
ProbeQuery: "dispatchlag dispatch lag epoch",
Vars: []varSpec{code("cue", "dispatchlag"), code("epoch", "cobalt")},
AnswerVars: []string{"epoch"}, CueVar: "cue", CueTier: tierQuarter,
PlantAfterGen: 4,
Plant: plantCall, CallID: "p37", CallTool: "bash",
CallArgs: `{"command":"./scripts/dispatchlag {{cue}}"}`,
PlantBody: "dispatch lag epoch = {{epoch}}",
},
{
ID: "i118-fencelag", Experiment: experimentIndex, TargetKind: "tool_input", Role: roleEfficiency,
Prompt: "早前会话里运行过 ./scripts/fencelag,它打印出的 fence lag 是多少?",
ProbeQuery: "fencelag fence lag",
Vars: []varSpec{code("cue", "fencelag"), code("v", "slate")},
AnswerVars: []string{"v"}, CueVar: "cue", CueTier: tierQuarter,
PlantAfterGen: 4,
Plant: plantCall, CallID: "x118", CallTool: "bash",
CallArgs: `{"command":"./scripts/fencelag {{cue}}"}`,
PlantBody: "fence lag = {{v}}",
},
}
}
// indexHalfTasks holds the half-tier literals.
func indexHalfTasks() []contextTask {
return []contextTask{
{
ID: "i70-ingestfloor", Experiment: experimentIndex, TargetKind: "tool_input", Role: roleEfficiency,
Prompt: "早前会话里执行过 ./scripts/ingestfloor,它打印出的 route quota 是多少?",
ProbeQuery: "ingestfloor quota",
Vars: []varSpec{code("cue", "ingestfloor"), num("quota", 1200, 9800)},
AnswerVars: []string{"quota"}, CueVar: "cue", CueTier: tierHalf,
PlantAfterGen: 2,
Plant: plantCall, CallID: "q70", CallTool: "bash",
CallArgs: `{"command":"./scripts/ingestfloor {{cue}}"}`,
PlantBody: "route quota = {{quota}}",
},
{
ID: "i106-budgetseal", Experiment: experimentIndex, TargetKind: "tool_input", Role: roleEfficiency,
Prompt: "早前会话里执行过 ./scripts/budgetseal,它打印出的 route quota 是多少?",
ProbeQuery: "budgetseal route quota",
Vars: []varSpec{code("cue", "budgetseal"), code("quota", "amber")},
AnswerVars: []string{"quota"}, CueVar: "cue", CueTier: tierHalf,
PlantAfterGen: 2,
Plant: plantCall, CallID: "h106", CallTool: "bash",
CallArgs: `{"command":"./scripts/budgetseal {{cue}}"}`,
PlantBody: "route quota = {{quota}}",
},
}
}
// indexDefaultTasks holds the default-tier literals.
func indexDefaultTasks() []contextTask {
return []contextTask{
{
ID: "i47-tenantlease", Experiment: experimentIndex, TargetKind: "tool_input", Role: roleEfficiency,
Prompt: "早前会话里执行过 ./scripts/tenantlease,它打印出的 tenant lease(秒)是多少?",
ProbeQuery: "tenantlease tenant lease",
Vars: []varSpec{code("cue", "tenantlease"), {Name: "lease", Kind: varDuration, Min: 15, Max: 300}},
AnswerVars: []string{"lease"}, CueVar: "cue", CueTier: tierDefault,
PlantAfterGen: 0,
Plant: plantCall, CallID: "p47", CallTool: "bash",
CallArgs: `{"command":"./scripts/tenantlease {{cue}}"}`,
PlantBody: "tenant lease = {{lease}}",
},
{
ID: "i112-lagquota", Experiment: experimentIndex, TargetKind: "tool_input", Role: roleEfficiency,
Prompt: "早前会话里执行过 ./scripts/lagquota,它打印出的 lagquota lease 是多少?",
ProbeQuery: "lagquota lagquota lease",
Vars: []varSpec{code("cue", "lagquota"), {Name: "lease", Kind: varDuration, Min: 20, Max: 420}},
AnswerVars: []string{"lease"}, CueVar: "cue", CueTier: tierDefault,
PlantAfterGen: 0,
Plant: plantCall, CallID: "d112", CallTool: "bash",
CallArgs: `{"command":"./scripts/lagquota {{cue}}"}`,
PlantBody: "lagquota lease = {{lease}}",
},
}
}
// stoppingIndexTasks is the frozen study's six tasks, kept out of efficiency
// batches and measurable on their own with -task <id>.
func stoppingIndexTasks() []contextTask {
return []contextTask{
{
ID: "i01-transport-fallback", Experiment: experimentIndex, TargetKind: "tool_input", Role: roleStopping,
ID: "i01-transport-fallback", Experiment: experimentIndex, TargetKind: "tool_input",
Prompt: "transport fallback probe 当时报告的 selector 是什么?",
ProbeQuery: "transport fallback probe selector",
Vars: []varSpec{code("cue", "tfprobe"), code("selector", "fern")},
@@ -322,7 +206,7 @@ func stoppingIndexTasks() []contextTask {
PlantBody: "fallback selector = {{selector}}",
},
{
ID: "i02-scheduler-handoff", Experiment: experimentIndex, TargetKind: "tool_input", Role: roleStopping,
ID: "i02-scheduler-handoff", Experiment: experimentIndex, TargetKind: "tool_input",
Prompt: "scheduler handoff 当时记录的 grace 和 marker 是什么?",
ProbeQuery: "scheduler handoff grace marker",
Vars: []varSpec{
@@ -336,7 +220,7 @@ func stoppingIndexTasks() []contextTask {
PlantBody: "handoff grace = {{grace}}\nmarker = {{marker}}",
},
{
ID: "i03-coalescing", Experiment: experimentIndex, TargetKind: "tool_input", Role: roleStopping,
ID: "i03-coalescing", Experiment: experimentIndex, TargetKind: "tool_input",
Prompt: "job completion 的 coalescing probe 最后记录的 window 和 marker 是多少?",
ProbeQuery: "coalescing policy completion window",
Vars: []varSpec{
@@ -350,7 +234,7 @@ func stoppingIndexTasks() []contextTask {
PlantBody: "completion coalesce window = {{window}}\nmarker = {{marker}}",
},
{
ID: "i04-recovery-fence", Experiment: experimentIndex, TargetKind: "tool_input", Role: roleStopping,
ID: "i04-recovery-fence", Experiment: experimentIndex, TargetKind: "tool_input",
Prompt: "recovery fence 那次检查报告的 marker 和两个 epoch 是什么?",
ProbeQuery: "recovery fence epoch check",
Vars: []varSpec{
@@ -364,7 +248,7 @@ func stoppingIndexTasks() []contextTask {
PlantBody: "{{marker}}\nexpected epoch {{expected}}\nobserved epoch {{observed}}",
},
{
ID: "i05-cache-probe", Experiment: experimentIndex, TargetKind: "tool_input", Role: roleStopping,
ID: "i05-cache-probe", Experiment: experimentIndex, TargetKind: "tool_input",
Prompt: "cache probe 当时记录的 prefix salt 和 scope 是什么?",
ProbeQuery: "cache probe prefix salt scope",
Vars: []varSpec{code("cue", "cacheprobe"), code("salt", "comet"), code("scope", "lineage")},
@@ -375,7 +259,7 @@ func stoppingIndexTasks() []contextTask {
PlantBody: "prefix salt = {{salt}}\nscope = {{scope}}",
},
{
ID: "i06-dispatch-handoff", Experiment: experimentIndex, TargetKind: "tool_input", Role: roleStopping,
ID: "i06-dispatch-handoff", Experiment: experimentIndex, TargetKind: "tool_input",
Prompt: "dispatch handoff probe 当时确认的 mode 和 marker 是什么?",
ProbeQuery: "dispatch handoff probe mode",
Vars: []varSpec{code("cue", "dispatchhandoff"), code("mode", "trigger"), code("marker", "iris")},
@@ -388,12 +272,6 @@ func stoppingIndexTasks() []contextTask {
}
}
// indexTasks is the whole index corpus, the efficiency substrate first.
func indexTasks() []contextTask {
out := efficiencyIndexTasks()
return append(out, stoppingIndexTasks()...)
}
func allTasks() []contextTask {
return append(searchTasks(), indexTasks()...)
}
@@ -454,9 +332,6 @@ func validateCorpus() error {
if t.CueVar == "" {
return fmt.Errorf("%s: an index task needs a cue var", t.ID)
}
if t.Role != roleEfficiency && t.Role != roleStopping {
return fmt.Errorf("%s: an index task needs a role (%s or %s)", t.ID, roleEfficiency, roleStopping)
}
if v, _ := tierScales(t.CueTier); v == nil {
return fmt.Errorf("%s: unknown cue tier %q", t.ID, t.CueTier)
}
+3 -14
View File
@@ -5,7 +5,6 @@ import (
"crypto/rand"
"fmt"
"hash/fnv"
"io"
mathrand "math/rand"
"strings"
)
@@ -91,25 +90,15 @@ func seededRand(parts ...string) *mathrand.Rand {
// liveRand is the generator a paid run uses: every run gets values that existed
// nowhere before it started.
func liveRand() *mathrand.Rand {
rng, err := liveRandFrom(rand.Reader)
if err != nil {
panic("contextbench: no entropy for a live run: " + err.Error())
}
return rng
}
// liveRandFrom seeds a generator from an entropy source, so what a run's values
// are unpredictable from is the source and nothing else.
func liveRandFrom(entropy io.Reader) (*mathrand.Rand, error) {
var seed [8]byte
if _, err := io.ReadFull(entropy, seed[:]); err != nil {
return nil, err
if _, err := rand.Read(seed[:]); err != nil {
panic("contextbench: no entropy for a live run: " + err.Error())
}
var n int64
for _, b := range seed {
n = n<<8 | int64(b)
}
return mathrand.New(mathrand.NewSource(n)), nil
return mathrand.New(mathrand.NewSource(n))
}
// instantiate substitutes {{name}} and refuses anything left over. The grammar
+1 -8
View File
@@ -62,14 +62,7 @@ func main() {
case "adversarial":
os.Exit(runAdversarial(root))
case "run-index":
batch := tasks
if *only == "" {
// A batch measures the efficiency substrate only. Naming one task
// with -task runs that task, role included, so the stopping corpus
// stays measurable on its own.
batch = efficiencyTasks()
}
os.Exit(runExperiment(experimentIndex, root, *dry, batch))
os.Exit(runExperiment(experimentIndex, root, *dry, tasks))
default:
fmt.Fprintf(os.Stderr, "unknown mode %q\n", *mode)
os.Exit(2)
+15 -67
View File
@@ -1,10 +1,7 @@
package main
import (
"bytes"
"fmt"
"maps"
mathrand "math/rand"
"os/exec"
"slices"
"strings"
@@ -22,24 +19,16 @@ func TestCorpusIsWellFormed(t *testing.T) {
if got := len(searchTasks()); got != 6 {
t.Errorf("search experiment has %d tasks, want 6", got)
}
if got := len(indexTasks()); got != 12 {
t.Errorf("index experiment has %d tasks, want 12", got)
if got := len(indexTasks()); got != 6 {
t.Errorf("index experiment has %d tasks, want 6", got)
}
stopping := map[string]bool{}
tiers := map[string]int{}
for _, task := range indexTasks() {
if task.Role == roleStopping {
stopping[task.ID] = true
continue
}
tiers[task.CueTier]++
}
if !stopping["i03-coalescing"] || !stopping["i04-recovery-fence"] {
t.Errorf("the stopping corpus is %v; it must keep i03-coalescing and i04-recovery-fence", stopping)
}
// Two per tier, over the efficiency substrate, is what makes the staircase
// readable: the same task can be compared across the boundary its cue sits
// on, instead of two different questions being compared across two arms.
// Two per tier is what makes the staircase readable: the same task can be
// compared across the boundary its cue sits on, instead of two different
// questions being compared across two arms.
for _, tier := range []string{tierQuarter, tierHalf, tierDefault} {
if tiers[tier] != 2 {
t.Errorf("tier %s has %d tasks, want 2", tier, tiers[tier])
@@ -47,21 +36,6 @@ func TestCorpusIsWellFormed(t *testing.T) {
}
}
// The role decides which batch a task enters; the literal lists are only where
// it is declared, so the two must not drift.
func TestIndexListsMatchTheirRoles(t *testing.T) {
for _, task := range append(append(indexQuarterTasks(), indexHalfTasks()...), indexDefaultTasks()...) {
if task.Role != roleEfficiency {
t.Errorf("%s sits in an efficiency list with role %q", task.ID, task.Role)
}
}
for _, task := range stoppingIndexTasks() {
if task.Role != roleStopping {
t.Errorf("%s sits in the stopping list with role %q", task.ID, task.Role)
}
}
}
// The invariant the whole instantiation layer exists for: no literal a scorer
// needs may be readable in this repository before a run starts.
func TestNoAnswerLiteralExistsInTheRepository(t *testing.T) {
@@ -85,46 +59,20 @@ func TestNoAnswerLiteralExistsInTheRepository(t *testing.T) {
}
}
// A run's values are unpredictable from a previous run only if the live
// generator is seeded from entropy. Comparing answers across two instances
// cannot assert that: a task whose answer is a bounded number collides by
// chance (1/8601 to 1/286 per task), so the check is on the seed source.
func TestLiveRandDrawsFromEntropy(t *testing.T) {
a, b := liveRand(), liveRand()
for range 4 {
if a.Int63() != b.Int63() {
return
}
}
t.Fatal("two live generators produced the same stream: they are seeded alike")
}
// The source alone decides an instance: the same entropy gives the same
// answers, different entropy gives different values.
func TestLiveRandSourceDecidesTheInstance(t *testing.T) {
same := func() *mathrand.Rand {
rng, err := liveRandFrom(bytes.NewReader(bytes.Repeat([]byte{7}, 8)))
// Two runs of the same task must not share an answer, or one leaked run leaks
// every future one.
func TestInstancesDifferBetweenRuns(t *testing.T) {
for _, task := range allTasks() {
a, err := instantiateTask(task, liveRand())
if err != nil {
t.Fatal(err)
}
return rng
}
other, err := liveRandFrom(bytes.NewReader(bytes.Repeat([]byte{9}, 8)))
if err != nil {
t.Fatal(err)
}
if _, err := liveRandFrom(bytes.NewReader(nil)); err == nil {
t.Fatal("an empty entropy source was accepted")
}
for _, task := range allTasks() {
a, _ := instantiateTask(task, same())
b, _ := instantiateTask(task, same())
if !slices.Equal(a.AnswerMarkers, b.AnswerMarkers) || !maps.Equal(a.Vars, b.Vars) {
t.Errorf("%s: the same entropy gave different instances", task.ID)
b, err := instantiateTask(task, liveRand())
if err != nil {
t.Fatal(err)
}
c, _ := instantiateTask(task, other)
if maps.Equal(a.Vars, c.Vars) {
t.Errorf("%s: different entropy gave the same values", task.ID)
if slices.Equal(a.AnswerMarkers, b.AnswerMarkers) {
t.Errorf("%s produced the same answers twice: %v", task.ID, a.AnswerMarkers)
}
}
}
+1 -1
View File
@@ -232,7 +232,7 @@ func runBoundaries(root string) int {
var all []contextMetrics
var cueSide, noCueSide []contextMetrics
for _, t := range efficiencyTasks() {
for _, t := range indexTasks() {
cue, noCue := boundaryPair(t.CueTier)
for _, side := range []struct {
scale string
@@ -1,3 +0,0 @@
alpha=QORVEX-7741
beta=MIRALD-3208
gamma=TESSIK-9155
@@ -1,3 +0,0 @@
def total(items):
"""Sum the items."""
return sum(x for x in items if x > 0)
@@ -1,3 +0,0 @@
def total(items):
"""Sum the items."""
return sum(x for x in items if x > 0)
@@ -1,3 +0,0 @@
def total(items):
"""Sum the items."""
return sum(x for x in items if x > 0)
@@ -1,3 +0,0 @@
def total(items):
"""Sum the items."""
return sum(x for x in items if x > 0)
@@ -1,3 +0,0 @@
def total(items):
"""Sum the items."""
return sum(x for x in items if x > 0)
@@ -1,3 +0,0 @@
def total(items):
"""Sum the items."""
return sum(x for x in items if x > 0)
@@ -1,3 +0,0 @@
def total(items):
"""Sum the items."""
return sum(x for x in items if x > 0)
@@ -1,3 +0,0 @@
def total(items):
"""Sum the items."""
return sum(x for x in items if x > 0)
@@ -1,2 +0,0 @@
value=7
file=limits/budget.py
@@ -1,2 +0,0 @@
handler=h07.py
missing=validate_payload
@@ -1,8 +0,0 @@
def wire_fee(amount):
"""Return the wire fee for `amount`: the greater of 15.0 and 0.1% of it.
A zero or negative amount has no wire fee, so it returns 0.0.
"""
if amount <= 0:
return 0.0
return max(15.0, amount * 0.001)
@@ -1,13 +0,0 @@
def convert(amount, rate):
"""Convert `amount` at `rate` and return the product.
Both units belong to the caller. A negative amount converts unchanged.
"""
return amount * rate
def spread(bid, ask):
"""Return `ask - bid`, the spread between the two quoted prices.
A negative result means the bid is above the ask.
"""
return ask - bid
@@ -1,4 +0,0 @@
EARTH_RADIUS_KM = 6371.0
def dist(left, right):
return abs(left - right) * EARTH_RADIUS_KM
@@ -1,4 +0,0 @@
import geo
def near_by(point, other):
return geo.dist(point, other) < 10
@@ -1,16 +0,0 @@
import json
import sys
def upgrade(row):
parts = row["fullname"].rsplit(" ", 1)
out = {k: v for k, v in row.items() if k != "fullname"}
out["first"] = parts[0]
out["last"] = parts[1] if len(parts) > 1 else ""
return out
def main():
for line in sys.stdin:
line = line.strip()
if not line:
continue
print(json.dumps(upgrade(json.loads(line))))
@@ -1,7 +0,0 @@
import logging
log = logging.getLogger("orders")
def place(order, request_id):
log.info("placing order %s", order["id"], extra={"request_id": request_id})
return {"ok": True}
@@ -1,7 +0,0 @@
import logging
log = logging.getLogger("shipping")
def dispatch(order, request_id):
log.info("dispatching %s", order["id"], extra={"request_id": request_id})
return {"ok": True}
@@ -1,11 +0,0 @@
import math
def p95(sorted_values):
if not sorted_values:
return 0
return sorted_values[math.ceil(len(sorted_values) * 0.95) - 1]
def rate(events, seconds):
if seconds <= 0:
return 0.0
return len(events) / seconds
@@ -1,9 +0,0 @@
import core
_cache = None
def list_records_cached():
global _cache
if _cache is None:
_cache = core.list_records()
return _cache
@@ -1,5 +0,0 @@
import cache
def main():
for k, v in sorted(cache.list_records_cached().items()):
print(f"{k}={v}")
@@ -1,4 +0,0 @@
DATA = {"a": 1, "b": 2}
def list_records():
return dict(DATA)
@@ -1 +0,0 @@
DEFAULTS = {"host": "localhost", "port": 8080}
@@ -1,6 +0,0 @@
import json
import defaults
def load(text):
data = json.loads(text) if text.strip() else {}
return dict(defaults.DEFAULTS, **data)
@@ -1,4 +0,0 @@
import json
def dump(cfg):
return json.dumps(cfg, sort_keys=True)
@@ -1,2 +0,0 @@
STATES = ("new", "paid", "delivered", "cancelled")
TERMINAL = ("delivered", "cancelled")
@@ -1,11 +0,0 @@
import states
LEGAL = {
"new": ("paid", "cancelled"),
"paid": ("delivered", "cancelled"),
"delivered": (),
"cancelled": (),
}
def can_move(a, b):
return b in LEGAL[a]
@@ -1,5 +0,0 @@
import schema, serializer
def display_name(line):
rec = serializer.load(line)
return rec["handle"] or rec["email"]
@@ -1,4 +0,0 @@
FIELDS = ("handle", "email")
def blank():
return {f: "" for f in FIELDS}
@@ -1,4 +0,0 @@
import timings
def is_slow(entry):
return entry["elapsed_s"] > timings.SLOW_SECONDS
@@ -1,4 +0,0 @@
import timings
def within_budget(entries):
return sum(e["elapsed_s"] for e in entries) <= timings.BUDGET_SECONDS
@@ -1,5 +0,0 @@
SLOW_SECONDS = 2.0
BUDGET_SECONDS = 60.0
def record(seconds):
return {"elapsed_s": seconds}
+5 -14
View File
@@ -28,15 +28,6 @@ const trainCorpusDir = "../../benchmarks/train"
// grader test instead.
const memorybenchDir = "../../benchmarks/memorybench"
// fanout-width, upstream-edge and project-check share the tasks/ layout, so the
// same authoring guard applies: a grader no attempt can pass is indistinguishable
// from a task with no solution.
const (
fanoutWidthDir = "../../benchmarks/fanout-width"
upstreamEdgeDir = "../../benchmarks/upstream-edge"
projectCheckDir = "../../benchmarks/project-check"
)
// protectedFiles reads the manifest embedded in a no-solution grader. The
// manifest lives inside verify.sh precisely because e2ebench drops that file
// in only after the run, so the agent never sees which files are watched.
@@ -189,7 +180,7 @@ 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 _, dir := range []string{corpusDir, verificationStressDir, trainCorpusDir, memorybenchDir} {
if !dirExists(dir) {
continue
}
@@ -242,7 +233,7 @@ 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 _, dir := range []string{corpusDir, verificationStressDir, trainCorpusDir} {
if !dirExists(dir) {
continue
}
@@ -259,10 +250,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 dir == trainCorpusDir {
t.Fatal("no solution/: a generated task must prove its grader can pass")
}
t.Skip("no solution/: the e2e suite does not commit reference solutions")
t.Skip("no solution/ to check this grader against")
}
if err := gradeSeed(t, work); err != nil {
t.Fatalf("the reference solution does not grade clean, so no attempt can: %v", err)
-28
View File
@@ -1,28 +0,0 @@
package main
import (
"testing"
"reasonix/internal/base/i18n"
"reasonix/internal/contract/config"
)
func TestKernelLanguageWithShellSystemLanguage(t *testing.T) {
before := i18n.CurrentLanguage()
t.Cleanup(func() { i18n.DetectLanguage(before) })
for _, key := range []string{"REASONIX_LANG", "LC_ALL", "LC_MESSAGES", "LANG"} {
t.Setenv(key, "")
}
t.Setenv("REASONIX_SYSTEM_LANG", "zh-CN")
for _, tt := range []struct{ setting, env, want string }{
{"auto", "", "zh"},
{"en", "", "en"},
{"auto", "en", "en"},
{"zh-TW", "en", "zh-TW"},
} {
t.Setenv("REASONIX_LANG", tt.env)
if got := resolveKernelLanguage(&config.Config{Language: tt.setting}); got != tt.want {
t.Errorf("setting %q, env %q: language = %q, want %q", tt.setting, tt.env, got, tt.want)
}
}
}
-46
View File
@@ -1,46 +0,0 @@
package main
import (
"os"
"path/filepath"
"strings"
"reasonix/internal/assembly/boot"
"reasonix/internal/frontend/serve"
)
// launchWorkspace is the folder the first pane opens. The working directory
// counts only when someone chose it: a Start-menu or Finder launch inherits the
// install directory or the filesystem root, and taking that as a project puts
// the app's own folder in the sidebar again after every removal.
func launchWorkspace(appExe string) string {
home, _ := os.UserHomeDir()
return pickLaunchWorkspace(boot.ResolveWorkspaceRoot(""), appExe, serve.LaunchWorkspaces(), home)
}
func pickLaunchWorkspace(cwd, appExe string, remembered []string, home string) string {
if userChoseDir(cwd, appExe) {
return cwd
}
for _, dir := range remembered {
if st, err := os.Stat(dir); err == nil && st.IsDir() {
return dir
}
}
if home != "" {
return home
}
return cwd
}
func userChoseDir(cwd, appExe string) bool {
if cwd == "" || filepath.Dir(cwd) == cwd {
return false
}
rel, err := filepath.Rel(filepath.Dir(appExe), cwd)
if err != nil {
return true
}
inside := rel == "." || (rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)))
return !inside
}
@@ -1,92 +0,0 @@
package main
import (
"encoding/json"
"os"
"path/filepath"
"testing"
)
func TestUserChoseDirRejectsWhatALauncherInherits(t *testing.T) {
base := t.TempDir()
app := filepath.Join(base, "ReasonixStudio")
exe := filepath.Join(app, "Reasonix Studio.exe")
cases := []struct {
name, cwd, exe string
want bool
}{
{"install directory", app, exe, false},
{"below the install directory", filepath.Join(app, "resources"), exe, false},
{"filesystem root", filepath.VolumeName(base) + string(filepath.Separator), exe, false},
{"a project elsewhere", filepath.Join(base, "project"), exe, true},
{"a sibling sharing the name prefix", app + "-notes", exe, true},
{"unpackaged run keeps the directory", app, "", true},
}
for _, c := range cases {
if got := userChoseDir(c.cwd, c.exe); got != c.want {
t.Errorf("%s: userChoseDir(%q) = %v, want %v", c.name, c.cwd, got, c.want)
}
}
}
func TestPickLaunchWorkspaceFallbackOrder(t *testing.T) {
base := t.TempDir()
app := filepath.Join(base, "ReasonixStudio")
exe := filepath.Join(app, "Reasonix Studio.exe")
mk := func(name string) string {
d := filepath.Join(base, name)
if err := os.MkdirAll(d, 0o755); err != nil {
t.Fatal(err)
}
return d
}
a, b, home := mk("a"), mk("b"), mk("home")
gone := filepath.Join(base, "gone")
proj := mk("proj")
cases := []struct {
name string
cwd string
remembered []string
want string
}{
{"chosen directory wins over the list", proj, []string{a}, proj},
{"list head", app, []string{a, b}, a},
{"stale head skipped", app, []string{gone, b}, b},
{"all stale falls to home", app, []string{gone}, home},
{"empty list falls to home", app, nil, home},
}
for _, c := range cases {
if got := pickLaunchWorkspace(c.cwd, exe, c.remembered, home); got != c.want {
t.Errorf("%s: got %q, want %q", c.name, got, c.want)
}
}
}
func TestLaunchWorkspaceKeepsLaunchProjectAfterSidebarReorder(t *testing.T) {
base := t.TempDir()
home := filepath.Join(base, "state")
app := filepath.Join(base, "app")
a, b := filepath.Join(base, "a"), filepath.Join(base, "b")
for _, dir := range []string{home, app, a, b} {
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
}
t.Setenv("REASONIX_HOME", home)
t.Chdir(app)
data, _ := json.Marshal(map[string]any{"paths": []string{b, a}, "launch": a})
if err := os.WriteFile(filepath.Join(home, "serve-workspaces.json"), data, 0o644); err != nil {
t.Fatal(err)
}
exe := filepath.Join(app, "Reasonix Studio")
if got := launchWorkspace(exe); got != a {
t.Fatalf("launchWorkspace = %q, want the pre-reorder launch project %q", got, a)
}
if err := os.Remove(a); err != nil {
t.Fatal(err)
}
if got := launchWorkspace(exe); got != b {
t.Fatalf("missing launch project = %q, want next remembered folder %q", got, b)
}
}
+11 -35
View File
@@ -19,7 +19,6 @@ import (
"os/signal"
"strings"
"syscall"
"time"
"github.com/gorilla/websocket"
@@ -35,7 +34,6 @@ import (
"reasonix/internal/frontend/traystate"
"reasonix/internal/platform/account"
"reasonix/internal/platform/appupdate"
"reasonix/internal/platform/feedback"
"reasonix/internal/platform/instanceid"
"reasonix/internal/platform/notify"
"reasonix/internal/platform/remotecloud"
@@ -149,7 +147,6 @@ func main() {
// because that pipe is the only channel pointing that way; every log goes to
// logs, which is why no line there can be mistaken for one of these.
func run(lease io.Reader, handshakeTo, logs io.Writer, page string, shell shellIdentity) int {
phases := newStartupPhases(logs, time.Now)
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
if lease != nil {
@@ -162,7 +159,6 @@ func run(lease io.Reader, handshakeTo, logs io.Writer, page string, shell shellI
}
served, err := serve.FindPage(page)
phases.mark(startupPage)
if err != nil {
fmt.Fprintln(logs, "reasonix-studio-host:", err)
return 1
@@ -171,7 +167,7 @@ func run(lease io.Reader, handshakeTo, logs io.Writer, page string, shell shellI
fmt.Fprintln(logs, "reasonix-studio-host: no built page found; serving the kernel only")
}
hub, err := assemble(ctx, logs, handshakeTo, shell, served, phases)
hub, err := assemble(ctx, logs, handshakeTo, shell, served)
if err != nil {
fmt.Fprintln(logs, "reasonix-studio-host:", err)
return 1
@@ -181,7 +177,6 @@ func run(lease io.Reader, handshakeTo, logs io.Writer, page string, shell shellI
defer hub.Shutdown()
bound, err := bind(hub.Handler())
phases.mark(startupBind)
if err != nil {
fmt.Fprintln(logs, "reasonix-studio-host:", err)
return 1
@@ -189,10 +184,7 @@ func run(lease io.Reader, handshakeTo, logs io.Writer, page string, shell shellI
// The credential-writing setup surface opens only on a loopback address,
// and this is the first host that has one to show it.
hub.EnableProviderSetupForListener(bound.listener.Addr().String())
phases.mark(startupSetup)
err = announce(handshakeTo, bound)
phases.mark(startupAnnounce)
if err != nil {
if err := announce(handshakeTo, bound); err != nil {
fmt.Fprintln(logs, "reasonix-studio-host:", err)
return 1
}
@@ -289,15 +281,13 @@ func resolveKernelLanguage(cfg *config.Config) string {
// assemble builds the hub this host serves: one pane on the workspace it was
// launched in, carrying the capabilities a local window may exercise.
func assemble(ctx context.Context, logs, handshakeTo io.Writer, shell shellIdentity, page fs.FS, phases *startupPhases) (*serve.Hub, error) {
func assemble(ctx context.Context, logs, handshakeTo io.Writer, shell shellIdentity, page fs.FS) (*serve.Hub, error) {
// The one-time upgrades belong to whichever entry point starts first; a
// person who only ever opens the window would otherwise never get them.
if _, err := config.ApplyUserConfigUpgradesOnStartup(config.UserConfigPath()); err != nil {
fmt.Fprintln(logs, "reasonix-studio-host: config upgrade:", err)
}
phases.mark(startupConfigUpgrade)
cfg, err := config.Load()
phases.mark(startupConfig)
if err != nil {
return nil, err
}
@@ -321,24 +311,15 @@ func assemble(ctx context.Context, logs, handshakeTo io.Writer, shell shellIdent
paneSink = reporter.Wrap(paneSink)
}
}
phases.mark(startupSinks)
root := launchWorkspace(shell.exe)
phases.mark(startupWorkspace)
root := boot.ResolveWorkspaceRoot("")
built, err := boot.BuildRuntime(ctx, boot.Options{
Version: version,
WorkspaceRoot: root,
SessionDir: serve.SessionDirFor(root),
Sink: paneSink,
Stderr: logs,
OnPhase: phases.boot,
StatsSource: surface.Desktop,
FeedbackSurface: feedback.SurfaceStudio,
// A stale default_model must not keep the window from opening to fix it.
OpenOnFallbackModel: true,
CleanupPendingReconciler: serve.BackgroundCleanupReconciler,
Version: version,
WorkspaceRoot: root,
SessionDir: serve.SessionDirFor(root),
Sink: paneSink,
Stderr: logs,
StatsSource: surface.Desktop,
})
phases.mark(startupRuntime)
if err != nil {
return nil, err
}
@@ -353,7 +334,6 @@ func assemble(ctx context.Context, logs, handshakeTo io.Writer, shell shellIdent
// Shut until the person at the window opens it; the context ending closes
// it with the rest of the kernel, unpairing every device.
share := serve.NewDeviceShare(page)
share.RestorePort(cfg.SharePort())
go func() {
<-ctx.Done()
share.Close()
@@ -378,16 +358,12 @@ func assemble(ctx context.Context, logs, handshakeTo io.Writer, shell shellIdent
srv := serve.New(built.Controller, bc, hubCfg)
srv.SetPaneSink(paneSink)
srv.AdoptRuntime(built)
phases.mark(startupHub)
_, err = hub.Adopt(srv, bc)
phases.mark(startupAdopt)
if err != nil {
if _, err := hub.Adopt(srv, bc); err != nil {
hub.Shutdown()
return nil, err
}
hub.StartRecoveryGC(ctx)
startCloudRemote(ctx, cfg, logs, hub)
phases.mark(startupBackground)
return hub, nil
}
+1 -1
View File
@@ -487,7 +487,7 @@ func TestAssembleAppliesTheStartupUpgrades(t *testing.T) {
t.Fatal(err)
}
t.Chdir(testenv.TempDir(t))
hub, err := assemble(t.Context(), io.Discard, io.Discard, shellIdentity{}, nil, newStartupPhases(io.Discard, time.Now))
hub, err := assemble(t.Context(), io.Discard, io.Discard, shellIdentity{}, nil)
if err != nil {
t.Fatalf("assemble: %v", err)
}
-50
View File
@@ -1,50 +0,0 @@
package main
import (
"fmt"
"io"
"time"
"reasonix/internal/assembly/boot"
)
type startupPhase string
const (
startupPage startupPhase = "page"
startupConfigUpgrade startupPhase = "config-upgrade"
startupConfig startupPhase = "config"
startupSinks startupPhase = "sinks"
startupWorkspace startupPhase = "workspace"
startupRuntime startupPhase = "runtime"
startupHub startupPhase = "hub"
startupAdopt startupPhase = "adopt"
startupBackground startupPhase = "background"
startupBind startupPhase = "bind"
startupSetup startupPhase = "provider-setup"
startupAnnounce startupPhase = "announce"
)
type startupPhases struct {
logs io.Writer
now func() time.Time
last time.Time
}
func newStartupPhases(logs io.Writer, now func() time.Time) *startupPhases {
return &startupPhases{logs: logs, now: now, last: now()}
}
func (p *startupPhases) mark(phase startupPhase) {
now := p.now()
p.write(phase, now.Sub(p.last))
p.last = now
}
func (p *startupPhases) write(phase startupPhase, elapsed time.Duration) {
fmt.Fprintf(p.logs, "reasonix-studio-host: startup phase=%s elapsed_ms=%d\n", phase, elapsed.Milliseconds())
}
func (p *startupPhases) boot(phase boot.Phase) {
p.write(startupPhase("boot."+phase.Name), phase.D)
}
-26
View File
@@ -1,26 +0,0 @@
package main
import (
"bytes"
"testing"
"time"
"reasonix/internal/assembly/boot"
)
func TestStartupPhasesUseElapsedMilliseconds(t *testing.T) {
var logs bytes.Buffer
now := time.Unix(100, 0)
p := newStartupPhases(&logs, func() time.Time { return now })
now = now.Add(1250 * time.Millisecond)
p.mark(startupPage)
now = now.Add(7 * time.Millisecond)
p.boot(boot.Phase{Name: "sessions", D: 3 * time.Millisecond})
p.mark(startupRuntime)
want := "reasonix-studio-host: startup phase=page elapsed_ms=1250\n" +
"reasonix-studio-host: startup phase=boot.sessions elapsed_ms=3\n" +
"reasonix-studio-host: startup phase=runtime elapsed_ms=7\n"
if got := logs.String(); got != want {
t.Fatalf("phase log = %q, want %q", got, want)
}
}
@@ -1,100 +0,0 @@
package main
import (
"context"
"encoding/json"
"io"
"net/http"
"strings"
"testing"
"time"
"reasonix/internal/base/i18n"
"reasonix/internal/base/testenv"
"reasonix/internal/contract/config"
"reasonix/internal/frontend/serve"
)
func TestAssembledTrayLanguage(t *testing.T) {
before := i18n.CurrentLanguage()
t.Cleanup(func() { i18n.DetectLanguage(before) })
for _, tt := range []struct{ name, system, desktop, env, locale, want string }{
{"system", "zh-Hans-CN", "auto", "", "", "zh"},
{"traditional", "zh-Hant-TW", "auto", "", "", "zh-TW"},
{"desktop", "zh-Hans-CN", "en", "", "", "en"},
{"reasonix", "zh-Hans-CN", "auto", "en-US", "", "en"},
{"desktop-over-env", "en-US", "zh", "en", "", "zh"},
{"c-locale", "zh-Hans-CN", "auto", "", "C", "en"},
{"posix-locale", "zh-Hans-CN", "auto", "", "POSIX.UTF-8", "en"},
} {
t.Run(tt.name, func(t *testing.T) {
t.Setenv("REASONIX_HOME", testenv.TempDir(t))
t.Chdir(testenv.TempDir(t))
for _, key := range []string{"REASONIX_LANG", "LC_ALL", "LC_MESSAGES", "LANG"} {
t.Setenv(key, "")
}
t.Setenv("REASONIX_SYSTEM_LANG", tt.system)
t.Setenv("REASONIX_LANG", tt.env)
t.Setenv("LANG", tt.locale)
cfg := config.Default()
disabled := false
cfg.Desktop.Telemetry, cfg.Desktop.Metrics = &disabled, &disabled
if err := cfg.SetDesktopLanguage(tt.desktop); err != nil {
t.Fatal(err)
}
if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithCancel(t.Context())
defer cancel()
hub, err := assemble(ctx, io.Discard, io.Discard, shellIdentity{}, nil, newStartupPhases(io.Discard, time.Now))
if err != nil {
t.Fatalf("assemble: %v", err)
}
defer hub.Shutdown()
b, err := bind(hub.Handler())
if err != nil {
t.Fatal(err)
}
done := make(chan error, 1)
go func() { done <- b.serve(ctx) }()
defer func() {
cancel()
select {
case err := <-done:
if err != nil {
t.Error(err)
}
case <-time.After(30 * time.Second):
t.Error("host did not drain")
}
}()
check := func(want string) {
t.Helper()
status, body := ask(t, b, http.MethodGet, "/tray/state", nil)
if status != http.StatusOK {
t.Fatalf("tray status = %d", status)
}
var state serve.TrayState
if err := json.Unmarshal([]byte(body), &state); err != nil {
t.Fatal(err)
}
say := i18n.Catalog(want)
labels := serve.TrayLabels{Open: say.TrayOpen, CloseToTray: say.TrayCloseToTray, Quit: say.TrayQuit}
if state.Labels != labels || state.Line != say.TrayIdle {
t.Fatalf("tray = %+v, want labels %+v and line %q", state, labels, say.TrayIdle)
}
}
check(tt.want)
for _, lang := range []string{"en", "zh"} {
status, _ := ask(t, b, http.MethodPost, "/appearance", func(r *http.Request) {
r.Body = io.NopCloser(strings.NewReader(`{"language":"` + lang + `"}`))
})
if status != http.StatusOK {
t.Fatalf("appearance status = %d", status)
}
check(lang)
}
})
}
}
-37
View File
@@ -71,40 +71,3 @@ func TestTheHostPointsTheKernelsCatalogueAtTheConfiguredLanguage(t *testing.T) {
}
}
}
// Sweeping a session directory costs a handful of file operations per
// transcript, and the window cannot open until the handshake. A build that
// sweeps inline makes the first launch after the cache went cold wait on
// history it never displays.
func TestTheHostsFirstBuildSweepsSessionsInTheBackground(t *testing.T) {
file, err := parser.ParseFile(token.NewFileSet(), "main.go", nil, 0)
if err != nil {
t.Fatalf("parse main.go: %v", err)
}
found := false
ast.Inspect(file, func(n ast.Node) bool {
lit, ok := n.(*ast.CompositeLit)
if !ok {
return true
}
sel, ok := lit.Type.(*ast.SelectorExpr)
if !ok || sel.Sel.Name != "Options" {
return true
}
for _, elt := range lit.Elts {
kv, ok := elt.(*ast.KeyValueExpr)
if !ok {
continue
}
if key, ok := kv.Key.(*ast.Ident); ok && key.Name == "CleanupPendingReconciler" {
if v, ok := kv.Value.(*ast.SelectorExpr); ok && v.Sel.Name == "BackgroundCleanupReconciler" {
found = true
}
}
}
return true
})
if !found {
t.Fatal("the host's boot.Options does not set CleanupPendingReconciler to serve.BackgroundCleanupReconciler")
}
}
+99 -23
View File
@@ -2,6 +2,7 @@ package main
import (
"os"
"os/exec"
"path/filepath"
"regexp"
"strings"
@@ -10,7 +11,10 @@ import (
"gopkg.in/yaml.v3"
)
var cliChannelSecrets = []string{"NPM_TOKEN", "HOMEBREW_TAP_TOKEN"}
var cliChannelSecrets = []string{"STUDIO_NPM_TOKEN", "STUDIO_HOMEBREW_TAP_TOKEN"}
// The 1.x line reads these repository secrets; studio must never touch them.
var legacyRegistrySecrets = []string{"NPM_TOKEN", "HOMEBREW_TAP_TOKEN"}
func parseWorkflowFile(t *testing.T, path string) *yaml.Node {
t.Helper()
@@ -90,8 +94,8 @@ func TestCLIChannelsJobIsGatedAndAttested(t *testing.T) {
if job == nil {
t.Fatal("cli-channels job is missing")
}
if mappingValue(job, "environment") != nil {
t.Fatal("cli-channels must not declare an environment; the switch and the tag rule are its controls")
if got := mappingScalar(job, "environment"); got != "studio-cli-channels" {
t.Fatalf("cli-channels environment = %q, want studio-cli-channels", got)
}
if got := mappingScalar(job, "if"); got != "vars.STUDIO_PUBLISHES_CLI == 'true'" {
t.Fatalf("cli-channels if = %q, want the STUDIO_PUBLISHES_CLI switch", got)
@@ -109,11 +113,18 @@ func TestCLIChannelsJobIsGatedAndAttested(t *testing.T) {
t.Fatalf("cli-channels permission %s = %q, want %q", scope, got, level)
}
}
if !nodeContains(job, "environments/studio-cli-channels") || !nodeContains(job, "required_reviewers") {
t.Fatal("cli-channels must verify the environment's required reviewers before publishing")
}
assertGateBeforePublishing(t, job)
for _, name := range cliChannelSecrets {
if !secretRefPattern(name).MatchString(scalarString(job)) {
t.Fatalf("cli-channels does not read secrets.%s", name)
}
}
if name, found := refersTo(job, legacyRegistrySecrets); found {
t.Fatalf("cli-channels reads %s; it must use the environment-only STUDIO_* names", name)
}
}
func scalarString(node *yaml.Node) string {
@@ -122,20 +133,6 @@ func scalarString(node *yaml.Node) string {
return strings.Join(scalars, "\n")
}
func TestReleaseStudioGrantsNoWriteAll(t *testing.T) {
root := parseWorkflowFile(t, "../../.github/workflows/release-studio.yml")
if top := mappingValue(root, "permissions"); top != nil && top.Kind == yaml.ScalarNode && top.Value == "write-all" {
t.Error("release-studio.yml grants write-all")
}
jobs := jobsOf(t, root)
for i := 0; i+1 < len(jobs.Content); i += 2 {
name, job := jobs.Content[i].Value, jobs.Content[i+1]
if p := mappingValue(job, "permissions"); p != nil && p.Kind == yaml.ScalarNode && p.Value == "write-all" {
t.Errorf("release-studio.yml job %s grants write-all", name)
}
}
}
func TestNoWorkflowGrantsIDTokenOrBroadPermissionsOutsideCLIChannels(t *testing.T) {
files, err := filepath.Glob("../../.github/workflows/*.yml")
if err != nil || len(files) == 0 {
@@ -176,7 +173,7 @@ func TestOnlyCLIChannelsReferencesRegistrySecrets(t *testing.T) {
if err != nil || len(files) == 0 {
t.Fatalf("no workflows found: %v", err)
}
guarded := cliChannelSecrets
guarded := append(append([]string{}, cliChannelSecrets...), legacyRegistrySecrets...)
for _, file := range files {
root := parseWorkflowFile(t, file)
jobs := mappingValue(root, "jobs")
@@ -199,18 +196,18 @@ func TestOnlyCLIChannelsReferencesRegistrySecrets(t *testing.T) {
continue
}
if name, found := refersTo(root, cliChannelSecrets); found {
t.Errorf("%s reaches %s, which only release-studio.yml cli-channels may read", file, name)
t.Errorf("%s reaches %s, which belongs to studio's cli-channels", file, name)
}
}
}
func TestSecretReferenceDetectionCoversBypassForms(t *testing.T) {
for _, text := range []string{
"${{ secrets.NPM_TOKEN }}",
"${{ secrets['NPM_TOKEN'] }}",
`${{ secrets["NPM_TOKEN"] }}`,
"${{ secrets.STUDIO_NPM_TOKEN }}",
"${{ secrets['STUDIO_NPM_TOKEN'] }}",
`${{ secrets["STUDIO_NPM_TOKEN"] }}`,
"${{ toJSON(secrets) }}",
"${{ secrets[format('NPM_{0}', 'TOKEN')] }}",
"${{ secrets[format('STUDIO_{0}', 'NPM_TOKEN')] }}",
} {
var doc yaml.Node
if err := yaml.Unmarshal([]byte("run: \""+regexp.MustCompile(`"`).ReplaceAllString(text, `\"`)+"\"\n"), &doc); err != nil {
@@ -228,3 +225,82 @@ func TestSecretReferenceDetectionCoversBypassForms(t *testing.T) {
t.Error("an unrelated secret was reported")
}
}
func assertGateBeforePublishing(t *testing.T, job *yaml.Node) {
t.Helper()
steps := mappingValue(job, "steps")
if steps == nil || steps.Kind != yaml.SequenceNode {
t.Fatal("cli-channels has no steps")
}
gate := -1
for i, step := range steps.Content {
if nodeContains(mappingValue(step, "run"), "environments/studio-cli-channels") {
gate = i
break
}
}
if gate < 0 {
t.Fatal("cli-channels has no environment check step")
}
for i, step := range steps.Content[:gate] {
if run := mappingScalar(step, "run"); run != "" || mappingValue(step, "env") != nil {
t.Errorf("step %d runs or gets credentials before the environment check", i)
}
}
for i, step := range steps.Content {
if i > gate {
continue
}
for _, name := range append(append([]string{}, cliChannelSecrets...), legacyRegistrySecrets...) {
if nodeContains(step, name) {
t.Errorf("step %d references %s before or at the environment check", i, name)
}
}
}
}
func TestEnvironmentCheckStepFailsClosed(t *testing.T) {
bash, err := exec.LookPath("bash")
if err != nil {
t.Skip("bash not available")
}
root := parseWorkflowFile(t, "../../.github/workflows/release-studio.yml")
job := mappingValue(jobsOf(t, root), "cli-channels")
var script string
for _, step := range mappingValue(job, "steps").Content {
if run := mappingScalar(step, "run"); strings.Contains(run, "environments/studio-cli-channels") {
script = run
break
}
}
if script == "" {
t.Fatal("environment check step not found")
}
reviewers := `{"type":"required_reviewers","reviewers":[{"a":1}]}`
cases := []struct {
name, response string
pass bool
}{
{"one reviewer", "HTTP/2.0 200 OK\n\n{\"protection_rules\":[" + reviewers + "]}", true},
{"no rules", "HTTP/2.0 200 OK\n\n{\"protection_rules\":[]}", false},
{"two JSON documents", "HTTP/2.0 200 OK\n\n{\"protection_rules\":[]}{\"protection_rules\":[]}", false},
{"empty body", "HTTP/2.0 200 OK\n\n", false},
{"non-numeric body", "HTTP/2.0 200 OK\n\n\"x\"", false},
{"missing environment", "HTTP/2.0 404 Not Found\n\n{\"message\":\"Not Found\"}", false},
{"server error", "HTTP/2.0 500 Error\n\n{}", false},
}
for _, tc := range cases {
dir := t.TempDir()
fake := "#!/bin/sh\ncat <<'EOF'\n" + tc.response + "\nEOF\n"
if err := os.WriteFile(filepath.Join(dir, "gh"), []byte(fake), 0o755); err != nil {
t.Fatal(err)
}
cmd := exec.Command(bash, "-c", script+"\necho PASSED")
cmd.Env = append(os.Environ(), "PATH="+dir+string(os.PathListSeparator)+os.Getenv("PATH"), "REPO=x/y", "GH_TOKEN=x", "GITHUB_ACTOR=t")
out, err := cmd.CombinedOutput()
passed := err == nil && strings.Contains(string(out), "PASSED")
if passed != tc.pass {
t.Errorf("%s: passed=%v, want %v\n%s", tc.name, passed, tc.pass, out)
}
}
}
-165
View File
@@ -1,165 +0,0 @@
package main
import (
"path/filepath"
"strings"
"testing"
"gopkg.in/yaml.v3"
)
func TestCLIPointerJobIsSwitchedSerializedAndNarrow(t *testing.T) {
root := parseWorkflowFile(t, "../../.github/workflows/release-studio.yml")
job := mappingValue(jobsOf(t, root), "cli-pointer")
if job == nil {
t.Fatal("cli-pointer job is missing")
}
if got := mappingScalar(job, "if"); got != "vars.STUDIO_PUBLISHES_CLI == 'true'" {
t.Fatalf("cli-pointer if = %q, want the STUDIO_PUBLISHES_CLI switch", got)
}
needs := mappingValue(job, "needs")
if needs == nil || needs.Kind != yaml.SequenceNode {
t.Fatal("cli-pointer must list its needs")
}
seen := map[string]bool{}
for _, n := range needs.Content {
seen[n.Value] = true
}
if !seen["resolve"] || !seen["publish"] || !seen["cli-gate"] {
t.Fatalf("cli-pointer needs %v, want resolve, publish and cli-gate", seen)
}
concurrency := mappingValue(job, "concurrency")
if concurrency == nil || !strings.HasPrefix(mappingScalar(concurrency, "group"), "studio-cli-pointer-") {
t.Fatal("cli-pointer must take its own per-channel lock, never the 1.x workflow's release-cli-<channel> group")
}
if mappingScalar(concurrency, "cancel-in-progress") != "false" {
t.Fatal("cli-pointer must not cancel a run that is mid-write")
}
permissions := mappingValue(job, "permissions")
if permissions == nil || permissions.Kind != yaml.MappingNode || len(permissions.Content) != 2 ||
mappingScalar(permissions, "contents") != "write" {
t.Fatal("cli-pointer permissions must be exactly contents: write")
}
if !nodeContains(job, "scripts/publish-cli-pointer.sh") {
t.Fatal("cli-pointer must run scripts/publish-cli-pointer.sh")
}
if !nodeContains(job, "vars.CLI_PUBLISH_FROZEN") {
t.Fatal("cli-pointer must pass vars.CLI_PUBLISH_FROZEN to its script")
}
if name, found := refersTo(job, append([]string{"RELEASE_TAG_TOKEN"}, cliChannelSecrets...)); found {
t.Fatalf("cli-pointer reads %s; registry credentials belong to cli-channels only", name)
}
}
func TestCLIGateBlocksEveryCLIChannel(t *testing.T) {
root := parseWorkflowFile(t, "../../.github/workflows/release-studio.yml")
jobs := jobsOf(t, root)
gate := mappingValue(jobs, "cli-gate")
if gate == nil {
t.Fatal("cli-gate job is missing")
}
if got := mappingScalar(gate, "if"); got != "vars.STUDIO_PUBLISHES_CLI == 'true'" {
t.Fatalf("cli-gate if = %q, want the STUDIO_PUBLISHES_CLI switch", got)
}
if !nodeContains(gate, "vars.CLI_PUBLISH_FROZEN") {
t.Fatal("cli-gate must read vars.CLI_PUBLISH_FROZEN")
}
if nodeContains(gate, "actions/checkout") || nodeContains(gate, "secrets.") {
t.Fatal("cli-gate must run no repository code and read no secret")
}
for _, name := range []string{"cli-tag", "cli-channels", "cli-pointer"} {
needs := mappingValue(mappingValue(jobs, name), "needs")
found := false
for _, n := range needs.Content {
found = found || n.Value == "cli-gate"
}
if !found {
t.Fatalf("%s does not wait for cli-gate", name)
}
}
for _, name := range []string{"cli-channels", "cli-pointer"} {
found := false
for _, n := range mappingValue(mappingValue(jobs, name), "needs").Content {
found = found || n.Value == "cli-tag"
}
if !found {
t.Fatalf("%s does not wait for cli-tag", name)
}
}
found := false
for _, n := range mappingValue(mappingValue(jobs, "cli-channels"), "needs").Content {
found = found || n.Value == "cli-pointer"
}
if !found {
t.Fatal("cli-channels does not wait for cli-pointer: the cask would name archives that may not exist yet")
}
}
var releaseTagSecrets = []string{"RELEASE_TAG_TOKEN"}
func workflowFiles(t *testing.T) []string {
t.Helper()
var files []string
for _, pattern := range []string{"*.yml", "*.yaml"} {
found, err := filepath.Glob("../../.github/workflows/" + pattern)
if err != nil {
t.Fatal(err)
}
files = append(files, found...)
}
if len(files) == 0 {
t.Fatal("no workflows found")
}
return files
}
// The token may appear in one inline step of Studio's cli-tag job and in no
// other env scope: not the workflow's, not a job's, not another step's.
func TestReleaseTagTokenIsReadByOneInlineStep(t *testing.T) {
holders := 0
for _, file := range workflowFiles(t) {
root := parseWorkflowFile(t, file)
studio := filepath.Base(file) == "release-studio.yml"
for i := 0; i+1 < len(root.Content); i += 2 {
if root.Content[i].Value == "jobs" {
continue
}
if what, found := refersTo(root.Content[i+1], releaseTagSecrets); found {
t.Errorf("%s reaches %s at workflow level (%s)", file, what, root.Content[i].Value)
}
}
jobs := mappingValue(root, "jobs")
if jobs == nil {
continue
}
for i := 0; i+1 < len(jobs.Content); i += 2 {
id, job := jobs.Content[i].Value, jobs.Content[i+1]
allowed := studio && id == "cli-tag"
for j := 0; j+1 < len(job.Content); j += 2 {
key, value := job.Content[j].Value, job.Content[j+1]
if key != "steps" {
if what, found := refersTo(value, releaseTagSecrets); found {
t.Errorf("%s job %s reaches %s in %s; only a step of cli-tag may", file, id, what, key)
}
continue
}
for _, step := range value.Content {
if what, found := refersTo(step, releaseTagSecrets); found {
holders++
if !allowed {
t.Errorf("%s job %s reaches %s; only Studio's cli-tag step may", file, id, what)
}
if mappingScalar(step, "uses") != "" {
t.Errorf("%s job %s: the token step must be inline shell, not an action", file, id)
}
} else if allowed && mappingScalar(step, "uses") != "" {
t.Errorf("cli-tag must check out nothing and run no action")
}
}
}
}
}
if holders != 1 {
t.Fatalf("%d steps read RELEASE_TAG_TOKEN, want exactly one", holders)
}
}
@@ -1,62 +0,0 @@
package main
import (
"gopkg.in/yaml.v3"
"strings"
"testing"
)
func stepNamed(t *testing.T, job *yaml.Node, name string) *yaml.Node {
t.Helper()
steps := mappingValue(job, "steps")
for _, step := range steps.Content {
if mappingScalar(step, "name") == name {
return step
}
}
t.Fatalf("no step %q", name)
return nil
}
func TestStudioReleaseDecidesLatestFromTheFlagsScript(t *testing.T) {
root := parseWorkflowFile(t, "../../.github/workflows/release-studio.yml")
publish := mappingValue(jobsOf(t, root), "publish")
step := stepNamed(t, publish, "Publish GitHub release")
run := mappingScalar(step, "run")
env := mappingValue(step, "env")
if got := mappingScalar(env, "CANDIDATE"); got != "${{ needs.resolve.outputs.candidate }}" {
t.Fatalf("CANDIDATE = %q, want the resolve job's grammar-derived output", got)
}
if !strings.Contains(run, `scripts/studio-release-flags.sh "$TAG" "$CANDIDATE"`) {
t.Fatal("the release flags do not come from scripts/studio-release-flags.sh")
}
for _, flag := range []string{"--prerelease", "--latest"} {
if strings.Contains(run, flag) {
t.Errorf("the publish step spells %s itself; the decision belongs to the flags script", flag)
}
}
if strings.Contains(run, "gh release edit") && strings.Contains(run[strings.Index(run, "gh release edit"):], "--latest") {
t.Error("an existing release must not have its latest flag edited")
}
}
func TestStudioGitHubReleaseCarriesNoRootManifest(t *testing.T) {
root := parseWorkflowFile(t, "../../.github/workflows/release-studio.yml")
publish := mappingValue(jobsOf(t, root), "publish")
run := mappingScalar(stepNamed(t, publish, "Publish GitHub release"), "run")
if !strings.Contains(run, `= latest.json ]`) {
t.Fatal("the publish step does not filter latest.json out of the uploaded assets")
}
for line := range strings.SplitSeq(run, "\n") {
if strings.Contains(line, "gh release") && strings.Contains(line, "dist/") {
t.Errorf("gh uploads the dist glob, root latest.json included: %s", strings.TrimSpace(line))
}
}
if !strings.Contains(run, `"${assets[@]}"`) {
t.Fatal("the release is not created from the filtered asset list")
}
mirror := mappingScalar(stepNamed(t, publish, "Mirror to R2 and update the Studio catalog"), "run")
if !strings.Contains(mirror, `aws s3 cp dist/ "s3://${R2_BUCKET}/${TAG}/" --recursive`) {
t.Fatal("the per-tag manifest must still reach R2 with the rest of dist")
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

+1 -1
View File
@@ -19,7 +19,7 @@
"computer-live": "electron test/computer_live.js"
},
"devDependencies": {
"electron": "44.5.1",
"electron": "44.4.5",
"electron-builder": "^26.15.3",
"playwright": "1.63.0"
},
+4 -11
View File
@@ -7,17 +7,11 @@ const { chromium } = require("playwright");
const ASSETS = path.join(__dirname, "..", "assets");
const ICO_SIZES = [16, 20, 24, 32, 40, 48, 64, 256];
// The tray is drawn at 16 logical pixels. Windows asks for exactly 16*scale
// pixels and resizes whatever it is handed, so every Windows scale has its own
// file (src/trayimage.js picks it).
const TRAY_SCALES = [[1, ""], [1.25, "@1.25x"], [1.5, "@1.5x"], [1.75, "@1.75x"], [2, "@2x"], [2.5, "@2.5x"], [3, "@3x"]];
// The tray is drawn at 16 logical pixels; nativeImage picks the @Nx file that
// matches the display, so a scaled screen never enlarges the 16px one.
const TRAY_SCALES = [[1, ""], [1.25, "@1.25x"], [1.5, "@1.5x"], [2, "@2x"], [3, "@3x"]];
// The app icon's margin is the platform's to keep; a tray slot has none to spare.
const TRAY_VIEWBOX = 'viewBox="103 103 818 818"';
// Windows draws the .ico edge to edge beside neighbours that fill their cell, so
// its tile is cropped to a 2% margin and the glyph enlarged within it; macOS and
// Linux keep the full-canvas artwork the platform templates expect.
const WIN_VIEWBOX = 'viewBox="91 91 842 842"';
const WIN_GLYPH_SCALE = "scale(2.4)";
// PNG payloads for every type modern macOS reads; ic11–ic14 are the @2x slots.
const ICNS_TYPES = [
["ic04", 16], ["ic05", 32], ["ic07", 128], ["ic08", 256], ["ic09", 512], ["ic10", 1024],
@@ -79,9 +73,8 @@ async function main() {
for (const [scale, suffix] of TRAY_SCALES) {
fs.writeFileSync(path.join(ASSETS, `tray${suffix}.png`), await render(page, traySvg, 16 * scale));
}
const winSvg = svg.replace('viewBox="0 0 1024 1024"', WIN_VIEWBOX).replace("scale(1.9)", WIN_GLYPH_SCALE);
const icoPngs = [];
for (const size of ICO_SIZES) icoPngs.push([size, await render(page, winSvg, size)]);
for (const size of ICO_SIZES) icoPngs.push([size, await at(size)]);
fs.writeFileSync(path.join(ASSETS, "icon.ico"), ico(icoPngs));
const icnsPngs = [];
for (const [type, size] of ICNS_TYPES) icnsPngs.push([type, await at(size)]);
+5 -5
View File
@@ -9,8 +9,8 @@ importers:
.:
devDependencies:
electron:
specifier: 44.5.1
version: 44.5.1([email protected])
specifier: 44.4.5
version: 44.4.5([email protected])
electron-builder:
specifier: ^26.15.3
version: 26.15.3([email protected])([email protected])
@@ -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.4.5:
resolution: {integrity: sha512-SjgoaeYsSWZfJzubgQU7juvuXMTvn6/e1gAHdGFA/yuMbpF+I+skYqIJ6DdXBXHeWbkbpNpmwZCTpiivtlWZSw==}
engines: {node: '>= 22.12.0'}
hasBin: true
@@ -1551,7 +1551,7 @@ snapshots:
transitivePeerDependencies:
- supports-color
electron@44.5.1([email protected]):
electron@44.4.5([email protected]):
dependencies:
'@electron-internal/extract-zip': 1.0.5
'@electron/get': 5.1.0([email protected])
-2
View File
@@ -1,7 +1,6 @@
"use strict";
const crypto = require("node:crypto");
const { WebContentsView, session } = require("electron");
const { installPaneReload } = require("./reload");
const { guestNavigationAllowed, typedAddress } = require("./browserguard");
// The size a page lays out at while nobody is looking at it: the kernel drives
@@ -97,7 +96,6 @@ class BrowserViews {
if (allowed(to)) onPopup(to);
return { action: "deny" };
});
installPaneReload(contents);
contents.debugger.attach("1.3");
contents.debugger.on("message", (_event, method, params, sessionId) => {
if (sessionId) return;
+13 -123
View File
@@ -1,131 +1,21 @@
"use strict";
// Role labels come from the platform's own catalogue, which follows the
// system locale rather than the language the interface was set to, so they are
// stated here for the two languages the interface draws.
const WORDS = {
zh: {
edit: "编辑",
undo: "撤销",
redo: "重做",
cut: "剪切",
copy: "复制",
paste: "粘贴",
selectAll: "全选",
pasteAndMatchStyle: "粘贴并匹配样式",
delete: "删除",
substitutions: "替换",
showSubstitutions: "显示替换",
smartQuotes: "智能引号",
smartDashes: "智能破折号",
textReplacement: "文本替换",
speech: "语音",
startSpeaking: "开始朗读",
stopSpeaking: "停止朗读",
},
en: {
edit: "Edit",
undo: "Undo",
redo: "Redo",
cut: "Cut",
copy: "Copy",
paste: "Paste",
selectAll: "Select All",
pasteAndMatchStyle: "Paste and Match Style",
delete: "Delete",
substitutions: "Substitutions",
showSubstitutions: "Show Substitutions",
smartQuotes: "Smart Quotes",
smartDashes: "Smart Dashes",
textReplacement: "Text Replacement",
speech: "Speech",
startSpeaking: "Start Speaking",
stopSpeaking: "Stop Speaking",
},
};
function wordsFor(lang) {
return WORDS[lang] ?? WORDS.en;
}
// The edit commands as roles carrying explicit labels: roles do the clipboard
// work, the labels follow the interface language.
function editItems(lang, can) {
const w = wordsFor(lang);
const on = (flag) => (can ? { enabled: !!can[flag] } : {});
return [
{ role: "undo", label: w.undo, ...on("canUndo") },
{ role: "redo", label: w.redo, ...on("canRedo") },
{ type: "separator" },
{ role: "cut", label: w.cut, ...on("canCut") },
{ role: "copy", label: w.copy, ...on("canCopy") },
{ role: "paste", label: w.paste, ...on("canPaste") },
{ type: "separator" },
{ role: "selectAll", label: w.selectAll, ...on("canSelectAll") },
];
}
// The context menu's shape, kept apart from the platform so it can be checked
// without one. editFlags is the page's own account of what is possible at the
// click: this only decides what to offer.
function contextTemplate(params, lang) {
// click: roles do the clipboard work, this only decides what to offer.
function contextTemplate(params) {
if (!params.isEditable && !params.selectionText) return [];
return editItems(lang, params.editFlags ?? {});
}
// Everything the platform's own Edit menu carries, with the same roles, so
// only the words change.
function editMenuTemplate(lang) {
const w = wordsFor(lang);
const items = editItems(lang);
const at = items.findIndex((i) => i.role === "selectAll");
items.splice(at - 1, 1);
items.splice(at - 1, 0,
{ role: "pasteAndMatchStyle", label: w.pasteAndMatchStyle },
{ role: "delete", label: w.delete });
items.push(
const can = params.editFlags ?? {};
return [
{ role: "undo", enabled: !!can.canUndo },
{ role: "redo", enabled: !!can.canRedo },
{ type: "separator" },
{
label: w.substitutions,
submenu: [
{ role: "showSubstitutions", label: w.showSubstitutions },
{ type: "separator" },
{ role: "toggleSmartQuotes", label: w.smartQuotes },
{ role: "toggleSmartDashes", label: w.smartDashes },
{ role: "toggleTextReplacement", label: w.textReplacement },
],
},
{
label: w.speech,
submenu: [
{ role: "startSpeaking", label: w.startSpeaking },
{ role: "stopSpeaking", label: w.stopSpeaking },
],
},
);
return { label: w.edit, submenu: items };
{ role: "cut", enabled: !!can.canCut },
{ role: "copy", enabled: !!can.canCopy },
{ role: "paste", enabled: !!can.canPaste },
{ type: "separator" },
{ role: "selectAll", enabled: !!can.canSelectAll },
];
}
function applicationMenuTemplate(lang) {
return [{ role: "appMenu" }, editMenuTemplate(lang), { role: "windowMenu" }];
}
// Only the Edit menu follows the interface language; the application and window
// menus are roles and follow the OS. Menu is injected so this runs without Electron.
function menuInstaller(Menu, platform = process.platform) {
let installedLang = null;
return function installApplicationMenu(languageOf = () => "en") {
if (platform !== "darwin") {
Menu.setApplicationMenu(null);
return null;
}
const lang = languageOf();
if (lang === installedLang) return Menu.getApplicationMenu();
installedLang = lang;
const menu = Menu.buildFromTemplate(applicationMenuTemplate(lang));
Menu.setApplicationMenu(menu);
return menu;
};
}
module.exports = { contextTemplate, editMenuTemplate, applicationMenuTemplate, menuInstaller };
module.exports = { contextTemplate };
+13 -60
View File
@@ -5,42 +5,22 @@ const PROTOCOL_VERSION = 1;
// A child that never sends a handshake must not be able to grow this process
// while it waits, nor hold the launch open forever.
const HANDSHAKE_LIMIT = 4096;
const HANDSHAKE_TIMEOUT_MS = 60000;
const SLOW_AFTER_MS = 3000;
// HostStartError says why a launch has no kernel. code is one of "timeout",
// "exited", "spawn", "overflow" or "malformed"; callers branch on it, never on
// the sentence.
class HostStartError extends Error {
constructor(code, message, detail = {}) {
super(message);
this.name = "HostStartError";
this.code = code;
Object.assign(this, detail);
}
}
const HANDSHAKE_TIMEOUT_MS = 20000;
// start spawns the kernel and reads the handshake it writes to stdout. Every
// line after it is an act a handover asks of this process, because that pipe is
// the only channel pointing this way. stdin is the lease: the kernel drains
// when this end closes, which is what stops it outliving a parent that exited
// without being asked to.
function start(binary, args, { onStderr, onExit, onAct, onSlow, systemLanguage, timeoutMs = HANDSHAKE_TIMEOUT_MS } = {}) {
const env = { ...process.env };
if (systemLanguage) env.REASONIX_SYSTEM_LANG = systemLanguage;
const child = spawn(binary, args, { stdio: ["pipe", "pipe", "pipe"], env });
function start(binary, args, { onStderr, onExit, onAct } = {}) {
const child = spawn(binary, args, { stdio: ["pipe", "pipe", "pipe"] });
child.stderr.setEncoding("utf8");
if (onStderr) child.stderr.on("data", onStderr);
if (onExit) child.on("exit", onExit);
const ready = firstLine(child, { onSlow, timeoutMs }).then(({ line, rest }) => {
const ready = firstLine(child).then(({ line, rest }) => {
// Only once the handshake parses: a child whose first line was not one has
// said nothing this process should act on.
let handshake;
try {
handshake = parse(line);
} catch (err) {
throw new HostStartError("malformed", err.message);
}
const handshake = parse(line);
if (onAct) readActs(child, rest, onAct);
return handshake;
});
@@ -80,31 +60,19 @@ function readActs(child, rest, onAct) {
drain();
}
function firstLine(child, { onSlow, timeoutMs }) {
function firstLine(child) {
return new Promise((resolve, reject) => {
let buffered = "";
const began = Date.now();
const elapsed = () => Date.now() - began;
const settle = (fn, value) => {
clearTimeout(timer);
clearTimeout(slow);
child.stdout.off("data", onData);
child.off("exit", onEarlyExit);
child.off("error", onSpawnFailure);
fn(value);
};
const slow = onSlow ? setTimeout(onSlow, Math.min(SLOW_AFTER_MS, timeoutMs)) : undefined;
const timer = setTimeout(
() =>
settle(
reject,
new HostStartError(
"timeout",
`the kernel was still running but sent no handshake within ${Math.round(timeoutMs / 1000)} s`,
{ elapsedMs: elapsed() },
),
),
timeoutMs,
() => settle(reject, new Error("the kernel sent no handshake")),
HANDSHAKE_TIMEOUT_MS,
);
const onData = (chunk) => {
buffered += chunk;
@@ -113,22 +81,15 @@ function firstLine(child, { onSlow, timeoutMs }) {
return settle(resolve, { line: buffered.slice(0, end), rest: buffered.slice(end + 1) });
}
if (buffered.length > HANDSHAKE_LIMIT) {
settle(reject, new HostStartError("overflow", "the handshake ran past its limit"));
settle(reject, new Error("the handshake ran past its limit"));
}
};
const onEarlyExit = (code, signal) =>
settle(
reject,
new HostStartError("exited", `the kernel exited with ${code ?? `signal ${signal}`} before saying anything`, {
exitCode: code,
signal,
elapsedMs: elapsed(),
}),
);
const onEarlyExit = (code) =>
settle(reject, new Error(`the kernel exited with ${code} before saying anything`));
// A program that could not be started emits this and never exits, so without
// it the launch waited out the handshake timeout and then reported the
// kernel as silent — which sent the reader looking at the kernel.
const onSpawnFailure = (err) => settle(reject, new HostStartError("spawn", `the kernel could not be started: ${err.message}`, { elapsedMs: elapsed() }));
const onSpawnFailure = (err) => settle(reject, new Error(`the kernel could not be started: ${err.message}`));
child.stdout.setEncoding("utf8");
child.stdout.on("data", onData);
child.on("exit", onEarlyExit);
@@ -167,12 +128,4 @@ function parse(line) {
return { origin: url.origin, token: body.token };
}
const RETRY_WITHIN_MS = 10000;
// shouldRetry allows one more attempt only for a kernel that stopped, or could
// not be run, soon after it was started.
function shouldRetry(err, attempt, elapsedMs) {
return attempt < 2 && (err?.code === "exited" || err?.code === "spawn") && elapsedMs <= RETRY_WITHIN_MS;
}
module.exports = { shouldRetry, start, parse, readActs, HostStartError, PROTOCOL_VERSION, HANDSHAKE_TIMEOUT_MS };
module.exports = { start, parse, readActs, PROTOCOL_VERSION };
+25 -88
View File
@@ -7,17 +7,15 @@ if (relaunchForOzonePlatform(app, process)) return;
const fs = require("node:fs/promises");
const { existsSync } = require("node:fs");
const path = require("node:path");
const { start, shouldRetry, HANDSHAKE_TIMEOUT_MS } = require("./host");
const { showStarting } = require("./starting");
const { start } = require("./host");
const { StudioHost } = require("./hostclient");
const { installTray } = require("./tray");
const { instanceID, profileFor } = require("./instance");
const { installApplicationMenu, installContextMenu } = require("./menu");
const { uiLanguage } = require("./uilang");
const { installFullScreenKey } = require("./fullscreen");
const { installReload } = require("./reload");
const { externalTarget } = require("./links");
const { reveal, revealWorkspace } = require("./reveal");
const { reveal } = require("./reveal");
const { appIcon } = require("./appicon");
const layout = require("./layout");
const { offerCleanup } = require("./legacy");
@@ -25,7 +23,7 @@ const { stripPackageGrants, unpaintedWindowCause } = require("./packagegrants");
const { BrowserProtocol } = require("./browserprotocol");
const { BrowserViews } = require("./browserviews");
const { startBrowserRelay } = require("./browserrelay");
const { loadPrefs, prefsFile, registerPrefs } = require("./prefs");
const { prefsFile, registerPrefs } = require("./prefs");
const { openLogs, redactArgv, failStartup } = require("./shelllog");
// A page in a minimized or fully covered window counts as hidden, and a hidden
@@ -47,7 +45,6 @@ const LIGHTS = { x: 11, y: 20 };
const DEFAULT_SIZE = { width: 1440, height: 900 };
const MIN_SIZE = { width: 760, height: 480 };
const HOST_DRAIN_MS = 1000;
const RETRY_DELAY_MS = 1000;
const where = {
packaged: app.isPackaged,
@@ -73,70 +70,6 @@ let reload = null;
let logs = null;
let handshaken = false;
let starting = null;
function closeStarting() {
const win = starting;
starting = null;
if (win && !win.isDestroyed()) win.close();
}
function handshakeTimeout() {
const ms = Number(process.env.REASONIX_STUDIO_HANDSHAKE_TIMEOUT_MS);
return Number.isFinite(ms) && ms > 0 ? ms : HANDSHAKE_TIMEOUT_MS;
}
// A kernel that exits or cannot be spawned within seconds is tried once more:
// a scanner holding a freshly written binary lets go about that fast. A slow
// kernel is waited for instead, never restarted.
async function launchKernel(args) {
for (let attempt = 1; attempt <= 2; attempt++) {
if (quitting) throw new Error("the launch was ended by quit");
const began = Date.now();
logs.shell.line(`host: starting ${hostBinary} (attempt ${attempt})`);
kernel = start(hostBinary, args, {
systemLanguage: app.getPreferredSystemLanguages()[0] ?? app.getLocale(),
timeoutMs: handshakeTimeout(),
onSlow: () => {
logs.shell.line("host: no handshake yet; showing the starting window");
if (starting || quitting) return;
const win = showStarting(app.getLocale());
starting = win;
win.once("closed", () => {
if (starting !== win) return;
starting = null;
logs.shell.line("host: starting window closed; ending the launch");
app.quit();
});
},
onStderr: (text) => {
logs.host.raw(text);
process.stderr.write(text);
},
onExit: (code, signal) => {
logs.shell.line(`host: exited code=${code} signal=${signal}${handshaken ? "" : " before its handshake"}`);
// Before the handshake the launch itself fails, and boot's catch owns
// telling the person why; quitting here would race that dialog.
if (handshaken && code !== 0 && !quitting) app.quit();
},
onAct: handOver,
});
const current = kernel;
current.child.on("error", (err) => logs.shell.line(`host: spawn failed: ${err.message}`));
current.child.stderr.on("close", () => logs.host.flush());
try {
return await current.ready;
} catch (err) {
if (quitting || !shouldRetry(err, attempt, Date.now() - began)) {
err.attempts = attempt;
closeStarting();
throw err;
}
logs.shell.line(`host: ${err.message}; retrying once`);
await new Promise((resolve) => setTimeout(resolve, RETRY_DELAY_MS));
}
}
}
async function boot() {
// Which build this is belongs to the shell: inside the bundle the kernel's
// own os.Executable() names the host binary, not the application around it.
@@ -153,7 +86,23 @@ async function boot() {
// inside the bundle rather than being it.
args.push("-studio-app", process.execPath, "-studio-app-pid", String(process.pid));
}
const ready = await launchKernel(args);
logs.shell.line(`host: starting ${hostBinary}`);
kernel = start(hostBinary, args, {
onStderr: (text) => {
logs.host.raw(text);
process.stderr.write(text);
},
onExit: (code, signal) => {
logs.shell.line(`host: exited code=${code} signal=${signal}${handshaken ? "" : " before its handshake"}`);
// Before the handshake the launch itself fails, and boot's catch owns
// telling the person why; quitting here would race that dialog.
if (handshaken && code !== 0 && !quitting) app.quit();
},
onAct: handOver,
});
kernel.child.on("error", (err) => logs.shell.line(`host: spawn failed: ${err.message}`));
kernel.child.stderr.on("close", () => logs.host.flush());
const ready = await kernel.ready;
logs.addSecret(ready.token);
handshaken = true;
logs.shell.line(`host: handshake from ${ready.origin}`);
@@ -161,14 +110,13 @@ async function boot() {
client = new StudioHost(ready.origin, ready.token);
await armCredential(ready);
win = createWindow();
closeStarting();
guard(win.webContents);
win.webContents.once("render-process-gone", (_event, details) => {
if (win.isVisible() || details.reason !== "crashed") return;
const cause = unpaintedWindowCause(grants, app.getLocale());
if (cause) dialog.showErrorBox(cause.title, cause.detail);
});
installContextMenu(win.webContents, win, uiLang);
installContextMenu(win.webContents, win);
installFullScreenKey(win.webContents, win);
reload = installReload(win.webContents, win);
win.once("ready-to-show", () => win.show());
@@ -253,7 +201,6 @@ function onWindowClose(event) {
// showWindow brings it back from wherever it went. Show alone is a no-op on a
// window that is merely buried, so the focus is what actually raises it.
function showWindow() {
if (starting && !starting.isDestroyed()) starting.focus();
if (quitting || !win || win.isDestroyed()) return;
if (win.isMinimized()) win.restore();
reload?.revive();
@@ -316,10 +263,7 @@ function fromWindow(event) {
return win && !win.isDestroyed() && event.sender === win.webContents ? win : null;
}
// The page reads navigator.languages[0], which is the first preferred system
// language; app.getLocale() is the locale Chromium resolved, which can differ.
const uiLang = () => uiLanguage(loadPrefs(prefsFile(app.getPath("userData"))), app.getPreferredSystemLanguages()[0] ?? app.getLocale());
registerPrefs(ipcMain, () => prefsFile(app.getPath("userData")), fromWindow, () => installApplicationMenu(uiLang));
registerPrefs(ipcMain, () => prefsFile(app.getPath("userData")), fromWindow);
ipcMain.handle("window:minimise", (event) => {
fromWindow(event)?.minimize();
@@ -356,11 +300,8 @@ ipcMain.handle("browser:login-answer", (event, id, username, password) => {
ipcMain.handle("shell:open-external", (event, raw) => {
if (!fromWindow(event)) return;
const target = externalTarget(raw);
if (target) return shell.openExternal(target);
if (target) void shell.openExternal(target);
});
ipcMain.handle("shell:reveal-workspace", (event, root) =>
fromWindow(event) && client ? revealWorkspace(client, shell, String(root)) : { code: "", error: "no window" },
);
ipcMain.handle("shell:reveal", (event, base, rel) =>
fromWindow(event) && client ? reveal(client, shell, String(base), String(rel)) : { code: "", error: "no window" },
);
@@ -436,7 +377,7 @@ if (!primary) {
app.whenReady().then(() => {
if (!primary) return;
installApplicationMenu(uiLang);
installApplicationMenu();
boot().catch((err) => {
console.error("reasonix-studio:", err.message);
if (quitting) {
@@ -482,11 +423,7 @@ function handOver(act) {
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", () => {
if (handshaken) app.quit();
});
app.on("window-all-closed", () => app.quit());
// Closing this end of the pipe is what tells the kernel to drain. Without it a
// session file is left being written by a process nobody is holding open.
+16 -4
View File
@@ -1,19 +1,31 @@
"use strict";
const { Menu } = require("electron");
const { contextTemplate, menuInstaller } = require("./editmenu");
const { contextTemplate } = require("./editmenu");
// Without an application menu a WebContents has no copy, paste or undo at all:
// macOS routes those shortcuts through it, and a window with none reads as a
// broken text editor. Elsewhere the bar would render inside the window, and
// those platforms bind the shortcuts themselves.
const installApplicationMenu = menuInstaller(Menu);
function installApplicationMenu() {
if (process.platform !== "darwin") {
Menu.setApplicationMenu(null);
return null;
}
const menu = Menu.buildFromTemplate([
{ role: "appMenu" },
{ role: "editMenu" },
{ role: "windowMenu" },
]);
Menu.setApplicationMenu(menu);
return menu;
}
// A production build ships without one, so a right-click in a text field offered
// nothing at all. Roles rather than handlers: the clipboard work belongs to the
// platform, and editFlags is the page's own account of what is possible here.
function installContextMenu(contents, window, languageOf = () => "en") {
function installContextMenu(contents, window) {
contents.on("context-menu", (_event, params) => {
const template = contextTemplate(params, languageOf());
const template = contextTemplate(params);
if (!template.length) return;
Menu.buildFromTemplate(template).popup({ window });
});
+1 -2
View File
@@ -44,7 +44,7 @@ function savePrefs(file, prefs) {
// in the same process and must not reach this file.
// fileOf is asked on each use: the profile directory is settled during boot,
// after these handlers exist.
function registerPrefs(ipcMain, fileOf, fromWindow, onSave = () => {}) {
function registerPrefs(ipcMain, fileOf, fromWindow) {
ipcMain.on("prefs:load", (event) => {
event.returnValue = fromWindow(event) ? loadPrefs(fileOf()) : {};
});
@@ -52,7 +52,6 @@ function registerPrefs(ipcMain, fileOf, fromWindow, onSave = () => {}) {
if (fromWindow(event)) {
try {
savePrefs(fileOf(), prefs);
onSave();
} catch {
// A preference that did not save is one relaunch of lost state, not a
// reason to fail the window.
-2
View File
@@ -28,8 +28,6 @@ contextBridge.exposeInMainWorld("reasonixHost", {
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)),
// 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.
+10 -26
View File
@@ -1,38 +1,22 @@
"use strict";
// A page gone blank, whether its tree unmounted or its renderer died, has no
// way back but quitting. The kernel owns every running turn and a loaded page
// finds it again. F5 and Ctrl/Cmd+R belong to the page, so the recovery chord
// is the shifted one.
// No menu this shell installs carries a reload role, and a page gone blank,
// whether its tree unmounted or its renderer died, has no way back but
// quitting. The kernel owns every running turn and a loaded page finds it again.
const CRASH_RELOADS = 3;
const CRASH_WINDOW_MS = 60_000;
function reloadAction(input, platform = process.platform) {
if (input.type !== "keyDown" || input.isAutoRepeat || input.alt) return null;
const mod = platform === "darwin" ? input.meta && !input.control : input.control && !input.meta;
const latin = typeof input.key === "string" && /^[a-z]$/i.test(input.key);
const isR = latin ? input.key.toLowerCase() === "r" : input.code === "KeyR";
if (mod && isR) return input.shift ? "hard" : "reload";
if (!input.control && !input.meta && !input.shift && input.key === "F5") return "reload";
return null;
}
function installPaneReload(contents, platform = process.platform) {
contents.on("before-input-event", (event, input) => {
const action = reloadAction(input, platform);
if (!action) return;
event.preventDefault();
if (action === "hard") contents.reloadIgnoringCache();
else contents.reload();
});
}
// A dead renderer's input never reaches before-input-event, so the crash itself
// has to trigger the reload, or for a hidden window the next revive before it is
// shown. A page that dies on every load stops being reloaded.
function installReload(contents, window, { platform = process.platform, now = Date.now } = {}) {
const mac = platform === "darwin";
contents.on("before-input-event", (event, input) => {
if (reloadAction(input, platform) !== "hard") return;
if (input.type !== "keyDown" || input.isAutoRepeat || input.alt || input.shift) return;
const mod = mac ? input.meta && !input.control : input.control && !input.meta;
const bare = !input.control && !input.meta;
const chord = mod && (input.key === "r" || input.key === "R" || input.code === "KeyR");
if (!chord && !(bare && input.key === "F5")) return;
event.preventDefault();
contents.reload();
});
@@ -56,4 +40,4 @@ function installReload(contents, window, { platform = process.platform, now = Da
};
}
module.exports = { installReload, installPaneReload, reloadAction, CRASH_RELOADS, CRASH_WINDOW_MS };
module.exports = { installReload, CRASH_RELOADS, CRASH_WINDOW_MS };
+2 -15
View File
@@ -23,13 +23,9 @@ function local(target, platform) {
async function locate(client, base, rel, platform) {
if (!PANE_BASE.test(base)) return { refusal: refused("not a pane this window publishes") };
const query = rel ? `?path=${encodeURIComponent(rel)}` : "";
return ask(client, `${base}/workspace/locate${query}`, platform);
}
async function ask(client, url, platform) {
let res;
try {
res = await client.request("GET", url);
res = await client.request("GET", `${base}/workspace/locate${query}`);
} catch (err) {
return { refusal: refused(err.message) };
}
@@ -58,13 +54,4 @@ async function reveal(client, shell, base, rel, platform = process.platform) {
return null;
}
// A project the sidebar lists needs no pane: the hub answers for the folders
// it lists and refuses any other, so the page still names no location itself.
async function revealWorkspace(client, shell, root, platform = process.platform) {
const found = await ask(client, `/host/workspaces/locate?root=${encodeURIComponent(String(root))}`, platform);
if (found.refusal) return found.refusal;
shell.showItemInFolder(found.target);
return null;
}
module.exports = { reveal, revealWorkspace };
module.exports = { reveal };
+12 -25
View File
@@ -159,29 +159,17 @@ function openLogs(userData, options = {}) {
};
}
const HINTS = {
timeout: [
"The kernel started but did not finish starting in time. Security software scanning a new or updated install is a common cause; starting Studio again usually works.",
"内核已启动,但没有在限定时间内完成启动。安全软件扫描新安装或刚更新的程序是常见原因;再次启动通常可以。",
],
exited: [
"The kernel process stopped before it was ready. Anything it printed is above; host.log has the rest.",
"内核进程在就绪前就停止了。它输出过的内容在上面,host.log 里有更多。",
],
spawn: [
"The kernel program could not be run. Security software blocking or locking it is a common cause.",
"无法运行内核程序。安全软件拦截或占用它是常见原因。",
],
};
function startupFailure(locale, reason, logDir, code) {
const zh = String(locale).toLowerCase().startsWith("zh");
const hint = HINTS[code]?.[zh ? 1 : 0];
const body = hint ? `${reason}\n\n${hint}` : reason;
if (zh) {
return { title: "Reasonix Studio 无法启动", detail: `${body}\n\n日志位于:\n${logDir}` };
function startupFailure(locale, reason, logDir) {
if (String(locale).toLowerCase().startsWith("zh")) {
return {
title: "Reasonix Studio 无法启动",
detail: `${reason}\n\n日志位于:\n${logDir}`,
};
}
return { title: "Reasonix Studio could not start", detail: `${body}\n\nLogs are in:\n${logDir}` };
return {
title: "Reasonix Studio could not start",
detail: `${reason}\n\nLogs are in:\n${logDir}`,
};
}
// failStartup is the one way a launch that cannot come up ends: recorded, told
@@ -189,9 +177,8 @@ function startupFailure(locale, reason, logDir, code) {
// words when it never finished its handshake, and is already redacted.
function failStartup({ app, dialog, logs, locale }, err, hostOutput = "") {
logs.shell.line(`startup failed: ${err?.stack || err}`);
const tried = err?.attempts > 1 ? ` (after ${err.attempts} attempts)` : "";
const reason = [`${String(err?.message || err)}${tried}`, hostOutput].filter(Boolean).join("\n\n");
const text = startupFailure(locale, logs.redact(reason), logs.dir, err?.code);
const reason = [String(err?.message || err), hostOutput].filter(Boolean).join("\n\n");
const text = startupFailure(locale, logs.redact(reason), logs.dir);
try {
dialog.showErrorBox(text.title, text.detail);
} catch (e) {
-41
View File
@@ -1,41 +0,0 @@
"use strict";
const { BrowserWindow } = require("electron");
const TEXT = {
en: ["Reasonix Studio is starting", "The first start after installing or rebooting can take a minute."],
zh: ["Reasonix Studio 正在启动", "安装或重启电脑后的首次启动可能需要一分钟。"],
};
function startingPage(locale) {
const [title, note] = String(locale).toLowerCase().startsWith("zh") ? TEXT.zh : TEXT.en;
return `<!doctype html><meta charset="utf-8"><title>${title}</title><style>
:root{color-scheme:light dark;--bg:#fafafa;--fg:#1c1c1e;--mute:#6b6b70}
@media(prefers-color-scheme:dark){:root{--bg:#1b1b1d;--fg:#ececee;--mute:#9a9aa0}}
body{margin:0;height:100vh;display:flex;flex-direction:column;justify-content:center;gap:8px;padding:0 28px;box-sizing:border-box;background:var(--bg);color:var(--fg);font:14px system-ui,sans-serif}
h1{margin:0;font-size:16px;font-weight:600}p{margin:0;color:var(--mute);line-height:1.5}
.bar{height:3px;margin-top:10px;border-radius:2px;background:var(--mute);opacity:.35;overflow:hidden;position:relative}
.bar::after{content:"";position:absolute;inset:0 70% 0 0;background:var(--fg);animation:run 1.4s ease-in-out infinite}
@keyframes run{from{transform:translateX(-100%)}to{transform:translateX(340%)}}
</style><h1>${title}</h1><p>${note}</p><div class="bar"></div>`;
}
// showStarting is the only sign of life while the kernel has not answered: until
// it does there is no window of the application's own to draw.
function showStarting(locale) {
const win = new BrowserWindow({
width: 380,
height: 150,
resizable: false,
minimizable: false,
maximizable: false,
frame: true,
title: "Reasonix Studio",
autoHideMenuBar: true,
webPreferences: { sandbox: true, contextIsolation: true, nodeIntegration: false },
});
win.removeMenu();
void win.loadURL(`data:text/html;charset=utf-8,${encodeURIComponent(startingPage(locale))}`);
return win;
}
module.exports = { showStarting, startingPage };
+4 -7
View File
@@ -1,17 +1,18 @@
"use strict";
const { Menu, Tray, nativeImage, screen } = require("electron");
const { trayImage } = require("./trayimage.js");
const path = require("node:path");
const { Menu, Tray, nativeImage } = require("electron");
// How often the fold is re-read. It is a projection the kernel recomputes on
// demand, so a missed read costs nothing the next one does not restore — which
// is why it is pulled on a timer rather than carried on the stream.
const FOLD_INTERVAL_MS = 5000;
const ICON = path.join(__dirname, "..", "assets", "tray.png");
// 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 }) {
const image = trayImage(nativeImage, screen.getPrimaryDisplay().scaleFactor);
const image = nativeImage.createFromPath(ICON);
if (image.isEmpty()) return null;
const icon = new Tray(image);
const state = { fold: null, prefs: null };
@@ -46,9 +47,6 @@ function installTray(host, { onOpen, onQuit }) {
);
};
const refit = () => icon.setImage(trayImage(nativeImage, screen.getPrimaryDisplay().scaleFactor));
if (process.platform === "win32") screen.on("display-metrics-changed", refit);
icon.on("click", onOpen);
const timer = setInterval(() => void refresh(), FOLD_INTERVAL_MS);
@@ -70,7 +68,6 @@ function installTray(host, { onOpen, onQuit }) {
refresh,
close() {
clearInterval(timer);
screen.removeListener("display-metrics-changed", refit);
icon.destroy();
},
};
-30
View File
@@ -1,30 +0,0 @@
"use strict";
const fs = require("node:fs");
const path = require("node:path");
const ASSETS = path.join(__dirname, "..", "assets");
const SCALES = [
[1, ""],
[1.25, "@1.25x"],
[1.5, "@1.5x"],
[1.75, "@1.75x"],
[2, "@2x"],
[2.5, "@2.5x"],
[3, "@3x"],
];
function trayAsset(scaleFactor) {
const want = Number.isFinite(scaleFactor) && scaleFactor > 0 ? scaleFactor : 1;
const hit = SCALES.find(([scale]) => scale >= want - 1e-6) ?? SCALES[SCALES.length - 1];
return { file: path.join(ASSETS, `tray${hit[1]}.png`), pixels: Math.round(16 * hit[0]) };
}
// Windows rebuilds the tray icon from the 1x bitmap, resizing it to the small
// icon metric, so the file is loaded as 1x pixels of exactly that size. Other
// platforms choose among the @Nx files themselves.
function trayImage(nativeImage, scaleFactor) {
if (process.platform !== "win32") return nativeImage.createFromPath(path.join(ASSETS, "tray.png"));
return nativeImage.createFromBuffer(fs.readFileSync(trayAsset(scaleFactor).file));
}
module.exports = { trayAsset, trayImage, SCALES };
-17
View File
@@ -1,17 +0,0 @@
"use strict";
// The interface language the page will draw in, decided from the same two
// facts it uses: its saved choice (rx-lang) and, for anything but an explicit
// zh or en, the machine's first locale.
function isChinese(tag) {
const s = String(tag ?? "").toLowerCase();
return s.startsWith("zh") || s.startsWith("yue") || s.startsWith("cmn") || s.includes("hans") || s.includes("hant");
}
function uiLanguage(prefs, machineLocale) {
const want = String(prefs?.["rx-lang"] ?? "").trim().toLowerCase();
if (want === "zh" || want === "en") return want;
return isChinese(machineLocale) ? "zh" : "en";
}
module.exports = { uiLanguage };
+3 -3
View File
@@ -303,11 +303,11 @@ async function menuChecks(win, js) {
const menu = Menu.getApplicationMenu();
const roles = (menu?.items ?? []).map((i) => String(i.role).toLowerCase());
check("the application menu is installed", !!menu);
check("it carries the three menus macOS routes through", ["appmenu", "windowmenu"].every((r) => roles.includes(r)) && menu.items.length === 3, roles);
check("it carries the three menus macOS routes through", ["appmenu", "editmenu", "windowmenu"].every((r) => roles.includes(r)), roles);
const edit = (menu?.items ?? [])[1];
const edit = (menu?.items ?? []).find((i) => String(i.role).toLowerCase() === "editmenu");
const editRoles = (edit?.submenu?.items ?? []).map((i) => String(i.role).toLowerCase());
check("the edit menu carries every editing command", ["undo", "redo", "cut", "copy", "paste", "pasteandmatchstyle", "delete", "selectall"].every((r) => editRoles.includes(r)), editRoles);
check("the edit menu carries every editing command", ["undo", "redo", "cut", "copy", "paste", "selectall"].every((r) => editRoles.includes(r)), editRoles);
app.focus({ steal: true });
win.focus();
+28 -256
View File
@@ -6,9 +6,8 @@ import os from "node:os";
import fs from "node:fs";
const require = createRequire(import.meta.url);
const { parse, readActs, start } = require("../src/host.js");
const { contextTemplate, editMenuTemplate, applicationMenuTemplate, menuInstaller } = require("../src/editmenu.js");
const { uiLanguage } = require("../src/uilang.js");
const { parse, readActs } = require("../src/host.js");
const { contextTemplate } = require("../src/editmenu.js");
const { externalTarget } = require("../src/links.js");
const { offerCleanup, ownBundle } = require("../src/legacy.js");
const { stripPackageGrants, readReport, unpaintedWindowCause } = require("../src/packagegrants.js");
@@ -17,23 +16,6 @@ const { pick, loadPrefs, savePrefs, registerPrefs } = require("../src/prefs.js")
const TOKEN = "a".repeat(64);
const line = (over) => JSON.stringify({ version: 1, origin: "http://127.0.0.1:8080", token: TOKEN, ...over });
test("the spawned host receives the system language and inherits explicit locale overrides", async () => {
const script = `console.log(${JSON.stringify(line())});
console.log(JSON.stringify({act: JSON.stringify({system: process.env.REASONIX_SYSTEM_LANG,
explicit: process.env.REASONIX_LANG})}));`;
const before = process.env.REASONIX_SYSTEM_LANG;
const explicit = process.env.REASONIX_LANG;
const state = new Promise((resolve) => {
const host = start(process.execPath, ["-e", script], {
systemLanguage: "zh-Hant-TW",
onAct: (act) => resolve(JSON.parse(act)),
});
host.ready.catch(resolve);
});
assert.deepEqual(await state, { system: "zh-Hant-TW", ...(explicit === undefined ? {} : { explicit }) });
assert.equal(process.env.REASONIX_SYSTEM_LANG, before);
});
test("the handshake is accepted only when every field accounts for itself", () => {
assert.deepEqual(parse(line()), { origin: "http://127.0.0.1:8080", token: TOKEN });
@@ -81,68 +63,6 @@ test("the context menu mirrors what the page says is possible", () => {
});
});
test("the edit menu's labels follow the interface language, not the system's", () => {
const params = { isEditable: true, selectionText: "", editFlags: {} };
const labels = (lang) => contextTemplate(params, lang).filter((i) => i.role).map((i) => i.label);
assert.deepEqual(labels("zh"), ["撤销", "重做", "剪切", "复制", "粘贴", "全选"]);
assert.deepEqual(labels("en"), ["Undo", "Redo", "Cut", "Copy", "Paste", "Select All"]);
assert.equal(editMenuTemplate("zh").label, "编辑");
});
test("the edit menu keeps everything the platform's own one carries", () => {
const roles = (items) => items.flatMap((i) => [i.role, ...(i.submenu ? roles(i.submenu) : [])]).filter(Boolean);
const want = ["undo", "redo", "cut", "copy", "paste", "pasteAndMatchStyle", "delete", "selectAll",
"showSubstitutions", "toggleSmartQuotes", "toggleSmartDashes", "toggleTextReplacement", "startSpeaking", "stopSpeaking"];
for (const lang of ["zh", "en"]) {
assert.deepEqual(roles(editMenuTemplate(lang).submenu), want);
const labels = (items) => items.flatMap((i) => [i.label, ...(i.submenu ? labels(i.submenu) : [])]).filter((l) => l !== undefined);
assert.ok(labels(editMenuTemplate(lang).submenu).every((l) => l.length > 0));
}
assert.deepEqual(editMenuTemplate("en").submenu.map((i) => i.role ?? i.type ?? i.label),
["undo", "redo", "separator", "cut", "copy", "paste", "pasteAndMatchStyle", "delete", "selectAll", "separator", "Substitutions", "Speech"]);
assert.deepEqual(applicationMenuTemplate("zh").map((i) => i.role ?? "edit"), ["appMenu", "edit", "windowMenu"]);
});
test("the application menu is rebuilt when the language changes and not otherwise", () => {
const built = [];
const Menu = {
buildFromTemplate: (t) => (built.push(t), { template: t }),
setApplicationMenu: () => {},
getApplicationMenu: () => null,
};
const install = menuInstaller(Menu, "darwin");
let lang = "en";
install(() => lang);
install(() => lang);
assert.equal(built.length, 1);
lang = "zh";
install(() => lang);
assert.equal(built.length, 2);
assert.equal(built[1][1].label, "编辑");
let cleared = 0;
menuInstaller({ ...Menu, setApplicationMenu: () => cleared++ }, "linux")(() => "zh");
assert.equal(cleared, 1);
assert.equal(built.length, 2);
});
test("the interface language is the page's own choice, else the machine's", () => {
assert.equal(uiLanguage({ "rx-lang": "zh" }, "en-US"), "zh");
assert.equal(uiLanguage({ "rx-lang": "en" }, "zh-CN"), "en");
assert.equal(uiLanguage({ "rx-lang": "" }, "zh-Hans-CN"), "zh");
assert.equal(uiLanguage({}, "fr-FR"), "en");
assert.equal(uiLanguage(undefined, undefined), "en");
});
test("a saved preference tells the shell so it can follow it", () => {
const handlers = {};
const ipc = { on: (name, fn) => { handlers[name] = fn; } };
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rx-prefs-"));
let saved = 0;
registerPrefs(ipc, () => path.join(dir, "p.json"), () => true, () => { saved++; });
handlers["prefs:save"]({ returnValue: null }, { "rx-lang": "zh" });
assert.equal(saved, 1);
});
test("only http and https ever reach the platform opener", () => {
assert.equal(externalTarget("https://example.test/a"), "https://example.test/a");
assert.equal(externalTarget("http://example.test/"), "http://example.test/");
@@ -204,7 +124,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 } = 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.
@@ -223,7 +143,7 @@ async function revealRig(answer, platform = process.platform) {
openPath: async (p) => (opened.push(["open", p]), ""),
showItemInFolder: (p) => opened.push(["select", p]),
};
return { asked, opened, run: (base, rel) => reveal(client, shell, base, rel, platform), workspace: (root) => revealWorkspace(client, shell, root, platform), close: () => server.close() };
return { asked, opened, run: (base, rel) => reveal(client, shell, base, rel, platform), close: () => server.close() };
}
const ROOT = path.resolve(os.tmpdir(), "rx-workspace");
@@ -274,22 +194,6 @@ test("the page cannot steer reveal to a location the kernel did not name", async
}
});
test("a listed project is shown through the hub and only on its answer", async () => {
const rig = await revealRig((url) =>
url.pathname === "/host/workspaces/locate" && url.searchParams.get("root") === "/work/a b"
? [200, { path: path.join(ROOT, "a b"), dir: true }]
: [404, { code: "workspace.not_listed", error: "not listed" }],
);
try {
assert.equal(await rig.workspace("/work/a b"), null);
assert.equal((await rig.workspace("/etc")).code, "workspace.not_listed");
assert.deepEqual(rig.opened, [["select", path.join(ROOT, "a b")]]);
assert.equal(rig.asked[0], "/host/workspaces/locate?root=%2Fwork%2Fa%20b");
} finally {
rig.close();
}
});
// A root that is an .app bundle, or a link to one, is a directory to stat and
// an application to `open`: handing it to openPath would launch it.
test("the workspace root is selected, never opened", async () => {
@@ -410,79 +314,6 @@ test("a kernel that cannot be started says so, rather than going quiet", async (
});
});
test("a launch failure carries a typed cause, and a slow kernel is waited for, not restarted", async () => {
const { start } = require("../src/host.js");
const node = process.execPath;
let slow = 0;
const silent = start(node, ["-e", "setTimeout(()=>{},5000)"], { timeoutMs: 400, onSlow: () => slow++ });
await assert.rejects(silent.ready, (err) => {
assert.equal(err.code, "timeout");
assert.match(err.message, /still running/);
return true;
});
silent.child.kill();
assert.equal(slow, 1, "the slow state was not reported once");
const quick = start(node, ["-e", "process.exit(7)"]);
await assert.rejects(quick.ready, (err) => err.code === "exited" && err.exitCode === 7 && /exited with 7/.test(err.message));
const missing = start(path.join(os.tmpdir(), "reasonix-no-such-kernel-c9"), []);
await assert.rejects(missing.ready, (err) => err.code === "spawn");
const handshake = JSON.stringify({ version: 1, origin: "http://127.0.0.1:1", token: "x".repeat(32) });
const fine = start(node, ["-e", `console.log(${JSON.stringify(handshake)});setTimeout(()=>{},300)`], { timeoutMs: 400 });
assert.equal((await fine.ready).origin, "http://127.0.0.1:1");
});
test("a retry needs an early stop, is capped at one, and never follows a timeout", () => {
const { shouldRetry } = require("../src/host.js");
const exited = { code: "exited" };
assert.equal(shouldRetry(exited, 1, 500), true);
assert.equal(shouldRetry({ code: "spawn" }, 1, 500), true);
assert.equal(shouldRetry(exited, 1, 10001), false, "an exit after the early window was retried");
assert.equal(shouldRetry(exited, 2, 500), false, "a second retry was allowed");
assert.equal(shouldRetry({ code: "timeout" }, 1, 500), false);
assert.equal(shouldRetry(new Error("x"), 1, 500), false);
});
test("a handshake that does not parse is a typed failure", async () => {
const { start } = require("../src/host.js");
const bad = start(process.execPath, ["-e", "console.log('hello')"]);
await assert.rejects(bad.ready, (err) => err.code === "malformed");
});
test("a kernel that never answers is started once and not retried", { skip: process.platform === "win32" }, async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "reasonix-silent-"));
const host = path.join(dir, "host.sh");
fs.writeFileSync(host, "#!/bin/sh\nexec sleep 5\n", { mode: 0o755 });
process.env.REASONIX_STUDIO_HANDSHAKE_TIMEOUT_MS = "400";
const shell = loadShell({ lock: true, host });
try {
await shell.quitted;
const log = shell.shellLog();
assert.equal((log.match(/host: starting /g) || []).length, 1);
assert.doesNotMatch(log, /retrying once/);
assert.match(shell.calls[0][2], /still running/);
} finally {
delete process.env.REASONIX_STUDIO_HANDSHAKE_TIMEOUT_MS;
shell.cleanup();
fs.rmSync(dir, { recursive: true, force: true });
}
});
test("each failure cause gets its own explanation in both languages", () => {
const { startupFailure } = require("../src/shelllog.js");
const seen = new Set();
for (const code of ["timeout", "exited", "spawn"]) {
for (const locale of ["en", "zh-CN"]) {
const { detail } = startupFailure(locale, "r", "/logs", code);
assert.ok(!seen.has(detail), `${code}/${locale} repeated another cause's text`);
seen.add(detail);
}
}
assert.equal(startupFailure("en", "r", "/logs", "overflow").detail, "r\n\nLogs are in:\n/logs");
});
// A fake child: only the stdout half readActs touches, and a way to push bytes
// through it in whatever chunks the test wants -- which is the point, since the
// bug this guards is a line split across two of them.
@@ -1018,67 +849,30 @@ function reloadRig(platform = "darwin") {
return { state, send, gone, revive };
}
const reloadPress = (over) => ({ type: "keyDown", key: "r", code: "KeyR", control: false, alt: false, shift: false, meta: false, isAutoRepeat: false, ...over });
const reloadMods = [
["darwin", { meta: true }, { control: true }],
["win32", { control: true }, { meta: true }],
["linux", { control: true }, { meta: true }],
];
test("reloadAction reads the chord, not the layout", () => {
const { reloadAction } = require("../src/reload.js");
for (const [platform, mod, other] of reloadMods) {
const at = (input) => reloadAction(input, platform);
assert.equal(at(reloadPress(mod)), "reload");
assert.equal(at(reloadPress({ ...mod, key: "к" })), "reload");
assert.equal(at(reloadPress({ key: "F5", code: "F5" })), "reload");
assert.equal(at(reloadPress({ ...mod, key: "p", code: "KeyR" })), null, `${platform}: a Dvorak P reloaded`);
assert.equal(at(reloadPress({ ...mod, key: "r", code: "KeyP" })), "reload", `${platform}: a Dvorak R did not reload`);
assert.equal(at(reloadPress({ ...mod, shift: true })), "hard");
assert.equal(at(reloadPress({ ...mod, shift: true, key: "R" })), "hard");
for (const input of [
reloadPress(), reloadPress(other), reloadPress({ ...mod, alt: true }),
reloadPress({ ...mod, type: "keyUp" }), reloadPress({ ...mod, isAutoRepeat: true }),
reloadPress({ ...mod, key: "t", code: "KeyT" }), reloadPress({ key: "F5", code: "F5", control: true }),
reloadPress({ key: "F5", code: "F5", shift: true }), reloadPress({ shift: true }),
]) assert.equal(at(input), null, `${platform}: ${JSON.stringify(input)}`);
}
});
test("only the hard reload chord reloads the app window; F5 and Ctrl/Cmd+R reach the page", () => {
for (const [platform, mod] of reloadMods) {
test("the reload key brings back a window whose page stopped drawing", () => {
const press = (over) => ({ type: "keyDown", key: "r", code: "KeyR", control: false, alt: false, shift: false, meta: false, isAutoRepeat: false, ...over });
const cases = [
["darwin", { meta: true }, { control: true }],
["win32", { control: true }, { meta: true }],
["linux", { control: true }, { meta: true }],
];
for (const [platform, mod, other] of cases) {
const { state, send } = reloadRig(platform);
send(reloadPress(mod));
send(reloadPress({ key: "F5", code: "F5" }));
assert.equal(state.reloads, 0, `${platform}: a plain reload key reloaded the UI`);
assert.equal(state.prevented, 0, `${platform}: a plain reload key was swallowed`);
send(reloadPress({ ...mod, shift: true, key: "R" }));
assert.equal(state.reloads, 1, `${platform}: the recovery chord did not reload`);
assert.equal(state.prevented, 1);
}
});
test("a browser pane reloads on F5 and Ctrl/Cmd+R, ignoring cache on the shifted chord, and swallows nothing else", () => {
const { installPaneReload } = require("../src/reload.js");
for (const [platform, mod] of reloadMods) {
let handler;
const calls = { reload: 0, hard: 0, prevented: 0 };
const contents = {
on: (name, fn) => { if (name === "before-input-event") handler = fn; },
reload: () => { calls.reload += 1; },
reloadIgnoringCache: () => { calls.hard += 1; },
};
installPaneReload(contents, platform);
const send = (input) => handler({ preventDefault: () => { calls.prevented += 1; } }, input);
send(reloadPress(mod));
send(reloadPress({ key: "F5", code: "F5" }));
assert.deepEqual(calls, { reload: 2, hard: 0, prevented: 2 }, platform);
send(reloadPress({ ...mod, shift: true, key: "R" }));
assert.deepEqual(calls, { reload: 2, hard: 1, prevented: 3 }, platform);
send(reloadPress({ ...mod, key: "f", code: "KeyF" }));
send(reloadPress({ key: "F12", code: "F12" }));
send(reloadPress({ ...mod, alt: true }));
assert.deepEqual(calls, { reload: 2, hard: 1, prevented: 3 }, `${platform}: another key was handled`);
send(press(mod));
send(press({ ...mod, key: "R" }));
send(press({ ...mod, key: "к" }));
send(press({ key: "F5", code: "F5" }));
assert.equal(state.reloads, 4, `${platform}: the reload keys did not reload`);
send(press());
send(press(other));
send(press({ ...mod, shift: true }));
send(press({ ...mod, alt: true }));
send(press({ ...mod, type: "keyUp" }));
send(press({ ...mod, isAutoRepeat: true }));
send(press({ ...mod, key: "t", code: "KeyT" }));
send(press({ key: "F5", code: "F5", control: true }));
assert.equal(state.reloads, 4, `${platform}: something other than a fresh reload press reloaded`);
assert.equal(state.prevented, 4);
}
});
@@ -1310,7 +1104,6 @@ function loadShell({ lock, host }) {
if (key === "getPath") return () => userData;
if (key === "getVersion") return () => "9.9.9";
if (key === "getLocale") return () => "en-US";
if (key === "getPreferredSystemLanguages") return () => ["en-US"];
if (key === "isPackaged") return false;
if (key === "quit") return () => { calls.push(["quit"]); quit(); };
return inert;
@@ -1370,29 +1163,8 @@ test("a host that exits before its handshake is logged and shown, not swallowed"
assert.match(shell.hostLog(), /bad option/, "the host's stderr never reached host.log");
const log = shell.shellLog();
assert.match(log, /host: exited code=9 signal=null before its handshake/);
assert.match(log, /startup failed: HostStartError: /);
assert.equal((log.match(/host: starting /g) || []).length, 2, "an early exit was not retried exactly once");
assert.match(log, /retrying once/);
assert.match(detail, /after 2 attempts/);
assert.match(detail, /stopped before it was ready/, "no cause was given for an early exit");
assert.match(log, /startup failed: Error: /);
} finally {
shell.cleanup();
}
});
test("the tray has a file for every Windows scale, at exactly 16 * scale pixels", () => {
const { trayAsset, SCALES } = require("../src/trayimage.js");
const dim = (f) => {
const b = fs.readFileSync(f);
return [b.readUInt32BE(16), b.readUInt32BE(20)];
};
for (const [scale] of SCALES) {
const { file, pixels } = trayAsset(scale);
assert.deepEqual(dim(file), [pixels, pixels], file);
assert.equal(pixels, Math.round(16 * scale));
}
assert.equal(trayAsset(1.75).pixels, 28);
assert.equal(trayAsset(1.8).pixels, 32);
assert.equal(trayAsset(5).pixels, 48);
assert.equal(trayAsset(NaN).pixels, 16);
});
+1 -3
View File
@@ -41,9 +41,7 @@
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(pair ? { code: pair } : { token: token }),
}).then(function (response) {
if (response.ok) window.location.reload();
});
}).then(function () {});
} catch (e) {
return Promise.resolve();
}
+4 -4
View File
@@ -29,9 +29,9 @@
"@codemirror/search": "^6.7.2",
"@codemirror/state": "^6.7.6",
"@codemirror/view": "^6.43.13",
"@lezer/highlight": "^1.2.5",
"@lezer/highlight": "^1.2.4",
"codemirror": "^6.0.2",
"katex": "^0.18.10",
"katex": "^0.18.9",
"react": "^19.3.0",
"react-dom": "^19.3.0",
"react-markdown": "^10.1.0",
@@ -53,7 +53,7 @@
"jsdom": "^30.1.1",
"playwright": "^1.63.0",
"typescript": "^7.0.2",
"vite": "^8.3.2",
"vitest": "^5.0.3"
"vite": "^8.3.0",
"vitest": "^5.0.1"
}
}
-4
View File
@@ -46,7 +46,6 @@ node perf/pick.mjs # 补全菜单:/ 和 @ 的选中态,深浅两色下
node perf/models.mjs # 连接面板:网关报出一百多个模型时,这一屏还能不能用
node perf/side.mjs # 右栏:端点自己写的一句话有多长,都不该改变栏宽
node perf/queue.mjs # 待送达:正文读不回来时,「改」不许开出一个空编辑器
node perf/onboarding.mjs # First-run setup: zoom-aware card bounds, resize, scrolling, focus and completion
node perf/running.mjs # 执行过程:在跑的步骤自己在动、收起后由标题接着动,步骤行没有空栏
node perf/lang.mjs # 双语验证:英文启动、界面译文到位、切回中文
node perf/locale.mjs # 跟随系统语言:中文各写法都归中文,其余归英文
@@ -54,16 +53,13 @@ node perf/i18n.mjs # 词表守卫:源码用到的中文 key 与英文词
node perf/selfcheck.mjs # 守卫的守卫:往每个断言脚本塞一条必假断言,看它是否真的会红
node perf/codes.mjs # 码的守卫:内核发的每个拒绝码,前端都要有话说
node perf/tokens.mjs # 变量的守卫:样式里不带兜底的 var(--x),都得真有人赋过值
node perf/tiers.mjs # 墨色分档的守卫:每档的正文/次要/淡/幽灵色不低于首次绘制的那一步,且都过 AA
node perf/contrast.mjs # 对比度的守卫:每一段正文对它真正落在的那块底够不够 AA
node perf/concurrent.mjs # 多会话并发的守卫:9 个会话同时流式,后台窗格不许以流式速率重绘,帧率不许掉
node perf/idle.mjs # 闲置的守卫:看不见的东西不许动,闲着的窗口不许烧 CPU
node perf/shipped.mjs # 产物的守卫:dist 里的东西必须比源码新(构建可以成功而编的是别的树)
node perf/reason.mjs # 内核拒绝的双语落地:同一个码,中英各说各的
node perf/budget.mjs # 预算的守卫:输入框上下堆满东西时,转录还剩多少
node perf/find.mjs # 对话内查找:快捷键穿过输入框、跳到没挂载的那条、同一行里的第 n 处落在眼前
node perf/focus.mjs # 专注的守卫:拿走外围之后,退出时世界是否原样
node perf/tooltip.mjs # 控件提示的守卫:文字不溢出提示框、不在半透明祖先之下、对比度达 AA(深浅×中英×宽窄)
```
`lang.mjs` 从仓库外跑时用 `PERF_SRC` 指向 `src/`(它要读固件源码来决定
-123
View File
@@ -1,123 +0,0 @@
// Many sessions streaming at once (#12232): hidden panes must not be rewritten at
// stream rate, and N=PERF_GUARD must keep FPS_RATIO_MIN of the N=1 frame rate.
// PERF_N=1,3,6,9 lists more rows; the verdict compares only N=1 with PERF_GUARD.
import { chromium } from "playwright";
const URL = process.env.PERF_URL ?? "http://localhost:4399/perf.html";
const CPU = Number(process.env.PERF_CPU ?? 4);
const SECS = Number(process.env.PERF_SECS ?? 5);
const TURNS = Number(process.env.PERF_TURNS ?? 40);
const GUARD = Number(process.env.PERF_GUARD ?? 9);
const COUNTS = (process.env.PERF_N ?? `1,${GUARD}`).split(",").map(Number);
const WRITES_MAX = Number(process.env.PERF_WRITES_MAX ?? 4);
const FPS_RATIO_MIN = Number(process.env.PERF_FPS_RATIO ?? 0.7);
const THOUGHT_CHARS = Number(process.env.PERF_THOUGHT ?? 3000);
const THOUGHT = "先看一下这个文件的结构,再决定改哪里;要注意调用方、回放路径与错误状态。".repeat(Math.ceil(THOUGHT_CHARS / 34 / 40));
const browser = await chromium.launch();
const rows = [];
const fails = [];
for (const n of COUNTS) {
const ctx = await browser.newContext({ locale: "zh-CN", viewport: { width: 1440, height: 900 } });
const page = await ctx.newPage();
const cdp = await ctx.newCDPSession(page);
if (CPU > 1) await cdp.send("Emulation.setCPUThrottlingRate", { rate: CPU });
await cdp.send("Performance.enable");
await page.goto(`${URL}?panes=${n}`, { waitUntil: "networkidle" });
await page.waitForSelector(".app", { timeout: 15000 });
await page.waitForTimeout(700);
await page.evaluate(async (k) => {
const yieldFrame = () => new Promise((r) => requestAnimationFrame(r));
for (let i = 0; i < k; i++) {
window.__feed({ kind: "turn_started" });
window.__feed({ kind: "reasoning", text: "先看文件再决定改哪里。" });
window.__feed({ kind: "text", text: `第 ${i} 段回答。\n\n- 要点一\n- 要点二\n\n` });
window.__feed({ kind: "message" });
window.__feed({ kind: "turn_done" });
if (i % 10 === 9) await yieldFrame();
}
await yieldFrame();
}, TURNS);
await page.waitForTimeout(500);
await page.evaluate((t) => {
window.__feed({ kind: "turn_started" });
for (let i = 0; i < 40; i++) window.__feed({ kind: "reasoning", text: t });
}, THOUGHT);
await page.waitForTimeout(500);
const read = async () => Object.fromEntries((await cdp.send("Performance.getMetrics")).metrics.map((m) => [m.name, m.value]));
async function phase(kind) {
const before = await read();
const r = await page.evaluate(
([k, secs]) =>
new Promise((resolve) => {
let frames = 0;
let worst = 0;
const t0 = performance.now();
let last = t0;
const tick = () => {
frames++;
const now = performance.now();
worst = Math.max(worst, now - last);
last = now;
if (now - t0 < secs * 1000) requestAnimationFrame(tick);
};
requestAnimationFrame(tick);
let writes = 0;
const mo = new MutationObserver((ms) => {
for (const m of ms) {
const at = m.target instanceof Element ? m.target : m.target.parentElement;
if (at?.closest(".pane[data-off] .out")) writes++;
}
});
mo.observe(document.body, { subtree: true, characterData: true, childList: true });
const feed = setInterval(() => window.__feed({ kind: k, text: "继续往下想一小段文字," }), 33);
setTimeout(() => {
clearInterval(feed);
mo.disconnect();
resolve({ wall: performance.now() - t0, frames, worst, writes });
}, secs * 1000);
}),
[kind, SECS],
);
const after = await read();
const d = (key) => (after[key] ?? 0) - (before[key] ?? 0);
return {
N: n,
阶段: kind === "reasoning" ? "thinking" : "answering",
"主线程%": ((d("TaskDuration") * 1000 * 100) / r.wall).toFixed(0),
"脚本%": ((d("ScriptDuration") * 1000 * 100) / r.wall).toFixed(0),
"样式重算/s": (d("RecalcStyleCount") / (r.wall / 1000)).toFixed(0),
"布局/s": (d("LayoutCount") / (r.wall / 1000)).toFixed(0),
fps: ((r.frames / r.wall) * 1000).toFixed(1),
"最长帧/ms": r.worst.toFixed(0),
"后台写/s/窗格": n > 1 ? (r.writes / (r.wall / 1000) / (n - 1)).toFixed(1) : "-",
节点: after.Nodes,
};
}
const a = await phase("reasoning");
await page.evaluate(() => window.__feed({ kind: "text", text: "开始回答。" }));
const b = await phase("text");
rows.push(a, b);
await ctx.close();
}
await browser.close();
console.log(`降频 ${CPU}x,每个窗格 ${TURNS} 轮历史,每阶段 ${SECS}s\n`);
console.table(rows);
for (const phase of ["thinking", "answering"]) {
const one = rows.find((r) => r.N === 1 && r.阶段 === phase);
const many = rows.find((r) => r.N === GUARD && r.阶段 === phase);
if (!one || !many) {
fails.push(`${phase} 缺 N=1 或 N=${GUARD} 的读数`);
continue;
}
const writes = Number(many["后台写/s/窗格"]);
const ratio = Number(many.fps) / Number(one.fps);
console.log(`${phase}: 后台窗格每秒写 ${writes} 次(≤ ${WRITES_MAX}),N=${GUARD} 帧率 / N=1 帧率 = ${ratio.toFixed(2)}(≥ ${FPS_RATIO_MIN})`);
if (writes > WRITES_MAX) fails.push(`${phase} 后台窗格每秒写 ${writes} 次 > ${WRITES_MAX}`);
if (ratio < FPS_RATIO_MIN) fails.push(`${phase} 帧率比 ${ratio.toFixed(2)} < ${FPS_RATIO_MIN}`);
}
console.log(fails.length ? `\n失败 ${fails.length} 项:\n- ${fails.join("\n- ")}` : "\n全部通过");
process.exit(fails.length ? 1 : 0);
+5 -19
View File
@@ -4,7 +4,6 @@ import { track as trackWidth } from "../src/ui/viewport";
import "../src/styles/tokens.css";
import "../src/styles/app.css";
import "../src/styles/studio.css";
import "../src/styles/feedback.css";
import { App } from "../src/ui/App";
import { MockHub } from "../src/port/mock_hub";
import { MockPort } from "../src/port/mock";
@@ -45,12 +44,11 @@ class BenchPort extends MockPort {
}
async providerSetup() {
return ONBOARDING ? { required: true, provider: "", model: "" } : null;
return null;
}
async appearance() {
let look = await super.appearance();
if (query.has("zoom")) look = { ...look, zoom: Number(query.get("zoom")) };
const look = await super.appearance();
return PREF === null ? look : { ...look, language: PREF };
}
@@ -150,7 +148,6 @@ const WORKSPACES = Number(query.get("ws") ?? 0);
// ?pref= 是「内核记着的语言」。真机上它来自 config;这里由地址给,好让
// 一次验证能把两侧摆成同一个值——否则 adopt 会认为本地缓存过期。
const PREF = query.get("pref");
const ONBOARDING = query.has("onboarding");
const SESSIONS = Number(query.get("sess") ?? 0);
// ?turns= is how long the conversation being opened already is.
const TURNS = Number(query.get("turns") ?? 0);
@@ -161,9 +158,8 @@ const JOBS = Number(query.get("jobs") ?? 0);
// ?treems= holds every tree read back until that long after load, so the
// window can be seen before the kernel has named a single folder.
const TREE_AT = performance.now() + Number(query.get("treems") ?? 0);
// ?panes=0 opens the window with no conversation open; ?panes=N opens N.
const PANES = query.has("panes") ? Number(query.get("panes")) : 1;
const NO_PANES = PANES === 0;
// ?panes=0 opens the window with no conversation open.
const NO_PANES = query.get("panes") === "0";
class BenchHub extends MockHub {
readonly feeds = new Map<string, BenchPort>();
@@ -175,17 +171,7 @@ class BenchHub extends MockHub {
}
runtimes(): Promise<RuntimeView[]> {
if (NO_PANES) return Promise.resolve([]);
if (PANES <= 1) return super.runtimes();
return Promise.resolve(
Array.from({ length: PANES }, (_, i) => ({
id: `r${i + 1}`,
base: i === 0 ? "" : `/rt/r${i + 1}`,
root: "~/projects/DeepSeek-Reasonix",
name: "DeepSeek-Reasonix",
sessionPath: `/sessions/pane-${i + 1}.jsonl`,
})),
);
return NO_PANES ? Promise.resolve([]) : super.runtimes();
}
tree(): Promise<TreeWorkspace[]> {
-108
View File
@@ -1,108 +0,0 @@
// The first-run "start" button must be reachable at every window height.
// The body clips, so a card taller than the window is only reachable if the
// stage itself scrolls. Focus is no evidence: the browser scrolls the clipped
// body to a focused element, so the wheel is what gets asserted.
import { chromium } from "playwright";
import { mkdirSync } from "node:fs";
import { join } from "node:path";
const PAGE = process.env.PERF_URL ?? "http://localhost:4399/perf.html?onboarding=1";
const SHOTS = process.env.ONBOARDING_SHOTS;
const SIZES = [
[1920, 700], [1600, 600], [2560, 560], [1366, 600], [1280, 540], [1440, 900], [1600, 760, 1.25],
[760, 480], [761, 480], [760, 480, 0.8], [761, 480, 1.8], [800, 480, 1.8], [840, 560, 1.8],
[840, 560, 1.5], [1280, 720, 1.8], [1600, 900, 1.8, [840, 560]], [840, 560, 1.8, [1600, 900]],
];
if (SHOTS) mkdirSync(SHOTS, { recursive: true });
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 [width, height, zoom = 1, resize] of SIZES) {
const ctx = await browser.newContext({ locale: "zh-CN", viewport: { width, height }, colorScheme: scheme });
const page = await ctx.newPage();
page.on("pageerror", (e) => fails.push("page error: " + e.message));
const url = new URL(PAGE);
url.searchParams.set("zoom", String(zoom));
await page.goto(url.href, { waitUntil: "domcontentloaded" });
await page.waitForSelector(".onb-card");
await page.waitForFunction((z) => Number(getComputedStyle(document.documentElement).zoom) === z, zoom);
const tag = `${scheme} ${width}x${height}${zoom !== 1 ? ` zoom${zoom}` : ""}${resize ? ` to ${resize.join("x")}` : ""}`;
const fitsCard = async (label) => {
const layout = await page.evaluate(() => {
const stage = document.querySelector(".onb-stage");
const shell = document.querySelector(".onb-shell");
const r = shell.getBoundingClientRect();
const fields = ["#onb-url", "#onb-key", ".onb-go", ".onb-note", ".onb-protocols", ".onb-pick"];
const clipped = fields.flatMap((selector) => {
const el = document.querySelector(selector);
if (!el) return [];
const box = el.getBoundingClientRect();
return box.left < Math.max(0, r.left) - 0.5 || box.right > Math.min(innerWidth, r.right) + 0.5
? [{ selector, left: box.left, right: box.right, shellRight: r.right }] : [];
});
return { clipped, columns: getComputedStyle(shell).gridTemplateColumns.split(" ").length,
expectedColumns: stage.clientWidth <= 760 ? 1 : 2, width: stage.scrollWidth, client: stage.clientWidth };
});
check(`${tag} ${label}: form stays within the card and viewport`, layout.clipped.length === 0, JSON.stringify(layout.clipped));
check(`${tag} ${label}: columns follow zoom-adjusted room`, layout.columns === layout.expectedColumns, JSON.stringify(layout));
check(`${tag} ${label}: stage does not scroll sideways`, layout.width <= layout.client + 1);
};
const reachable = async (label, enabled) => {
const viewport = page.viewportSize();
const go = page.locator(".onb-go");
await page.evaluate(() => document.activeElement?.blur());
await page.mouse.move(viewport.width / 2, viewport.height / 2);
await page.mouse.wheel(0, 4000);
await page.waitForTimeout(250);
const box = await go.boundingBox();
check(`${tag} ${label}: after wheeling to the bottom the button is in the viewport`, !!box && box.y >= 0 && box.y + box.height <= viewport.height + 0.5, JSON.stringify(box));
const bar = await page.locator(".onb-brandbar").boundingBox();
check(`${tag} ${label}: the title bar stays in the viewport`, !!bar && bar.y >= -0.5 && bar.y + bar.height <= viewport.height + 0.5, JSON.stringify(bar));
if (!enabled) return;
const hit = await page.evaluate(() => {
const r = document.querySelector(".onb-go").getBoundingClientRect();
const el = document.elementFromPoint(r.x + r.width / 2, r.y + r.height / 2);
return !!el?.closest(".onb-go");
});
check(`${tag} ${label}: the point under its centre hits it`, hit);
if (SHOTS) await page.screenshot({ path: join(SHOTS, `${tag.replaceAll(" ", "-")}-${label.replaceAll(" ", "-")}.png`) });
const first = page.locator(".onb-chip").first();
await page.evaluate(() => document.activeElement?.blur());
await first.focus();
await page.waitForTimeout(250);
const [fb, bb] = [await first.boundingBox(), await page.locator(".onb-brandbar").boundingBox()];
check(`${tag} ${label}: a focused top-of-card element sits below the title bar`, !!fb && !!bb && fb.y >= bb.y + bb.height - 0.5, JSON.stringify({ fb, bb }));
};
await fitsCard("before connect");
await reachable("before connect", false);
await page.fill('input[placeholder="sk-…"]', "sk-test");
await page.locator(".onb-go").click();
await page.waitForSelector(".onb-found");
await fitsCard("after connect");
await reachable("after connect", true);
if (resize) {
await page.setViewportSize({ width: resize[0], height: resize[1] });
await page.waitForFunction(() => {
const stage = document.querySelector(".onb-stage");
return Math.abs(stage.getBoundingClientRect().width - innerWidth) < 1;
});
await fitsCard("after resize");
await reachable("after resize", true);
}
await page.locator(".onb-go").click();
await page.waitForSelector(".app .chrome");
check(`${tag}: Start finishes setup`, await page.locator(".onb-stage").count() === 0);
await ctx.close();
}
}
await browser.close();
console.log(fails.length ? `\n${fails.length} failed` : "\nall passed");
process.exit(fails.length ? 1 : 0);
-109
View File
@@ -1,109 +0,0 @@
// Ink contrast per tier and scheme. Each tier's inks must be at least as legible
// as the default palette stepped once, and as the recorded floors below (ratios
// on --page; --ghost shares --faint's), and every ink must clear AA on both grounds.
import { chromium } from "playwright";
const PAGE = process.env.PERF_URL ?? "http://localhost:4399/perf.html?pref=zh&ws=2&sess=6&turns=1";
const STEPS = { "--text": [3.0, 10.1], "--muted": [3.5, 7.5], "--faint": [3.5, 7.0], "--ghost": [3.5, 7.0] };
const INKS = Object.keys(STEPS);
const GROUNDS = ["--page", "--surface"];
const TIERS = ["soft", "normal", "strong"];
const FLOOR = {
"light/soft": { "--text": 11.69, "--muted": 7.63, "--faint": 5.76 },
"light/normal": { "--text": 14.07, "--muted": 10.22, "--faint": 7.86 },
"light/strong": { "--text": 17.86, "--muted": 13.55, "--faint": 10.42 },
"dark/soft": { "--text": 11.48, "--muted": 7.48, "--faint": 5.63 },
"dark/normal": { "--text": 13.92, "--muted": 9.79, "--faint": 7.57 },
"dark/strong": { "--text": 18.01, "--muted": 13.16, "--faint": 9.92 },
};
const 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: 1200, height: 800 }, locale: "zh-CN" });
page.on("pageerror", (e) => fails.push("page error: " + e.message));
await page.goto(PAGE, { waitUntil: "networkidle" });
await page.waitForSelector(".compose");
const measured = await page.evaluate(({ STEPS, INKS, GROUNDS, TIERS }) => {
const root = document.documentElement;
const probe = document.createElement("div");
document.body.append(probe);
const ctx = document.createElement("canvas").getContext("2d", { willReadFrequently: true });
const rgb = (css) => {
ctx.clearRect(0, 0, 1, 1);
ctx.fillStyle = "#000";
ctx.fillStyle = css;
ctx.fillRect(0, 0, 1, 1);
return [...ctx.getImageData(0, 0, 1, 1).data].slice(0, 3);
};
const lum = (c) => {
const [r, g, b] = c.map((v) => {
const s = v / 255;
return s <= 0.04045 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4;
});
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
};
const ratio = (a, b) => {
const [hi, lo] = [lum(a), lum(b)].sort((x, y) => y - x);
return (hi + 0.05) / (lo + 0.05);
};
const read = (name) => {
probe.style.color = `var(${name})`;
return getComputedStyle(probe).color;
};
const out = {};
for (const scheme of ["light", "dark"]) {
root.dataset.theme = scheme;
delete root.dataset.contrast;
const base = Object.fromEntries(INKS.map((n) => [n, read(n === "--ghost" ? "--faint" : n)]));
const grounds = Object.fromEntries(GROUNDS.map((g) => [g, rgb(read(g))]));
for (const tier of TIERS) {
const k = TIERS.indexOf(tier);
root.dataset.contrast = tier;
for (const ink of INKS) {
const by = k === 0 ? 0 : STEPS[ink][k - 1];
probe.style.color = by ? `oklch(from ${base[ink]} calc(l ${scheme === "light" ? "-" : "+"} ${(by / 100).toFixed(3)}) c h)` : base[ink];
const ref = rgb(getComputedStyle(probe).color);
const got = rgb(read(ink));
for (const g of GROUNDS) out[`${scheme}/${tier}/${ink}/${g}`] = { ref: ratio(ref, grounds[g]), got: ratio(got, grounds[g]) };
}
}
}
probe.remove();
return out;
}, { STEPS, INKS, GROUNDS, TIERS });
for (const [key, { ref, got }] of Object.entries(measured)) {
const [scheme, tier, ink, ground] = key.split("/");
const floor = ground === "--page" ? (FLOOR[`${scheme}/${tier}`][ink === "--ghost" ? "--faint" : ink] ?? 0) : 0;
check(`${key} not below the stepped palette or its floor`, got >= Math.max(ref, floor) - 0.005, `${got.toFixed(2)} vs ${Math.max(ref, floor).toFixed(2)}`);
check(`${key} clears AA`, got >= 4.5, got.toFixed(2));
}
for (const scheme of ["light", "dark"]) {
await page.emulateMedia({ colorScheme: scheme, contrast: "more" });
const same = await page.evaluate((scheme) => {
const root = document.documentElement;
root.dataset.theme = scheme;
delete root.dataset.contrast;
const p = document.createElement("div");
document.body.append(p);
const c = (n) => ((p.style.color = `var(${n})`), getComputedStyle(p).color);
const r = [c("--faint"), c("--ghost")];
p.remove();
return r;
}, scheme);
check(`${scheme} under prefers-contrast: more keeps ghost on faint`, same[0] === same[1], same.join(" / "));
}
await browser.close();
if (fails.length) {
console.log(`\n${fails.length} failed`);
process.exit(1);
}
console.log("\nevery tier holds");
-99
View File
@@ -1,99 +0,0 @@
// Composer control tooltips: the box must hold its own text, must not sit under a
// dimmed ancestor, and must stay readable. All three are layout/paint questions jsdom cannot answer.
import { chromium } from "playwright";
const BASE = process.env.PERF_URL ?? "http://localhost:4399/perf.html";
const BOX = 'textarea[role="combobox"]';
const fails = [];
const check = (name, ok, detail = "") => {
console.log(`${ok ? " ok" : "FAIL"} ${name}${detail ? " — " + detail : ""}`);
if (!ok) fails.push(name);
};
// Controls are found by structure, never by label: the label is translated,
// so a lookup by its wording matches one language and skips the other.
const CONTROLS = { "prompt.refine": '[data-action="prompt.refine"]', attach: "button.attach" };
const measure = (page, selector) =>
page.evaluate((selector) => {
const button = document.querySelector(`.compose ${selector}`);
const tip = button?.querySelector(".studio-control-tip");
if (!button || !tip) return null;
let opacity = 1;
for (let el = tip; el; el = el.parentElement) opacity *= parseFloat(getComputedStyle(el).opacity);
const ink = document.createElement("canvas").getContext("2d", { willReadFrequently: true });
const rgba = (css) => {
ink.clearRect(0, 0, 1, 1);
ink.fillStyle = "#000";
ink.fillStyle = css;
ink.fillRect(0, 0, 1, 1);
const [r, g, b, a] = ink.getImageData(0, 0, 1, 1).data;
return [r, g, b, a / 255];
};
const lum = ([r, g, b]) => {
const f = (c) => ((c /= 255) <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4);
return 0.2126 * f(r) + 0.7152 * f(g) + 0.0722 * f(b);
};
const over = (top, bottom) => {
const a = top[3];
return [0, 1, 2].map((i) => top[i] * a + bottom[i] * (1 - a)).concat(1);
};
const bg = rgba(getComputedStyle(tip).backgroundColor);
const ratios = [...tip.children].map((child) => {
const fg = over(rgba(getComputedStyle(child).color), bg);
const [hi, lo] = [lum(fg), lum(bg)].sort((x, y) => y - x);
return (hi + 0.05) / (lo + 0.05);
});
const box = tip.getBoundingClientRect();
const spill = [...tip.children].map((child) => {
const range = document.createRange();
range.selectNodeContents(child);
return Math.max(0, range.getBoundingClientRect().right - box.right);
});
return {
opacity,
visibility: getComputedStyle(tip).visibility,
minRatio: Math.min(...ratios),
spill: Math.max(...spill),
scroll: tip.scrollWidth - tip.clientWidth,
left: box.left,
right: box.right,
width: box.width,
viewport: innerWidth,
disabled: button.disabled,
};
}, selector);
const browser = await chromium.launch();
for (const scheme of ["light", "dark"]) {
for (const lang of ["zh", "en"]) {
for (const width of [1440, 420]) {
const page = await browser.newPage({ viewport: { width, height: 900 }, colorScheme: scheme, reducedMotion: "reduce" });
page.setDefaultTimeout(8000);
page.on("pageerror", (e) => fails.push("页面异常: " + e.message));
await page.goto(`${BASE}?pref=${lang}&turns=2`, { waitUntil: "networkidle" });
await page.waitForSelector(".compose");
await page.evaluate(() => document.fonts.ready);
for (const [text, control] of [["", "prompt.refine"], ["1111", "prompt.refine"], ["", "attach"]]) {
await page.fill(BOX, text);
const tag = `${scheme}/${lang}/${width}px/${control}/${text ? "有字" : "空"}`;
if (!(await page.$(`.compose ${CONTROLS[control]}`))) { check(`${tag}:找得到按钮`, false); continue; }
await page.hover(`.compose ${CONTROLS[control]}`, { force: true });
await page.waitForTimeout(250);
const m = await measure(page, CONTROLS[control]);
if (!m) { check(`${tag}:找得到提示`, false); continue; }
check(`${tag}:提示不在半透明的祖先之下`, m.opacity === 1 && m.visibility === "visible", `有效不透明度 ${m.opacity.toFixed(2)}`);
check(`${tag}:文字不溢出提示框`, m.spill <= 0.5 && m.scroll <= 0, `溢出 ${m.spill.toFixed(1)}px,scroll ${m.scroll}px`);
check(`${tag}:提示框留在窗口内`, m.left >= 0 && m.right <= m.viewport, `${Math.round(m.left)}–${Math.round(m.right)} / ${m.viewport}`);
check(`${tag}:正文对比度达 AA`, m.minRatio >= 4.5, `${m.minRatio.toFixed(2)}:1`);
}
await page.close();
}
}
}
await browser.close();
if (fails.length) {
console.log(`\n${fails.length} 项不合格`);
process.exit(1);
}
+138 -125
View File
@@ -60,14 +60,14 @@ importers:
specifier: ^6.43.13
version: 6.43.13
'@lezer/highlight':
specifier: ^1.2.5
version: 1.2.5
specifier: ^1.2.4
version: 1.2.4
codemirror:
specifier: ^6.0.2
version: 6.0.2
katex:
specifier: ^0.18.10
version: 0.18.10
specifier: ^0.18.9
version: 0.18.9
react:
specifier: ^19.3.0
version: 19.3.0
@@ -116,7 +116,7 @@ importers:
version: 19.3.0(@types/[email protected])
'@vitejs/plugin-react':
specifier: ^6.1.1
version: 6.1.1([email protected].2([email protected]))
version: 6.1.1([email protected].0([email protected]))
jsdom:
specifier: ^30.1.1
version: 30.1.1
@@ -127,11 +127,11 @@ importers:
specifier: ^7.0.2
version: 7.0.2
vite:
specifier: ^8.3.2
version: 8.3.2([email protected])
specifier: ^8.3.0
version: 8.3.0([email protected])
vitest:
specifier: ^5.0.3
version: 5.0.3([email protected])([email protected].2([email protected]))
specifier: ^5.0.1
version: 5.0.1([email protected])([email protected].0([email protected]))
packages:
@@ -453,8 +453,8 @@ packages:
'@lezer/[email protected]':
resolution: {integrity: sha512-xToRsYxwsgJNHTgNdStpcvmbVuKxTapV0dM0wey1geMMRc9aggoVyKgzYp41D2/vVOx+Ii4hmE206kvxIXBVXQ==}
'@lezer/[email protected].5':
resolution: {integrity: sha512-O1GMVKgtf5YspFaRzpmqkVgMtIk2HG9uMAbH6wdAIdNh3TVzCsch/+cRi+6l2//UGDKe6NB9BW3cvuR5ezMQ0w==}
'@lezer/[email protected].4':
resolution: {integrity: sha512-4UeRVFO0lsBaM7GA32zOqbmqNPme9jedo8Ey+5c5656MU3MLitMLIA8aCZTYjUMEpEsuSeXEm78Uea6Iq3bvSg==}
'@lezer/[email protected]':
resolution: {integrity: sha512-oI7n6NJml729m7pjm9lvLvmXbdoMoi2f+1pwSDJkl9d68zGr7a9Btz8NdHTGQZtW2DA25ybeuv/SyDb9D5tseg==}
@@ -489,101 +489,101 @@ packages:
'@marijn/[email protected]':
resolution: {integrity: sha512-Wy0V7+SGUjnF9/TkiM1hKVDPj7jKXduPNboMVtHTA8dySMURWqfg/JZ9E2Sq8JgSJmkl7k7Qe9FLeMSrSraWmQ==}
'@oxc-project/[email protected]52.0':
resolution: {integrity: sha512-oM/5rLBm2tPkg0iBgkH/FOeR3PCDpY19GTgAZjMFM8h9WI9VW7cLgzp6nwtarYKmovavIQZ+Fe/RKX/8C8O/Rw==}
'@oxc-project/[email protected]47.0':
resolution: {integrity: sha512-IJ3s6ltHLp45S0bh7phkX+gJO7A1Wuz2EaqpAhb8WjqDwbzMiWKHhyyT42tskaWjEYXtHtVCPpnBJVT9+dcRLg==}
'@rolldown/[email protected].12':
resolution: {integrity: sha512-dB/a1214qKfHMXCpgqR4OZT+jS4kTyEXbQGJPqzobt5EwH5rX080pxE37alt3RzvR1bf1Yz/yGqRfrYAxuPw0A==}
'@rolldown/[email protected].6':
resolution: {integrity: sha512-b+jTcARdTiFLI6jB4a5XjTm0RWd6KcRfQj/I2356fxUZemiho9zQLxo0RtCuMDAyKcLo6cEltkgbQp6d1+sjjQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm]
os: [android]
'@rolldown/[email protected].12':
resolution: {integrity: sha512-7KHFgQ5VJxIHcLlrwrc3Xbds7oTNQT7Pgi9gQCJKrd2VGab/UksIOYp6VD8MzCstGxOKMgNamPwUCfxPdP1OHg==}
'@rolldown/[email protected].6':
resolution: {integrity: sha512-lkWU8ZJaRk9q3CIEY1Tc7vIFALp3Xw5NfGJo2hQg5oIqNgxWi1zI+IiDEK3r70BF5Dzol1tcXsnzsRc8NLhG+Q==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [android]
'@rolldown/[email protected].12':
resolution: {integrity: sha512-3YIhqHD96nA5SaYNRBR16HnGv4oavZvXfD/ayHM+oYZ0WD/8lBAtf6zQua4kEyAvpqrluKXl0lnOBoiNby7x9w==}
'@rolldown/[email protected].6':
resolution: {integrity: sha512-dgR56NYnvAszm7Ob1B2/Vn0e8bUQYZH2UjVaMMtMVOCKFSfjhfLmuA/9+O+F+ajUdG6B/bSssrKW6JJYASa8jA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [darwin]
'@rolldown/[email protected].12':
resolution: {integrity: sha512-UuuJ35MFw4gmFOrE9pEqIV+K3syIKveph+Qc1/ljHZVdoDW4pz/JHR/eMVom+TZGl/5OOvGJOWaOCVt3ZfqhxA==}
'@rolldown/[email protected].6':
resolution: {integrity: sha512-vpVxFvUCFioJqug7OTvqptkc4yb8UX0AwfDmJpaR/0sWz+BUmqSVAf7c8JkUgnN8YLspb4a/N6NhTyMAmdyQ7Q==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [darwin]
'@rolldown/[email protected].12':
resolution: {integrity: sha512-uMvssit0a4W+/7D8CbHUvG719mH3R2jwXAlh/XcPvuHTE0g++LymF88DCGNX0HM2rBOn0xrzgXktIB6fLSJBTQ==}
'@rolldown/[email protected].6':
resolution: {integrity: sha512-h1wG6Y6K3JlRswxsI64qQJqBAy4vrLuHgRbc8CZMGSWTOFRY6ghMApM1NKzB2I0n5xV1fjkE18SuVl2QpLeNpA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [freebsd]
'@rolldown/[email protected].12':
resolution: {integrity: sha512-XcFu0R0xWnwzSf4IQgFH1rJIckPN1pLy2R+4r9IDB7Yfu/ys9cVqfa4pBrMHj7a3gl8mIR4nRNPg0e5IvEVs6g==}
'@rolldown/[email protected].6':
resolution: {integrity: sha512-tbCiqub0q2MVWJKgF5PoAlNWCtQydiOYSLIkd8sByqK/6MMYLJRcSXSYodqYtd0O+Fw7QaVmKKlS4oL94YRZ0w==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm]
os: [linux]
'@rolldown/[email protected].12':
resolution: {integrity: sha512-260UrKgn8tz39ak+SMDOirKzr7V04M9dWPw5llW00SwBivCZoWcRBKV1d8cXnRkUmSZA3BdiUmBHWk7734Ulpw==}
'@rolldown/[email protected].6':
resolution: {integrity: sha512-oxK9+baEBPhZG5HB4URY+uU04zJWeZlH6Tb9rB5DK4DF9XR1uXNLXt5Q5ZsugTKayNCNLhkcwz/ye74hRI98dg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@rolldown/[email protected].12':
resolution: {integrity: sha512-5YK1I9SqDkbPgc1IA8BgDl34suqUS2q0KWnBrirm0E51YjOs6eo6dV6jbQfNE/argHRSvd0QUGgtpIoYx+WWpw==}
'@rolldown/[email protected].6':
resolution: {integrity: sha512-muWCk27FVBEZtv0MsK8gnfSmgczA8KQ0uRVJbTABKhkRfQc38aUrcb7fhi3BNiyseFmgcRsoMfQsSNJ+DbZdSw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [musl]
'@rolldown/[email protected].12':
resolution: {integrity: sha512-Rkcrmp7eFRg74yL5fXEU91JEWbdEPLevWwGtXpmhbjlD1StScbWTmO94Bhly+Mo+ketKYkdmM1vNUKeWSlx8cQ==}
'@rolldown/[email protected].6':
resolution: {integrity: sha512-eWDoSfU7Co2qj3vgB3Dt4lj1mG6CoWbcJQkRMP3XJplyCMtuaq3LHvPFjS9QIPvMGWVadJC04Xiy0IdcVPtnwQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@rolldown/[email protected].12':
resolution: {integrity: sha512-qvK4DuAsQc2BSjlx+Xr+IzOIvvxbGZqxFwdWfG6F518Erj0GGISyQbJ6pIappnOxlNPzNHvo/L0BwB30GZ+zVw==}
'@rolldown/[email protected].6':
resolution: {integrity: sha512-2bWNjRSIayvupRKxXUY2tWG9fYdoUlTqWywHRvE8Eq3GvuQ+f2HeIkve697fIt+IQs/PV8yFsdWuhp1aJ1PdnA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@rolldown/[email protected].12':
resolution: {integrity: sha512-Q9uLBO53Xd4QIq1WOycVQyPP1O4HhraEV2qqb3uTrnVw6QZih9duY4vNXOivL1xoUS1/z+W8eF4NMfl2a8Sdjw==}
'@rolldown/[email protected].6':
resolution: {integrity: sha512-KekI0gS0wLxe1UBSQSjenBVwou/JkcQPDzBPICGZjxUv9k3RteHDPBQaiOicZUFKRIH2wKEimGwVpnJsbPzu7w==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [glibc]
'@rolldown/[email protected].12':
resolution: {integrity: sha512-3IBxWFMjbOZskDPKv8Lf9BCnahlKuHthWkYnyIxOH/QcJrFcS4EmcenthApkwr/5+nEqZlLzeYbxeMaX7A5u4g==}
'@rolldown/[email protected].6':
resolution: {integrity: sha512-TvtPnfVr+HtyGiDmPK4VWmlNm7QhNNAcK5Q9A7aOXsI8545yCyaoMaicXrFZ72JzeYjaUVk7yT243zT0jzjFKQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [musl]
'@rolldown/[email protected].12':
resolution: {integrity: sha512-xtX61xg4LKPkPWilZU1ynKClz5Gj4bf74LML4r3eVLWumKnGjoEr1OSHQhMdbBDoYTi+yjrujvpZe2pUnqCrrA==}
'@rolldown/[email protected].6':
resolution: {integrity: sha512-iOo0VEay2XFhaCcH0sps5XIimkSuOnNaZrf6+ZkoSOQBJPKNU48RkmJv0/lSpipexu5P+ouFgafe5IGr/DiQfg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [openharmony]
'@rolldown/[email protected].12':
resolution: {integrity: sha512-At7fPB6PCaIjzgIhEZFxuT+BBFqiQibJDT4d3PhiR3f4E7bbMZF4aKblbFfEM3sETRDd1YiQx/+U/g/B/ou5Ew==}
'@rolldown/[email protected].6':
resolution: {integrity: sha512-y5NTmmasMS455JlOCO4ZM9krIchv3Mvm1crL1iUPGOPgEzSkves9n0SdC5Sjz6+qWDFhd8/JpfWMH8NSWNHe+A==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [win32]
'@rolldown/[email protected].12':
resolution: {integrity: sha512-WIw2haVKwjuYdXkHaoC0mF8Le71TuCBxjrdKqLbJGctbBABj+ClfmNvtbOnzpq3RokNo5+V1qhtSzJyXorsklQ==}
'@rolldown/[email protected].6':
resolution: {integrity: sha512-np8iZSLfXlAD4kWhiyq/u0Yt8oZDtRQ8lGhQaCXo2rl37KNjeU0GjJuwr4P3oeZ++ROfofsKNBqR5LTO8aXyWQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [win32]
@@ -799,8 +799,8 @@ packages:
oxc-transform-react:
optional: true
'@vitest/[email protected].3':
resolution: {integrity: sha512-T8sWAIbkSyAjkwTcaEc3Iu0o9A27X1/kdXrizhZkGuSKScRQtRzclfAMpOTcGdXCsqxeWlpGy3XjqaW8CpLORg==}
'@vitest/[email protected].1':
resolution: {integrity: sha512-6K1DoBNAPGvuOcSsGA4D6x+5zEEff/KmOOP3uetT2TrGpVfI+HRHRnJJfKi5ib/g1vx8IYHQD8s0pbJz8WQI7Q==}
peerDependencies:
msw: ^2.4.9
vite: ^6.0.0 || ^7.0.0 || ^8.0.0
@@ -810,8 +810,8 @@ packages:
vite:
optional: true
'@vitest/[email protected].3':
resolution: {integrity: sha512-XhFysQTB8AZ+P4gMi+Lpo99vg2AZi0qKpaB9yXQl37+CaMEAPO3iH/wGVnSyL5MPERiLezpqTVtrR6UZH5GCXg==}
'@vitest/[email protected].1':
resolution: {integrity: sha512-rbto/mF/SGERxEgYOek7Xm6B9b+y+mVoo+f4b2LymYO8zM1b7uB5nHuhVMTP2hxdzgxvGiZYGxGIaMvL5y180Q==}
[email protected]:
resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==}
@@ -1051,8 +1051,8 @@ packages:
resolution: {integrity: sha512-Eeo8Ys1doU1z+x8AZsPpQu+p/QcZBI5PeOo7QGQdy2x2m0MU/hYagBbGOmXwr5KVbEfVuWv9LpnQWeehogurjg==}
hasBin: true
[email protected].10:
resolution: {integrity: sha512-/B6p9eY9DX7aHBfpkHdpirDTZ5QH9xTwL9y837PWuzuv41O5jFdqNQwVQEQsimY0/iVNkwBY4+4hMfhQ7d4Dbw==}
[email protected].9:
resolution: {integrity: sha512-8ad9RyoKsb/g8/yLFE+KAlP+DhbCTRUNi/V9XGsxn0R+trJJltNwzcDNo0q/DEkOy5fQUQTAQyCXCYSE+OakTQ==}
hasBin: true
[email protected]:
@@ -1396,8 +1396,8 @@ packages:
resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==}
engines: {node: '>=0.10.0'}
[email protected].12:
resolution: {integrity: sha512-8wafseiaG80xmXSfqidUNqZcylTlhmPZZt+za2m+js2sFZ8dTNlhIOV2WcbIPx2hgwPBJpEUGFAMZ9bgBBLTSQ==}
[email protected].6:
resolution: {integrity: sha512-vMM4q3aixf46GiF1Kok8jDPFsEpXgFWGjUHXNkNHNm+Y2adXAG2dbX91jkti3i0ZRsOlcmbuzAz1poObSHCmUA==}
engines: {node: ^20.19.0 || >=22.12.0}
hasBin: true
@@ -1408,6 +1408,9 @@ packages:
[email protected]:
resolution: {integrity: sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==}
[email protected]:
resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==}
[email protected]:
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
engines: {node: '>=0.10.0'}
@@ -1415,6 +1418,9 @@ packages:
[email protected]:
resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==}
[email protected]:
resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==}
[email protected]:
resolution: {integrity: sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==}
@@ -1505,8 +1511,8 @@ packages:
[email protected]:
resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==}
[email protected].2:
resolution: {integrity: sha512-SQr1x6W5vVSbROg7vsyXIaxK9b0G7zsT68acdWWRmnBUsgDieLCRG+Rep9WdZgcposvv/GSnr4GUUBqB3vXq6w==}
[email protected].0:
resolution: {integrity: sha512-lhZBVvEHefgE+HQZC9O7EBJgCU/nVzFNl7vkS4RE0APtWLP02/8QVIkQtzBxPquh7lq5/78NHipTj7ODQ6XuyQ==}
engines: {node: ^20.19.0 || >=22.12.0}
hasBin: true
peerDependencies:
@@ -1548,20 +1554,20 @@ packages:
yaml:
optional: true
[email protected].3:
resolution: {integrity: sha512-xMw97S3rjdtj5dkVat7jCsqWBpvchs3RlpQctUqwJD0KkERk40vz2fJ77lDwW/Vzh/pk18eItYAzkodhSes3jQ==}
[email protected].1:
resolution: {integrity: sha512-iA95lQbKEkvrtTkdAgnWbXfbipWiiWe/hDl2P5tMi6WFwD76G0NxXAGp/M9EOcYupeGJRr6wppMc7CoA41TQjg==}
engines: {node: ^22.12.0 || ^24.0.0 || >=26.0.0}
hasBin: true
peerDependencies:
'@edge-runtime/vm': '*'
'@opentelemetry/api': ^1.9.0
'@types/node': ^22.0.0 || >=24.0.0
'@vitest/browser-playwright': 5.0.3
'@vitest/browser-preview': 5.0.3
'@vitest/browser-playwright': 5.0.1
'@vitest/browser-preview': 5.0.1
'@vitest/browser-webdriverio': ^5.0.0-beta.5 || >=5.0.0
'@vitest/coverage-istanbul': 5.0.3
'@vitest/coverage-v8': 5.0.3
'@vitest/ui': 5.0.3
'@vitest/coverage-istanbul': 5.0.1
'@vitest/coverage-v8': 5.0.1
'@vitest/ui': 5.0.1
happy-dom: '*'
jsdom: '*'
vite: ^6.4.0 || ^7.0.0 || ^8.0.0
@@ -1615,9 +1621,9 @@ packages:
resolution: {integrity: sha512-TEZA+Zqxin7Jjsm2cjRohCmen5awh+hT6Zi3VZdqZlNRk7zvOI/9WpBFg/DWlA56bWnzwm6DuB8NS0EsxQH9uQ==}
engines: {node: ^22.14.0 || >=24.0.0}
why-is-node-running@3.2.1:
resolution: {integrity: sha512-Tb2FUhB4vUsGQlfSquQLYkApkuPAFQXGFzxWKHHumVz2dK+X1RUm/HnID4+TfIGYJ1kTcwOaCk/buYCEJr6YjQ==}
engines: {node: '>=20.11'}
why-is-node-running@2.3.0:
resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==}
engines: {node: '>=8'}
hasBin: true
[email protected]:
@@ -1765,7 +1771,7 @@ snapshots:
'@codemirror/language': 6.12.4
'@codemirror/state': 6.7.6
'@lezer/common': 1.5.2
'@lezer/highlight': 1.2.5
'@lezer/highlight': 1.2.4
'@lezer/lr': 1.4.10
'@codemirror/[email protected]':
@@ -1783,7 +1789,7 @@ snapshots:
'@codemirror/language': 6.12.4
'@codemirror/state': 6.7.6
'@lezer/common': 1.5.2
'@lezer/highlight': 1.2.5
'@lezer/highlight': 1.2.4
'@lezer/lr': 1.4.10
'@lezer/yaml': 1.0.4
@@ -1792,7 +1798,7 @@ snapshots:
'@codemirror/state': 6.7.6
'@codemirror/view': 6.43.13
'@lezer/common': 1.5.2
'@lezer/highlight': 1.2.5
'@lezer/highlight': 1.2.4
'@lezer/lr': 1.4.10
style-mod: 4.1.4
@@ -1937,41 +1943,41 @@ snapshots:
'@lezer/[email protected]':
dependencies:
'@lezer/common': 1.5.2
'@lezer/highlight': 1.2.5
'@lezer/highlight': 1.2.4
'@lezer/lr': 1.4.10
'@lezer/[email protected]':
dependencies:
'@lezer/common': 1.5.2
'@lezer/highlight': 1.2.5
'@lezer/highlight': 1.2.4
'@lezer/lr': 1.4.10
'@lezer/[email protected].5':
'@lezer/[email protected].4':
dependencies:
'@lezer/common': 1.5.2
'@lezer/[email protected]':
dependencies:
'@lezer/common': 1.5.2
'@lezer/highlight': 1.2.5
'@lezer/highlight': 1.2.4
'@lezer/lr': 1.4.10
'@lezer/[email protected]':
dependencies:
'@lezer/common': 1.5.2
'@lezer/highlight': 1.2.5
'@lezer/highlight': 1.2.4
'@lezer/lr': 1.4.10
'@lezer/[email protected]':
dependencies:
'@lezer/common': 1.5.2
'@lezer/highlight': 1.2.5
'@lezer/highlight': 1.2.4
'@lezer/lr': 1.4.10
'@lezer/[email protected]':
dependencies:
'@lezer/common': 1.5.2
'@lezer/highlight': 1.2.5
'@lezer/highlight': 1.2.4
'@lezer/lr': 1.4.10
'@lezer/[email protected]':
@@ -1981,79 +1987,79 @@ snapshots:
'@lezer/[email protected]':
dependencies:
'@lezer/common': 1.5.2
'@lezer/highlight': 1.2.5
'@lezer/highlight': 1.2.4
'@lezer/[email protected]':
dependencies:
'@lezer/common': 1.5.2
'@lezer/highlight': 1.2.5
'@lezer/highlight': 1.2.4
'@lezer/lr': 1.4.10
'@lezer/[email protected]':
dependencies:
'@lezer/common': 1.5.2
'@lezer/highlight': 1.2.5
'@lezer/highlight': 1.2.4
'@lezer/lr': 1.4.10
'@lezer/[email protected]':
dependencies:
'@lezer/common': 1.5.2
'@lezer/highlight': 1.2.5
'@lezer/highlight': 1.2.4
'@lezer/lr': 1.4.10
'@lezer/[email protected]':
dependencies:
'@lezer/common': 1.5.2
'@lezer/highlight': 1.2.5
'@lezer/highlight': 1.2.4
'@lezer/lr': 1.4.10
'@marijn/[email protected]': {}
'@oxc-project/[email protected]52.0': {}
'@oxc-project/[email protected]47.0': {}
'@rolldown/[email protected].12':
'@rolldown/[email protected].6':
optional: true
'@rolldown/[email protected].12':
'@rolldown/[email protected].6':
optional: true
'@rolldown/[email protected].12':
'@rolldown/[email protected].6':
optional: true
'@rolldown/[email protected].12':
'@rolldown/[email protected].6':
optional: true
'@rolldown/[email protected].12':
'@rolldown/[email protected].6':
optional: true
'@rolldown/[email protected].12':
'@rolldown/[email protected].6':
optional: true
'@rolldown/[email protected].12':
'@rolldown/[email protected].6':
optional: true
'@rolldown/[email protected].12':
'@rolldown/[email protected].6':
optional: true
'@rolldown/[email protected].12':
'@rolldown/[email protected].6':
optional: true
'@rolldown/[email protected].12':
'@rolldown/[email protected].6':
optional: true
'@rolldown/[email protected].12':
'@rolldown/[email protected].6':
optional: true
'@rolldown/[email protected].12':
'@rolldown/[email protected].6':
optional: true
'@rolldown/[email protected].12':
'@rolldown/[email protected].6':
optional: true
'@rolldown/[email protected].12':
'@rolldown/[email protected].6':
optional: true
'@rolldown/[email protected].12':
'@rolldown/[email protected].6':
optional: true
'@rolldown/[email protected]': {}
@@ -2188,21 +2194,21 @@ snapshots:
'@ungap/[email protected]': {}
'@vitejs/[email protected]([email protected].2([email protected]))':
'@vitejs/[email protected]([email protected].0([email protected]))':
dependencies:
'@rolldown/pluginutils': 1.0.1
vite: 8.3.2([email protected])
vite: 8.3.0([email protected])
'@vitest/[email protected].3([email protected].2([email protected]))':
'@vitest/[email protected].1([email protected].0([email protected]))':
dependencies:
'@jridgewell/trace-mapping': 0.3.31
'@vitest/spy': 5.0.3
'@vitest/spy': 5.0.1
estree-walker: 3.0.3
magic-string: 1.4.2
optionalDependencies:
vite: 8.3.2([email protected])
vite: 8.3.0([email protected])
'@vitest/[email protected].3': {}
'@vitest/[email protected].1': {}
[email protected]: {}
@@ -2513,7 +2519,7 @@ snapshots:
dependencies:
commander: 8.3.0
[email protected].10:
[email protected].9:
dependencies:
commander: 15.0.0
@@ -3111,26 +3117,26 @@ snapshots:
[email protected]: {}
[email protected].12:
[email protected].6:
dependencies:
'@oxc-project/types': 0.152.0
'@oxc-project/types': 0.147.0
'@rolldown/pluginutils': 1.0.1
optionalDependencies:
'@rolldown/binding-android-arm-eabi': 1.2.12
'@rolldown/binding-android-arm64': 1.2.12
'@rolldown/binding-darwin-arm64': 1.2.12
'@rolldown/binding-darwin-x64': 1.2.12
'@rolldown/binding-freebsd-x64': 1.2.12
'@rolldown/binding-linux-arm-gnueabihf': 1.2.12
'@rolldown/binding-linux-arm64-gnu': 1.2.12
'@rolldown/binding-linux-arm64-musl': 1.2.12
'@rolldown/binding-linux-ppc64-gnu': 1.2.12
'@rolldown/binding-linux-s390x-gnu': 1.2.12
'@rolldown/binding-linux-x64-gnu': 1.2.12
'@rolldown/binding-linux-x64-musl': 1.2.12
'@rolldown/binding-openharmony-arm64': 1.2.12
'@rolldown/binding-win32-arm64-msvc': 1.2.12
'@rolldown/binding-win32-x64-msvc': 1.2.12
'@rolldown/binding-android-arm-eabi': 1.2.6
'@rolldown/binding-android-arm64': 1.2.6
'@rolldown/binding-darwin-arm64': 1.2.6
'@rolldown/binding-darwin-x64': 1.2.6
'@rolldown/binding-freebsd-x64': 1.2.6
'@rolldown/binding-linux-arm-gnueabihf': 1.2.6
'@rolldown/binding-linux-arm64-gnu': 1.2.6
'@rolldown/binding-linux-arm64-musl': 1.2.6
'@rolldown/binding-linux-ppc64-gnu': 1.2.6
'@rolldown/binding-linux-s390x-gnu': 1.2.6
'@rolldown/binding-linux-x64-gnu': 1.2.6
'@rolldown/binding-linux-x64-musl': 1.2.6
'@rolldown/binding-openharmony-arm64': 1.2.6
'@rolldown/binding-win32-arm64-msvc': 1.2.6
'@rolldown/binding-win32-x64-msvc': 1.2.6
[email protected]:
dependencies:
@@ -3138,10 +3144,14 @@ snapshots:
[email protected]: {}
[email protected]: {}
[email protected]: {}
[email protected]: {}
[email protected]: {}
[email protected]: {}
[email protected]:
@@ -3269,21 +3279,21 @@ snapshots:
'@types/unist': 3.0.3
vfile-message: 4.0.3
[email protected].2([email protected]):
[email protected].0([email protected]):
dependencies:
lightningcss: 1.33.0
picomatch: 4.0.7
postcss: 8.5.28
rolldown: 1.2.12
rolldown: 1.2.6
tinyglobby: 0.2.17
optionalDependencies:
esbuild: 0.28.2
fsevents: 2.3.3
[email protected].3([email protected])([email protected].2([email protected])):
[email protected].1([email protected])([email protected].0([email protected])):
dependencies:
'@types/chai': 5.2.3
'@vitest/mocker': 5.0.3([email protected].2([email protected]))
'@vitest/mocker': 5.0.1([email protected].0([email protected]))
chai: 6.2.2
es-module-lexer: 2.3.2
expect-type: 1.4.0
@@ -3294,8 +3304,8 @@ snapshots:
tinybench: 6.1.4
tinyexec: 1.3.0
tinyglobby: 0.2.17
vite: 8.3.2([email protected])
why-is-node-running: 3.2.1
vite: 8.3.0([email protected])
why-is-node-running: 2.3.0
optionalDependencies:
jsdom: 30.1.1
transitivePeerDependencies:
@@ -3329,7 +3339,10 @@ snapshots:
transitivePeerDependencies:
- '@noble/hashes'
why-is-node-running@3.2.1: {}
why-is-node-running@2.3.0:
dependencies:
siginfo: 2.0.0
stackback: 0.0.2
[email protected]: {}
-72
View File
@@ -52,8 +52,6 @@ export const ACTIONS: UIAction[] = [
// Completion verification is adaptive now, rather than a user-facing preset.
// The legacy endpoint remains in the port for older clients and sessions,
// but it is intentionally not advertised as an interface action.
{ id: "chart.scroll", kind: "view", target: "none", proof: "interaction" },
{ id: "chart.data", kind: "view", target: "none", proof: "interaction" },
{ id: "chrome.policy", kind: "view", target: "none", proof: "interaction" },
{ id: "chrome.rail", kind: "view", target: "none", proof: "browser" },
{ id: "appearance.theme", kind: "view", target: "none", proof: "interaction" },
@@ -83,7 +81,6 @@ export const ACTIONS: UIAction[] = [
// refused while a turn runs, so it never stops work.
{ id: "session.close", kind: "kernel-mutation", target: "entity", proof: "interaction" },
{ id: "session.archive", kind: "kernel-mutation", target: "entity", proof: "authority-effect" },
{ id: "session.copy-info", kind: "shell-native", target: "entity", proof: "interaction" },
{ id: "session.export", kind: "shell-native", target: "entity", proof: "browser" },
{ id: "session.delete", kind: "destructive", target: "entity", proof: "authority-effect" },
@@ -98,10 +95,6 @@ export const ACTIONS: UIAction[] = [
{ id: "tool.image-open", kind: "view", target: "none", proof: "interaction" },
{ id: "tool.image-close", kind: "view", target: "none", proof: "interaction" },
{ id: "tool.show-all", kind: "view", target: "none", proof: "interaction" },
{ id: "agent.open", kind: "view", target: "entity", proof: "interaction" },
{ id: "agent.focus-trap", kind: "navigation", target: "none", proof: "interaction" },
{ id: "agent.close", kind: "view", target: "none", proof: "interaction" },
// ── The turn ─────────────────────────────────────────────────────────────
// Send and stop remain two actions even when they sit together: during a
@@ -116,15 +109,6 @@ export const ACTIONS: UIAction[] = [
{ id: "prompt.adopt", kind: "view", target: "none", proof: "interaction" },
{ id: "prompt.discard", kind: "view", target: "none", proof: "interaction" },
// ── Committing what is staged ────────────────────────────────────────────
// Drafting reads the index and asks the model; only confirm records a commit,
// and only locally.
{ id: "commit.draft", kind: "repeatable", target: "none", proof: "interaction" },
{ id: "commit.edit", kind: "view", target: "none", proof: "interaction" },
{ id: "commit.acknowledge", kind: "view", target: "none", proof: "interaction" },
{ id: "commit.confirm", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
{ id: "commit.close", kind: "view", target: "none", proof: "interaction" },
// ── What is waiting to be sent ───────────────────────────────────────────
{ id: "queue.edit", kind: "kernel-mutation", target: "entity", proof: "interaction" },
// Giving a session a name of your own. The sidebar's tree and the pane tabs
@@ -167,37 +151,6 @@ export const ACTIONS: UIAction[] = [
{ id: "account.reload", kind: "repeatable", target: "none", proof: "interaction" },
{ id: "account.sign-in", kind: "kernel-mutation", target: "none", proof: "interaction" },
{ id: "account.sign-out", kind: "kernel-mutation", target: "none", proof: "interaction" },
{ id: "community.join", kind: "shell-native", target: "none", proof: "interaction" },
{ id: "community.discord", kind: "shell-native", target: "none", proof: "interaction" },
{ id: "community.issues", kind: "shell-native", target: "none", proof: "interaction" },
{ id: "community.contributors", kind: "shell-native", target: "none", proof: "interaction" },
{ id: "community.profile", kind: "shell-native", target: "entity", proof: "interaction" },
{ id: "community.douyin-qr", kind: "view", target: "none", proof: "interaction" },
{ id: "community.author", kind: "shell-native", target: "none", proof: "interaction" },
{ id: "community.more", kind: "view", target: "none", proof: "interaction" },
{ id: "feedback.open", kind: "view", target: "none", proof: "interaction" },
{ id: "feedback.tab", kind: "view", target: "none", proof: "interaction" },
{ id: "feedback.category", kind: "view", target: "none", proof: "interaction" },
{ id: "feedback.body", kind: "view", target: "none", proof: "interaction" },
{ id: "feedback.name", kind: "view", target: "none", proof: "interaction" },
{ id: "feedback.contact", kind: "view", target: "none", proof: "interaction" },
{ id: "feedback.shot.pick", kind: "view", target: "none", proof: "interaction" },
{ id: "feedback.shot.add", kind: "view", target: "none", proof: "interaction" },
{ id: "feedback.shot.remove", kind: "view", target: "entity", proof: "interaction" },
{ id: "feedback.send", kind: "kernel-mutation", target: "none", proof: "interaction" },
{ id: "feedback.another", kind: "view", target: "none", proof: "interaction" },
{ id: "feedback.thread.more", kind: "view", target: "none", proof: "interaction" },
{ id: "feedback.reply.open", kind: "view", target: "none", proof: "interaction" },
{ id: "feedback.reply.body", kind: "view", target: "none", proof: "interaction" },
{ id: "feedback.reply.send", kind: "kernel-mutation", target: "none", proof: "interaction" },
{ id: "feedback.reply.cancel", kind: "view", target: "none", proof: "interaction" },
{ id: "feedback.mine", kind: "view", target: "none", proof: "interaction" },
{ id: "feedback.refresh", kind: "repeatable", target: "none", proof: "interaction" },
{ id: "feedback.retry-env", kind: "repeatable", target: "none", proof: "interaction" },
{ id: "feedback.retry-mine", kind: "repeatable", target: "none", proof: "interaction" },
{ id: "feedback.link", kind: "shell-native", target: "none", proof: "interaction" },
{ id: "feedback.focus", kind: "view", target: "none", proof: "interaction" },
{ id: "feedback.close", kind: "view", target: "none", proof: "interaction" },
{ id: "backup.category", kind: "view", target: "entity", proof: "interaction" },
{ id: "backup.label", kind: "view", target: "none", proof: "interaction" },
{ id: "backup.passphrase", kind: "view", target: "optional", proof: "interaction" },
@@ -224,7 +177,6 @@ export const ACTIONS: UIAction[] = [
{ id: "memory.forget", kind: "destructive", target: "entity", proof: "interaction" },
{ id: "config.repair", kind: "kernel-mutation", target: "none", proof: "interaction" },
{ id: "extensions.reload", kind: "kernel-mutation", target: "none", proof: "interaction" },
{ id: "extensions.refresh", kind: "repeatable", target: "none", proof: "interaction" },
{ id: "extensions.inspect", kind: "kernel-mutation", target: "none", proof: "interaction" },
{ id: "extensions.install", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
{ id: "extensions.update", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
@@ -244,8 +196,6 @@ export const ACTIONS: UIAction[] = [
{ id: "market.sort", kind: "view", target: "none", proof: "interaction" },
{ id: "market.pinned", kind: "view", target: "none", proof: "interaction" },
{ id: "market.show-all", kind: "view", target: "none", proof: "interaction" },
{ id: "market.retry", kind: "view", target: "none", proof: "interaction" },
{ id: "market.detail-retry", kind: "view", target: "none", proof: "interaction" },
{ id: "market.more", kind: "view", target: "none", proof: "interaction" },
{ id: "market.open", kind: "navigation", target: "none", proof: "interaction" },
{ id: "market.back", kind: "navigation", target: "none", proof: "interaction" },
@@ -261,16 +211,12 @@ export const ACTIONS: UIAction[] = [
{ id: "market.signin", kind: "navigation", target: "none", proof: "interaction" },
{ id: "market.draft", kind: "view", target: "none", proof: "interaction" },
{ id: "market.publish", kind: "kernel-mutation", target: "none", proof: "interaction" },
{ id: "market.prepare-version", kind: "view", target: "none", proof: "interaction" },
{ id: "market.publish-again", kind: "view", target: "none", proof: "interaction" },
// The account's own packages: a preview, an install pinned to that preview's
// digest, and sending a private one to review.
{ id: "market.own-inspect", kind: "kernel-mutation", target: "none", proof: "interaction" },
{ id: "market.own-retry", kind: "kernel-mutation", target: "none", proof: "interaction" },
{ id: "market.mine-retry", kind: "view", target: "none", proof: "interaction" },
{ id: "market.submit", kind: "kernel-mutation", target: "none", proof: "interaction" },
{ id: "market.vote", kind: "kernel-mutation", target: "none", proof: "interaction" },
{ id: "market.vote-retry", kind: "view", target: "none", proof: "interaction" },
// Taking back the innermost open thing: a popover, an inline form. One
// intent, reached by pressing away and by Escape.
{ id: "layer.dismiss", kind: "navigation", target: "none", proof: "interaction" },
@@ -308,7 +254,6 @@ export const ACTIONS: UIAction[] = [
// state — which is why it is one id and why it is not a mutation.
{ id: "external.open", kind: "shell-native", target: "none", proof: "interaction" },
{ id: "remote-host.remove", kind: "destructive", target: "none", proof: "authority-effect" },
{ id: "workspace.reveal", kind: "shell-native", target: "entity", proof: "interaction" },
{ id: "workspace.remove", kind: "destructive", target: "none", proof: "authority-effect" },
{ id: "extensions.invoke", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
@@ -321,12 +266,8 @@ export const ACTIONS: UIAction[] = [
// Asking a machine whether it answers. Nothing on either side changes, and
// asking twice is the point when the first answer was "not yet".
{ id: "remote.probe", kind: "repeatable", target: "entity", proof: "authority-effect" },
{ id: "remote.hide", kind: "view", target: "entity", proof: "interaction" },
{ id: "remote.enable", kind: "view", target: "entity", proof: "interaction" },
{ id: "remote.read", kind: "repeatable", target: "entity", proof: "authority-effect" },
{ id: "workspace.add", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
{ id: "workspace.add-cancel", kind: "view", target: "none", proof: "interaction" },
{ id: "workspace.move", kind: "kernel-mutation", target: "entity", proof: "authority-effect" },
// ── Taking a change back ─────────────────────────────────────────────────
// Planning a revert and performing one are two contracts: the first asks the
@@ -341,9 +282,6 @@ export const ACTIONS: UIAction[] = [
{ id: "extensions.submit", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
{ id: "provider.probe", kind: "repeatable", target: "none", proof: "interaction" },
{ id: "provider.draft", kind: "interaction", target: "none", proof: "interaction" },
{ id: "provider.discard-edits", kind: "destructive", target: "none", proof: "interaction" },
{ id: "provider.revert", kind: "interaction", target: "none", proof: "interaction" },
{ id: "provider.model-check-all", kind: "repeatable", target: "none", proof: "interaction" },
{ id: "provider.model-check", kind: "repeatable", target: "entity", proof: "interaction" },
{ id: "provider.add", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
{ id: "provider.add-start", kind: "view", target: "none", proof: "interaction" },
@@ -365,7 +303,6 @@ export const ACTIONS: UIAction[] = [
{ id: "context.window-tokens", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
{ id: "mcp.add", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
{ id: "mcp.inspect", kind: "repeatable", target: "none", proof: "interaction" },
{ id: "mcp.scope", kind: "interaction", target: "none", proof: "interaction" },
{ id: "remotes.add", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
{ id: "remotes.save", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
{ id: "remotes.remove", kind: "destructive", target: "entity", proof: "authority-effect" },
@@ -377,7 +314,6 @@ export const ACTIONS: UIAction[] = [
{ id: "share.cloud-offer", kind: "repeatable", target: "none", proof: "interaction" },
{ id: "share.toggle", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
{ id: "share.address", kind: "view", target: "none", proof: "interaction" },
{ id: "share.port", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
{ id: "share.offer", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
{ id: "share.ask-revoke", kind: "view", target: "entity", proof: "interaction" },
{ id: "share.keep", kind: "view", target: "entity", proof: "interaction" },
@@ -406,11 +342,6 @@ export const ACTIONS: UIAction[] = [
{ id: "theme.import", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
{ id: "theme.folder", kind: "repeatable", target: "none", proof: "interaction" },
// Usage changes what report is read, not canonical session state.
{ id: "usage.range.start", kind: "view", target: "none", proof: "interaction" },
{ id: "usage.range.end", kind: "view", target: "none", proof: "interaction" },
{ id: "usage.range.month", kind: "view", target: "none", proof: "interaction" },
// ── How the window looks ─────────────────────────────────────────────────
// Named for what a person is doing, not for the control they reached for.
// The interface scale has a row of presets and a slider beside it, and both
@@ -489,9 +420,6 @@ export const ACTIONS: UIAction[] = [
{ id: "browser-tools.enabled", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
// Read by the running turn at its next round, so no rebuild stands between the
// click and the canonical change.
// The kernel announces the change on the stream, so no rebuild stands between
// the click and the canonical change.
{ id: "currency.mode", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
{ id: "progress-watch.pause", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
{ id: "progress-watch.rounds", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
{ id: "progress-watch.token-multiple", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
Binary file not shown.

Before

Width:  |  Height:  |  Size: 137 KiB

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="296" height="296" class="segno"><g transform="scale(8)"><path fill="#fff" d="M0 0h37v37h-37z"/><path class="qrline" stroke="#000" d="M4 4.5h7m2 0h1m1 0h1m2 0h1m2 0h1m1 0h2m1 0h7m-29 1h1m5 0h1m5 0h1m3 0h2m2 0h1m1 0h1m5 0h1m-29 1h1m1 0h3m1 0h1m2 0h4m1 0h2m1 0h1m2 0h1m1 0h1m1 0h3m1 0h1m-29 1h1m1 0h3m1 0h1m6 0h1m2 0h1m1 0h3m1 0h1m1 0h3m1 0h1m-29 1h1m1 0h3m1 0h1m11 0h1m1 0h1m1 0h1m1 0h3m1 0h1m-29 1h1m5 0h1m1 0h5m2 0h3m1 0h1m2 0h1m5 0h1m-29 1h7m1 0h1m1 0h1m1 0h1m1 0h1m1 0h1m1 0h1m1 0h1m1 0h7m-20 1h1m1 0h3m1 0h3m-18 1h1m2 0h1m1 0h2m1 0h1m1 0h2m2 0h1m1 0h1m1 0h2m1 0h1m1 0h1m-21 1h1m1 0h1m1 0h2m1 0h1m2 0h1m1 0h4m1 0h1m1 0h1m2 0h1m2 0h1m-29 1h4m1 0h4m1 0h2m1 0h3m2 0h2m3 0h5m-28 1h4m4 0h1m3 0h2m2 0h1m9 0h2m-28 1h2m2 0h1m1 0h1m1 0h5m1 0h2m1 0h1m1 0h2m1 0h2m1 0h1m1 0h2m-28 1h1m1 0h2m3 0h1m2 0h1m1 0h4m1 0h2m1 0h1m-22 1h2m1 0h2m1 0h1m1 0h1m1 0h1m1 0h3m3 0h1m1 0h2m1 0h1m1 0h4m-28 1h2m1 0h2m2 0h1m1 0h1m4 0h1m1 0h1m1 0h1m1 0h2m1 0h2m1 0h1m-28 1h4m2 0h2m1 0h3m1 0h1m3 0h3m1 0h1m5 0h1m-27 1h2m2 0h1m1 0h1m2 0h1m1 0h5m1 0h1m2 0h2m2 0h1m2 0h1m-29 1h1m1 0h2m1 0h6m3 0h5m3 0h3m2 0h2m-27 1h2m4 0h1m1 0h2m2 0h1m2 0h1m1 0h2m3 0h1m2 0h2m-29 1h1m2 0h1m2 0h1m2 0h1m5 0h3m1 0h6m1 0h1m-19 1h2m1 0h2m1 0h2m1 0h4m3 0h1m1 0h3m-29 1h7m2 0h1m2 0h1m4 0h2m1 0h1m1 0h1m1 0h1m2 0h1m-28 1h1m5 0h1m1 0h2m1 0h1m2 0h2m3 0h2m3 0h5m-29 1h1m1 0h3m1 0h1m2 0h2m1 0h1m3 0h3m1 0h5m2 0h1m-28 1h1m1 0h3m1 0h1m1 0h2m2 0h2m1 0h2m1 0h2m2 0h5m1 0h1m-29 1h1m1 0h3m1 0h1m2 0h2m1 0h1m1 0h1m2 0h4m3 0h3m1 0h1m-29 1h1m5 0h1m2 0h1m2 0h1m1 0h1m2 0h1m1 0h2m1 0h1m4 0h1m-28 1h7m1 0h1m3 0h2m2 0h2m2 0h3m1 0h1m2 0h1"/></g></svg>

Before

Width:  |  Height:  |  Size: 1.6 KiB

@@ -1,97 +0,0 @@
// @vitest-environment jsdom
import { describe, expect, it, vi } from "vitest";
import HTML from "../index.html?raw";
const script = new DOMParser().parseFromString(HTML, "text/html").querySelector("script")!.textContent!;
function boot(fragment: string, path = "/", search = "") {
let resolve!: (response: { ok: boolean }) => void;
let reject!: (error: Error) => void;
const response = new Promise<{ ok: boolean }>((yes, no) => { resolve = yes; reject = no; });
const fetch = vi.fn((_url: string, _options?: unknown) => response);
const reload = vi.fn();
const replaceState = vi.fn();
const context = {
window: null as unknown,
__rxAuth: undefined as Promise<void> | undefined,
fetch,
location: { hash: fragment, pathname: path, search, reload },
history: { replaceState },
localStorage: { getItem: () => null },
document: { documentElement: { dataset: {}, lang: "" } },
navigator: { language: "en" },
performance: { mark: vi.fn() },
URLSearchParams,
};
context.window = context;
new Function(...Object.keys(context), script)(...Object.values(context));
return { context, fetch, reload, replaceState, resolve, reject };
}
describe("credential bootstrap before native asset loading", () => {
it.each([
["token", "#token=launch-secret", "/auth/token", { token: "launch-secret" }],
["pair", "#pair=one-time-code", "/pair", { code: "one-time-code" }],
])("reloads after the %s cookie is established", async (_kind, fragment, route, body) => {
const page = boot(fragment);
expect(page.fetch).toHaveBeenCalledWith(route, {
method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body),
});
expect(page.reload).not.toHaveBeenCalled();
page.resolve({ ok: true });
await page.context.__rxAuth;
expect(page.reload).toHaveBeenCalledOnce();
});
it("preserves the session path, query, and unrelated fragment before reloading", async () => {
const page = boot("#tab=extensions&token=secret", "/sessions/session-123", "?layout=wide");
expect(page.replaceState).toHaveBeenCalledWith(null, "", "/sessions/session-123?layout=wide#tab=extensions");
expect(page.replaceState.mock.calls[0][2]).not.toContain("secret");
page.resolve({ ok: true });
await page.context.__rxAuth;
expect(page.reload).toHaveBeenCalledOnce();
});
it("consumes pairing once when both credentials are present", async () => {
const page = boot("#token=secret&pair=once");
expect(page.fetch).toHaveBeenCalledWith("/pair", expect.objectContaining({ body: JSON.stringify({ code: "once" }) }));
expect(page.replaceState).toHaveBeenCalledWith(null, "", "/");
page.resolve({ ok: true });
await page.context.__rxAuth;
expect(page.fetch).toHaveBeenCalledOnce();
expect(page.reload).toHaveBeenCalledOnce();
});
it("keeps application fetches behind the existing authentication promise", async () => {
const page = boot("#token=secret");
const application = page.context.fetch("/runtimes");
expect(page.fetch).toHaveBeenCalledOnce();
page.resolve({ ok: true });
await application;
expect(page.fetch).toHaveBeenLastCalledWith("/runtimes");
expect(page.reload).toHaveBeenCalledOnce();
});
it.each(["", "#tab=extensions"])("does not reload a page without a credential (%s)", async (fragment) => {
const page = boot(fragment);
await page.context.__rxAuth;
expect(page.fetch).not.toHaveBeenCalled();
expect(page.replaceState).not.toHaveBeenCalled();
expect(page.reload).not.toHaveBeenCalled();
});
it.each(["#token=wrong", "#pair=expired"])("does not reload a refused exchange (%s)", async (fragment) => {
const page = boot(fragment);
page.resolve({ ok: false });
await page.context.__rxAuth;
expect(page.reload).not.toHaveBeenCalled();
});
it("does not reload when the exchange cannot reach the server", async () => {
const page = boot("#token=secret");
const failure = new Error("network unavailable");
page.reject(failure);
await expect(page.context.__rxAuth).rejects.toBe(failure);
expect(page.reload).not.toHaveBeenCalled();
});
});
Loaded 100 of 1756 files, more files were not shown because too many files have changed in this diff. Show more