Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5207d6f89b |
No files matched your search
@@ -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,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.
|
||||
@@ -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)
|
||||
|
Before Width: | Height: | Size: 138 KiB |
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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[@]}"
|
||||
@@ -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
|
||||
@@ -15,7 +15,6 @@
|
||||
/runtime-resume
|
||||
/extension-protocol-gen
|
||||
/reasonix-plugin-example
|
||||
/mcp-line-counter-kit
|
||||
/reasonix-studio-host
|
||||
/signpath-contract
|
||||
/studio-delta
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -18,8 +18,6 @@
|
||||
<a href="https://esengine.github.io/DeepSeek-Reasonix/">Website</a>
|
||||
·
|
||||
<strong><a href="https://discord.gg/XF78rEME2D">Discord</a></strong>
|
||||
·
|
||||
<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>
|
||||
@@ -18,8 +18,6 @@
|
||||
<a href="https://esengine.github.io/DeepSeek-Reasonix/">官方网站</a>
|
||||
·
|
||||
<strong><a href="https://discord.gg/XF78rEME2D">Discord</a></strong>
|
||||
·
|
||||
<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>
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 36 KiB After Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 1.8 KiB |
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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)]);
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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.
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 };
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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/`(它要读固件源码来决定
|
||||
|
||||
@@ -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);
|
||||
@@ -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[]> {
|
||||
|
||||
@@ -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);
|
||||
@@ -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");
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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]: {}
|
||||
|
||||
|
||||
@@ -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" },
|
||||
|
||||
|
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();
|
||||
});
|
||||
});
|
||||