From 6607c54984378a8b32662ec1bb08a13f8ba297e9 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Wed, 14 Oct 2026 16:14:27 -0700 Subject: [PATCH] perf(ci): cut redundant tests and reuse expensive setup (#28669) * perf(ci): reduce test overhead and reuse expensive build work * test(ci): keep worker startup outside failure-case deadlines * test: isolate push fixture advisory locks * test(ci): preserve fixed drain retry allowance * Align CI contracts with current triggers and guide content * Stabilize consolidated reveal scenarios and retain behavioral CI contracts * Fix upstream lint blockers while retaining behavior and assertions * Classify global Jira preferences in the WSL migration inventory * Align Jira test fixtures with startup and build safety checks * Include package-test routing dependency in CI checkouts and fixtures * Keep native stream classes intact in transcript dependency tests --- .../prepare-orcad-prebuilds/action.yml | 4 +- .../prepare-wayland-clipboard/action.yml | 53 +++ .../ci-cache-warmup-platform-jobs.yml | 23 ++ .../workflows/ci-cache-warmup-platforms.yml | 6 + .github/workflows/ci-cache-warmup.yml | 8 + .github/workflows/mobile.yml | 1 + .github/workflows/node-server-tests.yml | 12 +- .github/workflows/pr.yml | 55 +-- ...urable-push-fixture-lock-isolation.test.ts | 42 ++ .../src/durable-push-store.test-fixture.ts | 35 +- ...lease-cell-row-contention-postgres.test.ts | 3 +- ...ublic-assignment-drain-return-lane.test.ts | 19 +- .../src/reserve-placement-simulation.test.ts | 7 - config/patches/i18next-cli@1.74.2.patch | 358 +++++++++++++++++- .../scripts/build-wayland-clipboard.test.mjs | 46 +++ .../scripts/check-node-runtime-pin.test.mjs | 3 +- ...ectron-builder-mac-channel-config.test.mjs | 16 - ...enerated-artifact-line-ending-pin.test.mjs | 4 + config/scripts/happy-dom-offscreen-canvas.ts | 2 +- .../scripts/locale-ko-key-overrides.test.mjs | 14 - ...calization-extraction-call-syntax.test.mjs | 43 +++ .../mobile-release-check-scope.test.mjs | 4 +- config/scripts/mobile-test-change-scope.mjs | 1 + .../scripts/mobile-test-change-scope.test.mjs | 1 + ...-web-app-browser-pane-text-inputs.test.mjs | 108 ------ .../mobile-web-app-module-closure.test.mjs | 20 - ...-web-app-session-terminal-closure.test.mjs | 18 +- ...obile-web-app-session-text-inputs.test.mjs | 76 ---- ...eb-app-terminal-live-input-render.test.mjs | 2 +- ...bile-web-app-text-input-discovery.test.mjs | 22 ++ config/scripts/node-server-change-scope.mjs | 1 + .../scripts/node-server-change-scope.test.mjs | 2 +- .../orcad-windows-prebuild-workflow.test.mjs | 5 +- ...wright-focused-workflow-selection.test.mjs | 51 +-- .../pr-auxiliary-preflight-admission.test.mjs | 2 + config/scripts/pr-code-change-scope.mjs | 85 +---- config/scripts/pr-code-change-scope.test.mjs | 8 +- config/scripts/pr-e2e-gate-contract.test.mjs | 34 +- config/scripts/pr-e2e-source-routing.mjs | 18 +- config/scripts/pr-package-test-paths.mjs | 78 ++++ .../scripts/process-host-build-dist.test.mjs | 34 +- .../shebang-script-line-ending-pin.test.mjs | 70 ---- config/scripts/vitest-dom-setup.ts | 7 + config/scripts/vitest-host-ports-setup.ts | 17 +- .../workflow-ref-mirror-case-safety.test.mjs | 33 -- .../workflow-ref-reachability.test.mjs | 2 + config/tsconfig.node.json | 3 + config/vitest.config.ts | 3 +- ...native-chat-merged-snapshot-parity.test.ts | 4 +- .../rpc-recording/golden-value-pool.ts | 5 +- pnpm-lock.yaml | 6 +- ...ser-cookie-credentials-empty-value.test.ts | 117 ------ ...e.test.ts => browser-empty-values.test.ts} | 79 ++++ src/cli/index.test.ts | 258 +------------ src/cli/specs/file.test.ts | 31 -- src/cli/specs/search.test.ts | 129 ------- src/cli/terminal-format-draft.test.ts | 21 - src/cli/terminal-format.test.ts | 20 + .../server-transport-interference.test.ts | 33 +- ...ion-search-transcript-dependencies.test.ts | 18 +- .../browser-popup-background-cdp-probe.ts | 24 +- .../daemon-pty-startup-delivery.test.ts | 31 +- .../slow-daemon-session-verification.test.ts | 30 +- .../git/repo-ref-maintenance-real-git.test.ts | 36 +- ...rktree-rebase-update-refs-real-git.test.ts | 48 +-- src/main/git/worktree-safety-real-git.test.ts | 59 +-- .../hook-termination-real-process.test.ts | 4 +- src/main/ipc/jira.ts | 8 +- .../transcript-opencode2-sqlite.test.ts | 7 +- .../profile-state-backup-worker.test.ts | 5 +- ...der-process-supervisor.integration.test.ts | 42 +- ...orca-runtime-files-artifact-facade.test.ts | 110 ++---- ...cad-state-mutation-fence-heartbeat.test.ts | 29 +- ...ChatMessageList.rail-history-jump.test.tsx | 14 +- .../onboarding/OnboardingFlow.test.tsx | 8 +- .../OnboardingInlineCommandTerminal.test.ts | 31 -- .../components/settings/GeneralPane.test.ts | 6 + .../browser-cookie-import-label.test.ts | 53 --- .../settings/osc52-clipboard-copy.test.ts | 45 --- .../settings/setting-ownership.test.ts | 39 -- .../sidebar/wsl-host-copy-catalog.test.ts | 27 -- .../tab-session-history-switch.test.ts | 5 +- ...-technical-literal-mistranslations.test.ts | 67 ---- .../ko-ui-semantic-mistranslations.test.ts | 68 ---- .../i18n/locale-english-regression.test.ts | 97 ----- .../src/i18n/native-chat-locales.test.ts | 90 ----- ...elative-duration-copy-localization.test.ts | 70 ---- ...settings-status-label-localization.test.ts | 168 -------- .../technical-literal-catalog-values.test.ts | 71 ---- ...-technical-literal-mistranslations.test.ts | 139 ------- src/shared/constants.test.ts | 148 +------- src/shared/contextual-tours.test.ts | 187 +-------- src/shared/feature-tips.test.ts | 35 -- src/shared/git-binary-compatibility.test.ts | 66 +--- src/shared/git-binary.test-fixture.ts | 114 ++++++ ...it-branch-line-total-soft-deadline.test.ts | 52 ++- src/shared/native-chat-types.test.ts | 45 --- tests/e2e/browser-tab.spec.ts | 127 ------- .../e2e-completed-onboarding-profile.ts | 3 +- tests/e2e/onboarding.spec.ts | 34 +- tests/e2e/tabs.spec.ts | 165 +------- tests/e2e/terminal-tui-wheel-drain.spec.ts | 69 ++-- .../e2e/worktree-reveal-host-sections.spec.ts | 293 +++++++------- 103 files changed, 1673 insertions(+), 3276 deletions(-) create mode 100644 .github/actions/prepare-wayland-clipboard/action.yml create mode 100644 cloud/apps/push/src/durable-push-fixture-lock-isolation.test.ts delete mode 100644 config/scripts/locale-ko-key-overrides.test.mjs delete mode 100644 config/scripts/mobile-web-app-browser-pane-text-inputs.test.mjs delete mode 100644 config/scripts/mobile-web-app-module-closure.test.mjs delete mode 100644 config/scripts/mobile-web-app-session-text-inputs.test.mjs create mode 100644 config/scripts/mobile-web-app-text-input-discovery.test.mjs create mode 100644 config/scripts/pr-package-test-paths.mjs delete mode 100644 config/scripts/shebang-script-line-ending-pin.test.mjs create mode 100644 config/scripts/vitest-dom-setup.ts delete mode 100644 src/cli/browser-cookie-credentials-empty-value.test.ts rename src/cli/{browser-storage-empty-value.test.ts => browser-empty-values.test.ts} (61%) delete mode 100644 src/cli/specs/file.test.ts delete mode 100644 src/cli/specs/search.test.ts delete mode 100644 src/cli/terminal-format-draft.test.ts delete mode 100644 src/renderer/src/components/onboarding/OnboardingInlineCommandTerminal.test.ts delete mode 100644 src/renderer/src/components/settings/browser-cookie-import-label.test.ts delete mode 100644 src/renderer/src/components/settings/osc52-clipboard-copy.test.ts delete mode 100644 src/renderer/src/components/settings/setting-ownership.test.ts delete mode 100644 src/renderer/src/components/sidebar/wsl-host-copy-catalog.test.ts delete mode 100644 src/renderer/src/i18n/ja-technical-literal-mistranslations.test.ts delete mode 100644 src/renderer/src/i18n/ko-ui-semantic-mistranslations.test.ts delete mode 100644 src/renderer/src/i18n/locale-english-regression.test.ts delete mode 100644 src/renderer/src/i18n/native-chat-locales.test.ts delete mode 100644 src/renderer/src/i18n/relative-duration-copy-localization.test.ts delete mode 100644 src/renderer/src/i18n/settings-status-label-localization.test.ts delete mode 100644 src/renderer/src/i18n/technical-literal-catalog-values.test.ts delete mode 100644 src/renderer/src/i18n/zh-technical-literal-mistranslations.test.ts create mode 100644 src/shared/git-binary.test-fixture.ts delete mode 100644 src/shared/native-chat-types.test.ts diff --git a/.github/actions/prepare-orcad-prebuilds/action.yml b/.github/actions/prepare-orcad-prebuilds/action.yml index 635fb9d6d54..126e2b60f40 100644 --- a/.github/actions/prepare-orcad-prebuilds/action.yml +++ b/.github/actions/prepare-orcad-prebuilds/action.yml @@ -63,5 +63,5 @@ runs: fi pnpm build:orcad-prebuilds fi - pnpm build:orcad-prebuilds --require-slots "$(node config/scripts/build-orcad-prebuilds.mjs --print-slot)" - pnpm build:orcad-prebuilds --smoke + node config/scripts/build-orcad-prebuilds.mjs --require-slots "$(node config/scripts/build-orcad-prebuilds.mjs --print-slot)" + node config/scripts/build-orcad-prebuilds.mjs --smoke diff --git a/.github/actions/prepare-wayland-clipboard/action.yml b/.github/actions/prepare-wayland-clipboard/action.yml new file mode 100644 index 00000000000..78c622855bd --- /dev/null +++ b/.github/actions/prepare-wayland-clipboard/action.yml @@ -0,0 +1,53 @@ +name: Prepare Wayland clipboard helper +description: Reuse the source-matched Ubuntu 20.04 helper, validating its architecture and glibc requirements every time. + +inputs: + save-cache: + description: Publish a validated helper from the default branch. + default: 'false' + +runs: + using: composite + steps: + - name: Resolve Wayland clipboard build identity + id: identity + shell: bash + run: | + node --input-type=module <<'NODE' + import { appendFileSync } from 'node:fs' + import { waylandClipboardFingerprint } from './config/scripts/build-wayland-clipboard.mjs' + const fingerprint = waylandClipboardFingerprint(process.cwd(), process.arch) + // Immutable invalid hits need a version bump here before publishing their repair. + appendFileSync(process.env.GITHUB_OUTPUT, `key=wayland-clipboard-v1-${process.arch}-${fingerprint}\n`) + NODE + + - name: Restore Wayland clipboard helper + id: cache + uses: actions/cache/restore@v5 + continue-on-error: true + with: + path: native/wayland-clipboard/.build + key: ${{ steps.identity.outputs.key }} + + - name: Build or validate Wayland clipboard helper + shell: bash + env: + CACHE_HIT: ${{ steps.cache.outputs.cache-hit }} + run: | + if node config/scripts/build-wayland-clipboard.mjs; then + exit 0 + fi + if [ "$CACHE_HIT" != true ]; then + exit 1 + fi + echo 'Discarding an unusable Wayland clipboard cache and rebuilding from source.' + rm -rf -- native/wayland-clipboard/.build + node config/scripts/build-wayland-clipboard.mjs + + - name: Save validated Wayland clipboard helper from main + if: inputs.save-cache == 'true' && steps.cache.outputs.cache-hit != 'true' && github.ref == 'refs/heads/main' && github.event_name != 'pull_request' + uses: actions/cache/save@v5 + continue-on-error: true + with: + path: native/wayland-clipboard/.build + key: ${{ steps.identity.outputs.key }} diff --git a/.github/workflows/ci-cache-warmup-platform-jobs.yml b/.github/workflows/ci-cache-warmup-platform-jobs.yml index 47d1238909d..b4a7be987d0 100644 --- a/.github/workflows/ci-cache-warmup-platform-jobs.yml +++ b/.github/workflows/ci-cache-warmup-platform-jobs.yml @@ -53,6 +53,29 @@ jobs: - name: Verify native cache is usable run: node config/scripts/ensure-native-runtime.mjs --check-only + - name: Cache Windows CLI launcher + uses: actions/cache@v5 + with: + path: | + native/windows-cli-launcher/.build + native/windows-cli-launcher/target + native/windows-agent-hook/.build + native/windows-agent-hook/target + ~/.cargo/registry + key: windows-cli-launcher-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('native/windows-cli-launcher/**', 'native/windows-agent-hook/**', 'config/scripts/build-windows-cli-launcher.mjs', 'config/scripts/build-windows-agent-hook.mjs', 'config/scripts/windows-rust-executable-build.mjs', 'resources/build/icon.ico', 'package.json') }} + + - name: Build the Windows native executables + shell: pwsh + run: | + if (-not (Get-Command cargo -ErrorAction SilentlyContinue)) { + rustup toolchain install stable --profile minimal + rustup default stable + } + node config/scripts/build-windows-agent-hook.mjs + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + node config/scripts/build-windows-cli-launcher.mjs + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + warm-linux-package-fixtures: concurrency: group: ci-cache-warmup-linux-package-fixtures-${{ github.event.pull_request.number || github.ref }} diff --git a/.github/workflows/ci-cache-warmup-platforms.yml b/.github/workflows/ci-cache-warmup-platforms.yml index 7643747da79..8f49c2a63aa 100644 --- a/.github/workflows/ci-cache-warmup-platforms.yml +++ b/.github/workflows/ci-cache-warmup-platforms.yml @@ -34,6 +34,12 @@ on: - 'config/scripts/space-sharing-copy.mjs' - 'config/patches/**' - 'native/windows-registry/**' + - 'native/windows-cli-launcher/**' + - 'native/windows-agent-hook/**' + - 'config/scripts/build-windows-cli-launcher.mjs' + - 'config/scripts/build-windows-agent-hook.mjs' + - 'config/scripts/windows-rust-executable-build.mjs' + - 'resources/build/icon.ico' pull_request: paths: - '.github/workflows/ci-cache-warmup.yml' diff --git a/.github/workflows/ci-cache-warmup.yml b/.github/workflows/ci-cache-warmup.yml index 848cf7ef42a..b44598f7ac6 100644 --- a/.github/workflows/ci-cache-warmup.yml +++ b/.github/workflows/ci-cache-warmup.yml @@ -18,6 +18,9 @@ on: - '.github/actions/prepare-headless-compiler/**' - 'config/scripts/headless-detector-compiler-cache.mjs' - '.github/actions/prepare-linux-package-fixture/**' + - '.github/actions/prepare-wayland-clipboard/**' + - 'native/wayland-clipboard/**' + - 'config/scripts/build-wayland-clipboard.mjs' - 'config/docker/headless-serve-shutdown/**' - 'config/docker/cli-launch-contract/**' - 'config/docker/daemon-shutdown-descendants/**' @@ -48,6 +51,7 @@ on: - '.github/workflows/ci-cache-warmup-platforms.yml' - '.github/actions/prepare-headless-compiler/**' - 'config/scripts/headless-detector-compiler-cache.mjs' + - '.github/actions/prepare-wayland-clipboard/**' permissions: contents: read @@ -83,6 +87,10 @@ jobs: - uses: ./.github/actions/prepare-git-compatibility + - uses: ./.github/actions/prepare-wayland-clipboard + with: + save-cache: 'true' + warm-platforms: if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' uses: ./.github/workflows/ci-cache-warmup-platform-jobs.yml diff --git a/.github/workflows/mobile.yml b/.github/workflows/mobile.yml index f0edb9c52ee..f6265737195 100644 --- a/.github/workflows/mobile.yml +++ b/.github/workflows/mobile.yml @@ -36,6 +36,7 @@ on: - 'config/scripts/mobile-release-check-scope*' - 'config/scripts/mobile-test-change-scope*' - 'config/scripts/pr-code-change-scope.mjs' + - 'config/scripts/pr-package-test-paths.mjs' - 'config/scripts/pr-layout-oracle-route.mjs' # Why main too: two pull requests can each pass against their own base and disagree once both # land, and `verify` never runs on main. The same source paths as above, less the CI inputs only diff --git a/.github/workflows/node-server-tests.yml b/.github/workflows/node-server-tests.yml index 54d8b40e16b..457382b1b91 100644 --- a/.github/workflows/node-server-tests.yml +++ b/.github/workflows/node-server-tests.yml @@ -27,7 +27,7 @@ on: description: Also merge every lane's slot into the desktop orcad template artifact type: boolean default: false - # Release packaging calls this to build the orcad template it ships (design D2). + # Release and dev builds qualify every platform for the template they ship. workflow_call: inputs: ref: @@ -38,11 +38,14 @@ on: description: Upload each lane's release slot and merge them into the orcad-template artifact type: boolean default: false + # Requalify fresh builds daily; publish missing immutable Windows cache keys. + schedule: + - cron: '30 11 * * *' permissions: contents: read -# Main pushes must finish detection before they can supersede relevant qualification. +# Keep template callers independent while replacing superseded PR qualification. concurrency: group: node-server-${{ (inputs.build_template || github.event_name == 'push') && format('run-{0}', github.run_id) || github.event.pull_request.number || github.ref }} cancel-in-progress: ${{ !inputs.build_template && github.event_name != 'push' }} @@ -117,9 +120,8 @@ jobs: concurrency: group: node-server-persistence-${{ matrix.os }}-${{ github.event_name == 'push' && !inputs.build_template && inputs.ref == '' && github.ref || github.run_id }} cancel-in-progress: ${{ github.event_name == 'push' && !inputs.build_template && inputs.ref == '' }} - # Missing/failed detection runs the full matrix; manual runs remain unconditional. - # A draft carries no platform verdict; readiness re-triggers this workflow. Spelled against - # the event name so the push and schedule paths do not rest on a null property comparison. + # Missing/failed detection runs the full matrix; scheduled and manual runs stay unconditional. + # Drafts re-trigger at readiness; inherited caller events must not depend on PR fields. if: >- ${{ !cancelled() && needs.changes.outputs.should_run != 'false' && (github.event_name != 'pull_request' || github.event.pull_request.draft != true) }} diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index ca5c9079cce..894469dadb9 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -79,6 +79,7 @@ jobs: /config/.bun-version /config/scripts/check-readme-local-links.mjs /config/scripts/pr-code-change-scope.mjs + /config/scripts/pr-package-test-paths.mjs /config/scripts/pr-layout-oracle-route.mjs /config/scripts/pr-e2e-source-routing.mjs /config/scripts/ci-e2e-job-selection.mjs @@ -219,6 +220,7 @@ jobs: with: bun-version-file: config/.bun-version + # Run gate scripts directly to avoid repeated package-manager startup. - name: Check process-host imports if: '!cancelled()' id: process-host-imports @@ -239,7 +241,7 @@ jobs: PREFLIGHT_PRIOR_SUCCESS: ${{ job.status == 'success' }} run: | if [ "$PREFLIGHT_PHASE_SELECTED" != true ] || [ "$PREFLIGHT_PRIOR_SUCCESS" != true ]; then exit 0; fi - bun --bun run check:main-sync-spawn-ratchet + bun config/scripts/check-main-sync-spawn-ratchet.mjs # Keep each check in its own log while sharing this runner. - name: Lint @@ -251,7 +253,7 @@ jobs: PREFLIGHT_PRIOR_SUCCESS: ${{ job.status == 'success' }} run: | if [ "$PREFLIGHT_PHASE_SELECTED" != true ] || [ "$PREFLIGHT_PRIOR_SUCCESS" != true ]; then exit 0; fi - pnpm exec oxlint --format github + node node_modules/oxlint/bin/oxlint --format github - name: Reject low-evidence patterns if: needs.code_paths.outputs.static_analysis == 'true' @@ -270,11 +272,11 @@ jobs: PREFLIGHT_PRIOR_SUCCESS: ${{ job.status == 'success' }} run: | if [ "$PREFLIGHT_PHASE_SELECTED" != true ] || [ "$PREFLIGHT_PRIOR_SUCCESS" != true ]; then exit 0; fi - pnpm run audit:code-quality:native + node node_modules/oxlint/bin/oxlint --config config/oxlint-code-quality-native-plugins.json src config tests mobile --deny-warnings - name: Enforce type-aware code-quality baseline if: needs.code_paths.outputs.static_analysis == 'true' - run: pnpm run audit:code-quality:type-aware + run: node node_modules/oxlint/bin/oxlint --type-aware --config config/oxlint-code-quality-type-aware.json src config tests --deny-warnings # Mobile installation changes import resolution for the native cycle check. - wait: native-code-quality @@ -294,11 +296,11 @@ jobs: PREFLIGHT_PRIOR_SUCCESS: ${{ job.status == 'success' }} run: | if [ "$PREFLIGHT_PHASE_SELECTED" != true ] || [ "$PREFLIGHT_PRIOR_SUCCESS" != true ]; then exit 0; fi - pnpm run check:code-quality:changed -- "${{ github.event.pull_request.base.sha }}" + node config/scripts/check-changed-code-quality.mjs "${{ github.event.pull_request.base.sha }}" - name: Enforce React Doctor on changed lines if: needs.code_paths.outputs.static_analysis == 'true' - run: pnpm run check:react-doctor:changed -- "${{ github.event.pull_request.base.sha }}" + run: node config/scripts/check-react-doctor-changed.mjs "${{ github.event.pull_request.base.sha }}" - wait: changed-code-quality @@ -316,15 +318,15 @@ jobs: - name: Check Zustand selector fan-out budget if: needs.code_paths.outputs.static_analysis == 'true' - run: pnpm run check:zustand-selector-fanout + run: node config/scripts/zustand-selector-fanout-benchmark.mjs --check - name: Check reliability gate manifest if: needs.code_paths.outputs.static_analysis == 'true' - run: pnpm run check:reliability-gates + run: node config/scripts/check-reliability-gates.mjs - name: Enforce dead design-system classes if: needs.code_paths.outputs.static_analysis == 'true' - run: pnpm run check:dead-classes + run: node node_modules/oxlint/bin/oxlint --config config/oxlint-dead-classes.json src/renderer - name: Check VM runtime rollback compatibility if: needs.code_paths.outputs.static_analysis == 'true' @@ -350,19 +352,19 @@ jobs: - name: Enforce max-lines ratchet if: needs.code_paths.outputs.static_analysis == 'true' - run: pnpm run check:max-lines-ratchet + run: node config/scripts/check-max-lines-ratchet.mjs - name: Enforce ts-nocheck ratchet if: needs.code_paths.outputs.static_analysis == 'true' - run: pnpm run check:ts-nocheck-ratchet + run: node config/scripts/check-ts-nocheck-ratchet.mjs - name: Enforce runtime Electron-import ratchet if: needs.code_paths.outputs.static_analysis == 'true' - run: pnpm run check:runtime-electron-ratchet + run: node config/scripts/check-runtime-electron-ratchet.mjs - name: Enforce focus-read ratchet if: needs.code_paths.outputs.static_analysis == 'true' - run: pnpm run check:focus-read-ratchet + run: node config/scripts/check-focus-read-ratchet.mjs - name: Enforce layout-writer ratchets if: needs.code_paths.outputs.static_analysis == 'true' @@ -370,7 +372,7 @@ jobs: - name: Check Node runtime pin if: needs.code_paths.outputs.static_analysis == 'true' - run: pnpm run check:node-runtime-pin + run: node config/scripts/check-node-runtime-pin.mjs # Why: extraction writes sorted evidence to an isolated temporary path, # so feature PRs need one normalized AST pass rather than a three-OS matrix. @@ -414,19 +416,19 @@ jobs: - name: Verify the generated RPC params catalog if: needs.code_paths.outputs.static_analysis == 'true' - run: pnpm run verify:rpc-params-catalog + run: node config/scripts/generate-rpc-params-catalog.mjs --check - name: Verify the generated ACP protocol schema if: needs.code_paths.outputs.static_analysis == 'true' - run: pnpm run verify:acp-protocol + run: node config/scripts/acp/generate-protocol.mjs --check - name: Verify bundled skill guides if: needs.code_paths.outputs.static_analysis == 'true' - run: pnpm run verify:bundled-skill-guides + run: node config/scripts/generate-bundled-skill-guides.mjs --check - name: Verify skill freshness manifest if: needs.code_paths.outputs.static_analysis == 'true' - run: pnpm run verify:skill-bundle-manifest + run: node config/scripts/generate-skill-bundle-manifest.mjs - name: Verify localization catalogs if: '!cancelled()' @@ -437,7 +439,7 @@ jobs: PREFLIGHT_PRIOR_SUCCESS: ${{ job.status == 'success' }} run: | if [ "$PREFLIGHT_PHASE_SELECTED" != true ] || [ "$PREFLIGHT_PRIOR_SUCCESS" != true ]; then exit 0; fi - pnpm run verify:localization-catalogs + bun config/scripts/verify-localization-catalogs.mjs - name: Verify localization coverage if: needs.code_paths.outputs.static_analysis == 'true' @@ -465,11 +467,11 @@ jobs: - name: Check feature wall asset budget if: needs.code_paths.outputs.static_analysis == 'true' - run: pnpm check:feature-wall-assets + run: node config/scripts/check-feature-wall-assets.mjs - name: Verify macOS entitlements if: needs.code_paths.outputs.static_analysis == 'true' - run: pnpm verify:macos-entitlements + run: node config/scripts/verify-macos-entitlements.mjs # Planning shares setup and stays off the compiler's critical path. - name: Plan unit selection @@ -1103,6 +1105,12 @@ jobs: src/main/browser/browser-popup-native-compatibility.electron.test.ts src/main/browser/browser-popup-background-compatibility.electron.test.ts + # The isolated glibc-floor build writes only the Wayland helper, so JavaScript can overlap. + - name: Build native components + id: linux-native + background: true + uses: ./.github/actions/prepare-wayland-clipboard + # Restore independent fixtures during builds; keep native lifecycle probes serial. - uses: ./.github/actions/prepare-linux-package-fixture id: shutdown-fixture-cache @@ -1157,10 +1165,7 @@ jobs: - name: Build mobile web bundle run: pnpm run build:mobile-web - - name: Build native components - run: pnpm run build:native - - - wait: [linux-package-tools, web-client] + - wait: [linux-native, linux-package-tools, web-client] - name: Package unpacked app env: diff --git a/cloud/apps/push/src/durable-push-fixture-lock-isolation.test.ts b/cloud/apps/push/src/durable-push-fixture-lock-isolation.test.ts new file mode 100644 index 00000000000..be3a10802c4 --- /dev/null +++ b/cloud/apps/push/src/durable-push-fixture-lock-isolation.test.ts @@ -0,0 +1,42 @@ +import { afterEach, expect, it } from 'vitest' +import { DurablePushStore } from './durable-push-store.js' +import { + cleanupDurablePushFixtures, + DEVICE_HEAD_SQL, + durablePushTestDatabaseUrl, + fixture, + notification, + pauseAfter, + within +} from './durable-push-store.test-fixture.js' + +afterEach(cleanupDurablePushFixtures) + +it.skipIf(!durablePushTestDatabaseUrl)( + 'claims the same device in another schema while a fixture holds its device lock', + async () => { + const first = await fixture() + const second = await fixture() + const [firstScope] = await first.db.query( + 'SELECT current_database() AS database, current_schema() AS schema' + ) + const [secondScope] = await second.db.query( + 'SELECT current_database() AS database, current_schema() AS schema' + ) + expect(firstScope?.database).toBe(secondScope?.database) + expect(firstScope?.schema).not.toBe(secondScope?.schema) + await first.store.accept('host', 'phone', notification(1)) + await second.store.accept('host', 'phone', notification(2)) + const paused = pauseAfter(first.db, DEVICE_HEAD_SQL) + const held = new DurablePushStore(paused.wrapped, first.clock).claim() + await paused.atCandidate + try { + expect(await within(first.store.claim(), 2_000)).toBeNull() + expect((await within(second.store.claim(), 2_000))?.notification.notificationSeq).toBe(2) + } finally { + paused.release() + await held + } + expect((await held)?.notification.notificationSeq).toBe(1) + } +) diff --git a/cloud/apps/push/src/durable-push-store.test-fixture.ts b/cloud/apps/push/src/durable-push-store.test-fixture.ts index 417931878d1..d4fedd46074 100644 --- a/cloud/apps/push/src/durable-push-store.test-fixture.ts +++ b/cloud/apps/push/src/durable-push-store.test-fixture.ts @@ -11,7 +11,10 @@ const cleanups: (() => Promise)[] = [] export async function cleanupDurablePushFixtures(): Promise { await Promise.all(cleanups.splice(0).map((cleanup) => cleanup())) } -export const notification = (seq: number, kind: 'alert' | 'dismiss' = 'alert'): PushNotification => ({ +export const notification = ( + seq: number, + kind: 'alert' | 'dismiss' = 'alert' +): PushNotification => ({ notificationId: `notification-${seq}`, notificationEpoch: 'epoch', notificationSeq: seq, @@ -21,11 +24,26 @@ export const notification = (seq: number, kind: 'alert' | 'dismiss' = 'alert'): body: '', kind }) -// ownDatabase: advisory locks are database-wide, so a test that holds a global key must not share one. + +// Schemas isolate rows, but advisory locks need the same fixture boundary. +function scopeFixtureLocks(database: PushDatabase, scope: string): PushDatabase { + return { + dialect: database.dialect, + query: (sql, params) => database.query(sql, params), + transaction: (run) => database.transaction((tx) => run(scopeFixtureLocks(tx, scope))), + lockQuotaScope: (key) => database.lockQuotaScope(`${scope}:${key}`), + tryLockScope: (key) => database.tryLockScope(`${scope}:${key}`), + tryLockSharedScope: (key) => database.tryLockSharedScope(`${scope}:${key}`), + close: () => database.close() + } +} + +// ownDatabase keeps production lock keys for mixed-revision tests. export async function fixture({ ownDatabase = false } = {}) { const databaseUrl = durablePushTestDatabaseUrl - if (databaseUrl && !process.env.CI && new URL(databaseUrl).port !== '55440') + if (databaseUrl && !process.env.CI && new URL(databaseUrl).port !== '55440') { throw new Error('isolated_postgres_port_required') + } let db: PushDatabase if (databaseUrl) { const admin = new pg.Client({ connectionString: databaseUrl }) @@ -38,7 +56,9 @@ export async function fixture({ ownDatabase = false } = {}) { } finally { try { await admin.query( - ownDatabase ? `DROP DATABASE IF EXISTS ${name}` : `DROP SCHEMA IF EXISTS ${name} CASCADE` + ownDatabase + ? `DROP DATABASE IF EXISTS ${name}` + : `DROP SCHEMA IF EXISTS ${name} CASCADE` ) } finally { await admin.end() @@ -53,7 +73,8 @@ export async function fixture({ ownDatabase = false } = {}) { await admin.query(`CREATE SCHEMA ${name}`) url.searchParams.set('options', `-c search_path=${name}`) } - db = scoped = await openPushDatabase({ databaseUrl: url.toString(), dataDir: '', poolMax: 4 }) + scoped = await openPushDatabase({ databaseUrl: url.toString(), dataDir: '', poolMax: 4 }) + db = ownDatabase ? scoped : scopeFixtureLocks(scoped, name) } else { db = await openInMemoryPushDatabase() cleanups.push(() => db.close()) @@ -117,7 +138,9 @@ export async function within(operation: Promise, ms: number): Promise { try { return await Promise.race([ operation, - new Promise((_, reject) => (timer = setTimeout(() => reject(new Error('claim_blocked')), ms))) + new Promise( + (_, reject) => (timer = setTimeout(() => reject(new Error('claim_blocked')), ms)) + ) ]) } finally { clearTimeout(timer) diff --git a/cloud/apps/relay/src/drain-release-cell-row-contention-postgres.test.ts b/cloud/apps/relay/src/drain-release-cell-row-contention-postgres.test.ts index a484fc81246..6e2b0974f16 100644 --- a/cloud/apps/relay/src/drain-release-cell-row-contention-postgres.test.ts +++ b/cloud/apps/relay/src/drain-release-cell-row-contention-postgres.test.ts @@ -32,7 +32,8 @@ const describePostgres = databaseUrl ? describe : describe.skip // asia-east2 cell to the us-central1 database, measured in production. const ASIA_ROUND_TRIP_MS = 171 const LOCAL_ROUND_TRIP_MS = 1 -const WINDOW_MS = 10_000 +// Keep the measured contention rates; repeating them for longer only repeats the same locks. +const WINDOW_MS = 5_000 // Reconnecting hosts dialling the directors during the window. Above the // ~6-15/s the production tail sustained, so the director side is never idle. const DIAL_RATE_PER_SECOND = 20 diff --git a/cloud/apps/relay/src/public-assignment-drain-return-lane.test.ts b/cloud/apps/relay/src/public-assignment-drain-return-lane.test.ts index cd1344dc5b6..361dcad7575 100644 --- a/cloud/apps/relay/src/public-assignment-drain-return-lane.test.ts +++ b/cloud/apps/relay/src/public-assignment-drain-return-lane.test.ts @@ -233,17 +233,17 @@ describe('drain-return lane', () => { expect(redial.headers.get('retry-after')).toBe('2') }) - it('admits a 2,500-host drain across five directors without a sticky or placement rejection', async () => { + it('admits a paced drain across five directors without a sticky or placement rejection', async () => { vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'Date'] }) vi.spyOn(console, 'warn').mockImplementation(() => {}) // Five directors behind a load balancer that sends each request to any of - // them. 2,500 drained hosts over the 300 s pace window, beside ordinary - // reconnects (c29 baseline ~13/s) and a trickle of new placements. + // them. A shorter cohort preserves the production arrival rates: ~8 drained + // hosts/s, ~13 ordinary reconnects/s, and a trickle of new placements. const DIRECTORS = 5 - const DRAINED = 2_500 - const PACE_WINDOW_MS = 300_000 - const ORDINARY = 3_900 - const PLACEMENTS = 30 + const DRAINED = 250 + const PACE_WINDOW_MS = 30_000 + const ORDINARY = 390 + const PLACEMENTS = 3 // Measured c29 2026-10-01: ~5.8 re-placements/s fleet-wide, bound by the // region's inventory lock, so ~172 ms of lock per re-placement. const INVENTORY_LOCK_MS = 172 @@ -348,7 +348,7 @@ describe('drain-return lane', () => { } let elapsedMs = 0 - while (inFlight > 0 && elapsedMs < 1_200_000) { + while (inFlight > 0 && elapsedMs < 120_000) { await vi.advanceTimersByTimeAsync(20) elapsedMs += 20 // Signing the lease completes off the timer queue. @@ -363,10 +363,11 @@ describe('drain-return lane', () => { expect(outcomes.get('sticky')).toBe(ORDINARY) expect(outcomes.get('placement')).toBe(PLACEMENTS) expect(outcomes.get('drain-return')).toBe(DRAINED) + expect(outcomes.get('drain-return-deferred') ?? 0).toBeGreaterThan(0) // Paced returns: fewer than two deferrals per drained host, none past the cap. expect(outcomes.get('drain-return-deferred') ?? 0).toBeLessThan(2 * DRAINED) expect(Math.max(...retryAfters)).toBeLessThanOrEqual(300) - // The cohort finishes at the inventory lock's rate, not minutes behind it. + // Retry rounding and off-thread signing keep their original fixed allowance. expect(elapsedMs).toBeLessThan(DRAINED * INVENTORY_LOCK_MS + 60_000) }, 300_000) }) diff --git a/cloud/apps/relay/src/reserve-placement-simulation.test.ts b/cloud/apps/relay/src/reserve-placement-simulation.test.ts index 44f2cafa24e..13bd867b93c 100644 --- a/cloud/apps/relay/src/reserve-placement-simulation.test.ts +++ b/cloud/apps/relay/src/reserve-placement-simulation.test.ts @@ -317,13 +317,6 @@ describe('step 5 deterministic simulation', () => { // A director whose map has not seen the leave yet may re-place level with the row; the // WRONG_CELL row read keeps that a small share (the rest are repaired by one more reconcile). expect(report.badFirstReseats / report.rowAheadDemotions).toBeLessThan(0.1) - const withoutRead = await runReservePlacementSimulation({ ...config, faults: { noWrongCellRowRead: true } }) - process.stderr.write( - `row_ahead_repair ${JSON.stringify({ - withRead: { demotions: report.rowAheadDemotions, badFirstReseats: report.badFirstReseats, perHost: report.rowAheadDemotionsPerHost }, - withoutRead: { demotions: withoutRead.rowAheadDemotions, badFirstReseats: withoutRead.badFirstReseats, perHost: withoutRead.rowAheadDemotionsPerHost } - })}\n` - ) }, 240_000) // Every director misses its feed polls for 150 s: each reserve cell's dead-man trips (60-120 s) diff --git a/config/patches/i18next-cli@1.74.2.patch b/config/patches/i18next-cli@1.74.2.patch index e4eb976d1ec..307096dd25c 100644 --- a/config/patches/i18next-cli@1.74.2.patch +++ b/config/patches/i18next-cli@1.74.2.patch @@ -1,5 +1,5 @@ diff --git a/dist/cjs/extractor/core/extractor.js b/dist/cjs/extractor/core/extractor.js -index 6d61ade2471c20bf1a253e3784bfadcb01440545..c1b001e78a215f89a8d4da1756a87562a5a6e723 100644 +index 6d61ade2471..23ad9f5ba49 100644 --- a/dist/cjs/extractor/core/extractor.js +++ b/dist/cjs/extractor/core/extractor.js @@ -143,6 +143,8 @@ const extractionSiteRegexes = new WeakMap(); @@ -20,8 +20,185 @@ index 6d61ade2471c20bf1a253e3784bfadcb01440545..c1b001e78a215f89a8d4da1756a87562 extractionSiteRegexes.set(config, re); } return re.test(code); +@@ -185,13 +187,15 @@ function mayContainExtractionSite(code, config) { + * + * @internal + */ +-async function processFile(file, plugins, astVisitors, pluginContext, config, logger$1 = new logger.ConsoleLogger(), fileErrors) { ++async function processFile(file, plugins, astVisitors, pluginContext, config, logger$1 = new logger.ConsoleLogger(), fileErrors, parsedFiles) { + try { + // Skip directories that happen to match file-extension globs (e.g. a directory named "Foo.tsx") + const fileStat = await promises.stat(file); + if (fileStat.isDirectory()) + return; +- let code = await promises.readFile(file, 'utf-8'); ++ const cached = parsedFiles?.get(file); ++ parsedFiles?.delete(file); ++ let code = cached?.code ?? await promises.readFile(file, 'utf-8'); + // Run onLoad hooks from plugins with error handling. + // Track whether any plugin actually transformed the code so we can make + // the right parse decision for non-native file types (e.g. .svelte, .vue). +@@ -241,59 +245,61 @@ async function processFile(file, plugins, astVisitors, pluginContext, config, lo + const isTypeScriptFile = effectiveExt === '.ts' || effectiveExt === '.tsx' || effectiveExt === '.mts' || effectiveExt === '.cts'; + const isTSX = effectiveExt === '.tsx'; + const isJSX = effectiveExt === '.jsx'; +- let ast; +- try { +- ast = await core.parse(code, { +- syntax: isTypeScriptFile ? 'typescript' : 'ecmascript', +- tsx: isTSX, +- jsx: isJSX, +- decorators: true, +- dynamicImport: true, +- comments: true, +- }); +- } +- catch (err) { +- // Fallback for .ts files with JSX (already present) +- if (effectiveExt === '.ts' && !isTSX) { +- try { +- ast = await core.parse(code, { +- syntax: 'typescript', +- tsx: true, +- decorators: true, +- dynamicImport: true, +- comments: true, +- }); +- logger$1.info?.(`Parsed ${file} using TSX fallback`); +- } +- catch (err2) { +- throw new validation.ExtractorError('Failed to process file', file, err2); +- } +- // Fallback for .js files with JSX ++ let ast = cached?.ast; ++ if (!cached) { ++ try { ++ ast = await core.parse(code, { ++ syntax: isTypeScriptFile ? 'typescript' : 'ecmascript', ++ tsx: isTSX, ++ jsx: isJSX, ++ decorators: true, ++ dynamicImport: true, ++ comments: true, ++ }); + } +- else if (effectiveExt === '.js' && !isJSX) { +- try { +- ast = await core.parse(code, { +- syntax: 'ecmascript', +- jsx: true, +- decorators: true, +- dynamicImport: true, +- comments: true, +- }); +- logger$1.info?.(`Parsed ${file} using JSX fallback`); ++ catch (err) { ++ // Fallback for .ts files with JSX (already present) ++ if (effectiveExt === '.ts' && !isTSX) { ++ try { ++ ast = await core.parse(code, { ++ syntax: 'typescript', ++ tsx: true, ++ decorators: true, ++ dynamicImport: true, ++ comments: true, ++ }); ++ logger$1.info?.(`Parsed ${file} using TSX fallback`); ++ } ++ catch (err2) { ++ throw new validation.ExtractorError('Failed to process file', file, err2); ++ } ++ // Fallback for .js files with JSX + } +- catch (err2) { +- throw new validation.ExtractorError('Failed to process file', file, err2); ++ else if (effectiveExt === '.js' && !isJSX) { ++ try { ++ ast = await core.parse(code, { ++ syntax: 'ecmascript', ++ jsx: true, ++ decorators: true, ++ dynamicImport: true, ++ comments: true, ++ }); ++ logger$1.info?.(`Parsed ${file} using JSX fallback`); ++ } ++ catch (err2) { ++ throw new validation.ExtractorError('Failed to process file', file, err2); ++ } ++ } ++ else { ++ throw new validation.ExtractorError('Failed to process file', file, err); + } + } +- else { +- throw new validation.ExtractorError('Failed to process file', file, err); +- } ++ // Normalize SWC span offsets so every span is a file-relative (0-based) ++ // character index. SWC accumulates byte offsets across successive parse() ++ // calls and Module.span.start points to the first token, NOT to byte 0 of ++ // the source. ++ astUtils.normalizeSpansToCharIndices(ast, code); + } +- // Normalize SWC span offsets so every span is a file-relative (0-based) +- // character index. SWC accumulates byte offsets across successive parse() +- // calls and Module.span.start points to the first token, NOT to byte 0 of +- // the source. +- astUtils.normalizeSpansToCharIndices(ast, code); + // "Wire up" the visitor's scope method to the context. + // This avoids a circular dependency while giving plugins access to the scope. + pluginContext.getVarFromScope = astVisitors.getVarFromScope.bind(astVisitors); +@@ -344,7 +350,7 @@ async function processFile(file, plugins, astVisitors, pluginContext, config, lo + * @param logger - Logger for warnings/errors + * @param fileErrors - Optional array to collect per-file error messages + */ +-async function preScanFile(file, astVisitors, config, logger$1 = new logger.ConsoleLogger(), fileErrors) { ++async function preScanFile(file, astVisitors, config, logger$1 = new logger.ConsoleLogger(), fileErrors, parsedFiles) { + try { + // Skip directories that happen to match file-extension globs (e.g. a directory named "Foo.tsx") + const fileStat = await promises.stat(file); +@@ -396,6 +402,8 @@ async function preScanFile(file, astVisitors, config, logger$1 = new logger.Cons + astUtils.normalizeSpansToCharIndices(ast, code); + astVisitors.setCurrentFile(file, code); + astVisitors.preScanForConstants(ast); ++ if (parsedFiles && mayContainExtractionSite(code, config)) ++ parsedFiles.set(file, { code, ast }); + } + catch (error) { + if (error instanceof pluginManager.ConflictError) +diff --git a/dist/cjs/extractor/core/key-finder.js b/dist/cjs/extractor/core/key-finder.js +index 1411f287157..200b6b974b6 100644 +--- a/dist/cjs/extractor/core/key-finder.js ++++ b/dist/cjs/extractor/core/key-finder.js +@@ -40,6 +40,8 @@ async function findKeys(config, logger$1 = new logger.ConsoleLogger(), fileError + const plugins = pluginsOrUndefined || []; + const sourceFiles = await processSourceFiles(config); + const allKeys = new Map(); ++ // Retain only translation-site trees; plugins can transform code between passes. ++ const parsedFiles = plugins.length === 0 ? new Map() : undefined; + // 1. Create the base context with config and logger. + const pluginContext = pluginManager.createPluginContext(allKeys, plugins, otherConfig, logger$1); + // 2. Create hooks for plugins to hook into AST +@@ -92,7 +94,7 @@ async function findKeys(config, logger$1 = new logger.ConsoleLogger(), fileError + // correctly even when the defining constants file is processed after the file that + // uses the identifier. + for (const file of sourceFiles) { +- await extractor.preScanFile(file, astVisitors$1, otherConfig, logger$1, fileErrors); ++ await extractor.preScanFile(file, astVisitors$1, otherConfig, logger$1, fileErrors, parsedFiles); + } + // 6b. Mirror declarations onto the local names they were imported as, so that + // `import { ResourceStatus as Status }` resolves like the unaliased import. +@@ -100,7 +102,7 @@ async function findKeys(config, logger$1 = new logger.ConsoleLogger(), fileError + // 7. Extraction pass: all shared tables are now fully populated, so every + // identifier reference can be resolved regardless of file order. + for (const file of sourceFiles) { +- await extractor.processFile(file, plugins, astVisitors$1, pluginContext, otherConfig, logger$1, fileErrors); ++ await extractor.processFile(file, plugins, astVisitors$1, pluginContext, otherConfig, logger$1, fileErrors, parsedFiles); + } + // 7. Run onEnd hooks + for (const plugin of plugins) { diff --git a/dist/esm/extractor/core/extractor.js b/dist/esm/extractor/core/extractor.js -index 910bdb6e25e26c01ea6127191f1a00c7089693c1..00da4a6a7acb6c3da8d4b6fe7be106c409542f8d 100644 +index 910bdb6e25e..a3868d29950 100644 --- a/dist/esm/extractor/core/extractor.js +++ b/dist/esm/extractor/core/extractor.js @@ -141,6 +141,8 @@ const extractionSiteRegexes = new WeakMap(); @@ -42,3 +219,180 @@ index 910bdb6e25e26c01ea6127191f1a00c7089693c1..00da4a6a7acb6c3da8d4b6fe7be106c4 extractionSiteRegexes.set(config, re); } return re.test(code); +@@ -183,13 +185,15 @@ function mayContainExtractionSite(code, config) { + * + * @internal + */ +-async function processFile(file, plugins, astVisitors, pluginContext, config, logger = new ConsoleLogger(), fileErrors) { ++async function processFile(file, plugins, astVisitors, pluginContext, config, logger = new ConsoleLogger(), fileErrors, parsedFiles) { + try { + // Skip directories that happen to match file-extension globs (e.g. a directory named "Foo.tsx") + const fileStat = await stat(file); + if (fileStat.isDirectory()) + return; +- let code = await readFile(file, 'utf-8'); ++ const cached = parsedFiles?.get(file); ++ parsedFiles?.delete(file); ++ let code = cached?.code ?? await readFile(file, 'utf-8'); + // Run onLoad hooks from plugins with error handling. + // Track whether any plugin actually transformed the code so we can make + // the right parse decision for non-native file types (e.g. .svelte, .vue). +@@ -239,59 +243,61 @@ async function processFile(file, plugins, astVisitors, pluginContext, config, lo + const isTypeScriptFile = effectiveExt === '.ts' || effectiveExt === '.tsx' || effectiveExt === '.mts' || effectiveExt === '.cts'; + const isTSX = effectiveExt === '.tsx'; + const isJSX = effectiveExt === '.jsx'; +- let ast; +- try { +- ast = await parse(code, { +- syntax: isTypeScriptFile ? 'typescript' : 'ecmascript', +- tsx: isTSX, +- jsx: isJSX, +- decorators: true, +- dynamicImport: true, +- comments: true, +- }); +- } +- catch (err) { +- // Fallback for .ts files with JSX (already present) +- if (effectiveExt === '.ts' && !isTSX) { +- try { +- ast = await parse(code, { +- syntax: 'typescript', +- tsx: true, +- decorators: true, +- dynamicImport: true, +- comments: true, +- }); +- logger.info?.(`Parsed ${file} using TSX fallback`); +- } +- catch (err2) { +- throw new ExtractorError('Failed to process file', file, err2); +- } +- // Fallback for .js files with JSX ++ let ast = cached?.ast; ++ if (!cached) { ++ try { ++ ast = await parse(code, { ++ syntax: isTypeScriptFile ? 'typescript' : 'ecmascript', ++ tsx: isTSX, ++ jsx: isJSX, ++ decorators: true, ++ dynamicImport: true, ++ comments: true, ++ }); + } +- else if (effectiveExt === '.js' && !isJSX) { +- try { +- ast = await parse(code, { +- syntax: 'ecmascript', +- jsx: true, +- decorators: true, +- dynamicImport: true, +- comments: true, +- }); +- logger.info?.(`Parsed ${file} using JSX fallback`); ++ catch (err) { ++ // Fallback for .ts files with JSX (already present) ++ if (effectiveExt === '.ts' && !isTSX) { ++ try { ++ ast = await parse(code, { ++ syntax: 'typescript', ++ tsx: true, ++ decorators: true, ++ dynamicImport: true, ++ comments: true, ++ }); ++ logger.info?.(`Parsed ${file} using TSX fallback`); ++ } ++ catch (err2) { ++ throw new ExtractorError('Failed to process file', file, err2); ++ } ++ // Fallback for .js files with JSX + } +- catch (err2) { +- throw new ExtractorError('Failed to process file', file, err2); ++ else if (effectiveExt === '.js' && !isJSX) { ++ try { ++ ast = await parse(code, { ++ syntax: 'ecmascript', ++ jsx: true, ++ decorators: true, ++ dynamicImport: true, ++ comments: true, ++ }); ++ logger.info?.(`Parsed ${file} using JSX fallback`); ++ } ++ catch (err2) { ++ throw new ExtractorError('Failed to process file', file, err2); ++ } ++ } ++ else { ++ throw new ExtractorError('Failed to process file', file, err); + } + } +- else { +- throw new ExtractorError('Failed to process file', file, err); +- } ++ // Normalize SWC span offsets so every span is a file-relative (0-based) ++ // character index. SWC accumulates byte offsets across successive parse() ++ // calls and Module.span.start points to the first token, NOT to byte 0 of ++ // the source. ++ normalizeSpansToCharIndices(ast, code); + } +- // Normalize SWC span offsets so every span is a file-relative (0-based) +- // character index. SWC accumulates byte offsets across successive parse() +- // calls and Module.span.start points to the first token, NOT to byte 0 of +- // the source. +- normalizeSpansToCharIndices(ast, code); + // "Wire up" the visitor's scope method to the context. + // This avoids a circular dependency while giving plugins access to the scope. + pluginContext.getVarFromScope = astVisitors.getVarFromScope.bind(astVisitors); +@@ -342,7 +348,7 @@ async function processFile(file, plugins, astVisitors, pluginContext, config, lo + * @param logger - Logger for warnings/errors + * @param fileErrors - Optional array to collect per-file error messages + */ +-async function preScanFile(file, astVisitors, config, logger = new ConsoleLogger(), fileErrors) { ++async function preScanFile(file, astVisitors, config, logger = new ConsoleLogger(), fileErrors, parsedFiles) { + try { + // Skip directories that happen to match file-extension globs (e.g. a directory named "Foo.tsx") + const fileStat = await stat(file); +@@ -394,6 +400,8 @@ async function preScanFile(file, astVisitors, config, logger = new ConsoleLogger + normalizeSpansToCharIndices(ast, code); + astVisitors.setCurrentFile(file, code); + astVisitors.preScanForConstants(ast); ++ if (parsedFiles && mayContainExtractionSite(code, config)) ++ parsedFiles.set(file, { code, ast }); + } + catch (error) { + if (error instanceof ConflictError) +diff --git a/dist/esm/extractor/core/key-finder.js b/dist/esm/extractor/core/key-finder.js +index 86612731b2d..4e724e84538 100644 +--- a/dist/esm/extractor/core/key-finder.js ++++ b/dist/esm/extractor/core/key-finder.js +@@ -38,6 +38,8 @@ async function findKeys(config, logger = new ConsoleLogger(), fileErrors) { + const plugins = pluginsOrUndefined || []; + const sourceFiles = await processSourceFiles(config); + const allKeys = new Map(); ++ // Retain only translation-site trees; plugins can transform code between passes. ++ const parsedFiles = plugins.length === 0 ? new Map() : undefined; + // 1. Create the base context with config and logger. + const pluginContext = createPluginContext(allKeys, plugins, otherConfig, logger); + // 2. Create hooks for plugins to hook into AST +@@ -90,7 +92,7 @@ async function findKeys(config, logger = new ConsoleLogger(), fileErrors) { + // correctly even when the defining constants file is processed after the file that + // uses the identifier. + for (const file of sourceFiles) { +- await preScanFile(file, astVisitors, otherConfig, logger, fileErrors); ++ await preScanFile(file, astVisitors, otherConfig, logger, fileErrors, parsedFiles); + } + // 6b. Mirror declarations onto the local names they were imported as, so that + // `import { ResourceStatus as Status }` resolves like the unaliased import. +@@ -98,7 +100,7 @@ async function findKeys(config, logger = new ConsoleLogger(), fileErrors) { + // 7. Extraction pass: all shared tables are now fully populated, so every + // identifier reference can be resolved regardless of file order. + for (const file of sourceFiles) { +- await processFile(file, plugins, astVisitors, pluginContext, otherConfig, logger, fileErrors); ++ await processFile(file, plugins, astVisitors, pluginContext, otherConfig, logger, fileErrors, parsedFiles); + } + // 7. Run onEnd hooks + for (const plugin of plugins) { diff --git a/config/scripts/build-wayland-clipboard.test.mjs b/config/scripts/build-wayland-clipboard.test.mjs index 9977a8353d8..64ebce4770f 100644 --- a/config/scripts/build-wayland-clipboard.test.mjs +++ b/config/scripts/build-wayland-clipboard.test.mjs @@ -12,6 +12,8 @@ import { createRequire } from 'node:module' import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { parse } from 'yaml' +import { runProcessSync } from '@orca/process-host' import { buildWaylandClipboard } from './build-wayland-clipboard.mjs' import glibcVerification from './verify-linux-glibc-floor.cjs' @@ -148,6 +150,50 @@ describe('Wayland clipboard build', () => { }) }) +describe('Wayland clipboard CI cache recovery', () => { + it.skipIf(process.platform === 'win32').each([ + ['healthy cache', true, 0, 0, 1], + ['invalid cache', true, 1, 0, 2], + ['failed replacement build', true, 1, 1, 2], + ['failed uncached build', false, 1, 1, 1] + ])('%s preserves build validation and failures', (_label, hit, initial, rebuild, calls) => { + const action = parse( + readFileSync('.github/actions/prepare-wayland-clipboard/action.yml', 'utf8') + ) + const build = action.runs.steps.find( + (step) => step.name === 'Build or validate Wayland clipboard helper' + ) + mkdirSync(join(root, 'native', 'wayland-clipboard', '.build'), { recursive: true }) + writeFileSync(join(root, 'native', 'wayland-clipboard', '.build', 'restored-cache'), '') + const script = ` + node() { + echo attempt >> calls + if [ -e native/wayland-clipboard/.build/restored-cache ]; then + return "$INITIAL_STATUS" + fi + return "$REBUILD_STATUS" + } + ${build.run} + ` + const result = runProcessSync({ + program: 'bash', + args: ['-e', '-c', script], + cwd: root, + env: { + ...process.env, + CACHE_HIT: String(hit), + INITIAL_STATUS: String(initial), + REBUILD_STATUS: String(rebuild) + } + }) + expect(result.code).toBe(initial === 0 ? 0 : hit ? rebuild : initial) + expect(readFileSync(join(root, 'calls'), 'utf8').trim().split('\n')).toHaveLength(calls) + expect(existsSync(join(root, 'native', 'wayland-clipboard', '.build', 'restored-cache'))).toBe( + calls === 1 + ) + }) +}) + describe('Wayland clipboard packaging', () => { it.each([ [1, 'x64'], diff --git a/config/scripts/check-node-runtime-pin.test.mjs b/config/scripts/check-node-runtime-pin.test.mjs index a805985d966..885bbc39776 100644 --- a/config/scripts/check-node-runtime-pin.test.mjs +++ b/config/scripts/check-node-runtime-pin.test.mjs @@ -189,7 +189,8 @@ describe('committed pin', () => { it('runs in the static analysis job', () => { const workflow = parse(readFileSync(path.join(projectDir, '.github/workflows/pr.yml'), 'utf8')) + const packageJson = JSON.parse(readFileSync(path.join(projectDir, 'package.json'), 'utf8')) const commands = workflow.jobs.preflight.steps.map((step) => step.run ?? '') - expect(commands).toContain('pnpm run check:node-runtime-pin') + expect(commands).toContain(packageJson.scripts['check:node-runtime-pin']) }) }) diff --git a/config/scripts/electron-builder-mac-channel-config.test.mjs b/config/scripts/electron-builder-mac-channel-config.test.mjs index 05572f3497a..693ac753978 100644 --- a/config/scripts/electron-builder-mac-channel-config.test.mjs +++ b/config/scripts/electron-builder-mac-channel-config.test.mjs @@ -133,20 +133,4 @@ describe('electron-builder mac channel config', () => { } ) }) - - // Why: the dev channels share every packaging decision except where they - // publish, so a future edit that collapses them must not also collapse the - // repos — a branch or daily build landing in orca-hourly would be offered to - // everyone riding main's hourlies. - it('keeps the dev channels on separate repos', () => { - withHourlyEnv((hourly) => { - withDailyEnv((daily) => { - withAdhocEnv((adhoc) => { - expect(new Set([hourly.publish.repo, daily.publish.repo, adhoc.publish.repo]).size).toBe( - 3 - ) - }) - }) - }) - }) }) diff --git a/config/scripts/generated-artifact-line-ending-pin.test.mjs b/config/scripts/generated-artifact-line-ending-pin.test.mjs index 7720cda1938..70e2ab41075 100644 --- a/config/scripts/generated-artifact-line-ending-pin.test.mjs +++ b/config/scripts/generated-artifact-line-ending-pin.test.mjs @@ -16,10 +16,14 @@ it('keeps byte-compared artifacts and source LF in an autocrlf checkout', () => ['src/renderer/src/example.tsx', lf], ['src/main/example.ts', lf], ['config/scripts/example.mjs', lf], + ['config/scripts/nested/deeper/example.mjs', lf], ...['cmd', 'bat', 'ps1', 'nsh'].map((extension) => [`config/example.${extension}`, crlf]), ['resources/skills-extra/example.json', crlf], ['vendor/resources/skills/example.json', crlf], ['scripts/example.mjs', crlf], + ['config/scripts-extra/example.mjs', crlf], + ['vendor/config/scripts/example.mjs', crlf], + ['config/scripts/example.mjsx', crlf], ['src/main/runtime/__fixtures__/example.txt', crlf], ['src/main/daemon/__fixtures__/pty-transcripts/example.txt', crlf] ]) diff --git a/config/scripts/happy-dom-offscreen-canvas.ts b/config/scripts/happy-dom-offscreen-canvas.ts index 1b06e472025..04e116a4192 100644 --- a/config/scripts/happy-dom-offscreen-canvas.ts +++ b/config/scripts/happy-dom-offscreen-canvas.ts @@ -49,7 +49,7 @@ export function installHappyDomOffscreenCanvasCompatibility(): boolean { return context } - const canvas = document.createElement('canvas') as PatchableCanvas + const canvas = document.createElement('canvas') return htmlCanvas.prototype.getContext!.call(canvas, contextType, contextAttributes) } prototype[OFFSCREEN_CANVAS_COMPATIBILITY_INSTALLED] = true diff --git a/config/scripts/locale-ko-key-overrides.test.mjs b/config/scripts/locale-ko-key-overrides.test.mjs deleted file mode 100644 index da98d401e54..00000000000 --- a/config/scripts/locale-ko-key-overrides.test.mjs +++ /dev/null @@ -1,14 +0,0 @@ -import fs from 'node:fs' - -import { describe, expect, it } from 'vitest' - -describe('locale-ko-key-overrides', () => { - it('keeps Korean key override data scoped to Korean values', () => { - const overrides = JSON.parse( - fs.readFileSync(new URL('./locale-ko-key-overrides.json', import.meta.url), 'utf8') - ) - for (const value of Object.values(overrides)) { - expect(Object.keys(value)).toEqual(['ko']) - } - }) -}) diff --git a/config/scripts/localization-extraction-call-syntax.test.mjs b/config/scripts/localization-extraction-call-syntax.test.mjs index 125fe9c4fcb..6ae1f24be04 100644 --- a/config/scripts/localization-extraction-call-syntax.test.mjs +++ b/config/scripts/localization-extraction-call-syntax.test.mjs @@ -53,4 +53,47 @@ describe.each([ rmSync(directory, { recursive: true, force: true }) } }) + + it('preserves cross-file namespaces, source locations and fresh plugin transformations', async () => { + const directory = mkdtempSync(join(tmpdir(), 'orca-extraction-passes-')) + const file = join(directory, 'a-consumer.ts') + const config = { + locales: ['en'], + extract: { + input: [join(directory, '*.ts').replaceAll('\\', '/')], + functions: ['t', '*.t'], + disablePlurals: true + } + } + try { + writeFileSync(join(directory, 'z-constants.ts'), "export const NS = 'probe' as const\n") + const source = [ + "import { NS } from './z-constants'", + '/* 🙂 */ const { t } = useTranslation(NS);', + "t('first', 'First')" + ].join('\n') + writeFileSync(file, source) + const first = await extractKeys(config) + expect([...first.allKeys.values()]).toMatchObject([ + { key: 'first', ns: 'probe', locations: [{ file, line: 3, column: 2 }] } + ]) + + writeFileSync(file, source.replace("'first'", "'updated'")) + const updated = await extractKeys(config) + expect([...updated.allKeys.values()].map((entry) => entry.key)).toEqual(['updated']) + + const transformed = await extractKeys({ + ...config, + plugins: [ + { + name: 'fixture-transform', + onLoad: (code) => code.replace("'updated'", "'transformed'") + } + ] + }) + expect([...transformed.allKeys.values()].map((entry) => entry.key)).toEqual(['transformed']) + } finally { + rmSync(directory, { recursive: true, force: true }) + } + }) }) diff --git a/config/scripts/mobile-release-check-scope.test.mjs b/config/scripts/mobile-release-check-scope.test.mjs index 7a07b19af2f..5325c1626c6 100644 --- a/config/scripts/mobile-release-check-scope.test.mjs +++ b/config/scripts/mobile-release-check-scope.test.mjs @@ -102,7 +102,8 @@ it('gates Ruby independently and retains mobile static validation', () => { for (const file of [ 'config/scripts/mobile-release-check-scope.mjs', 'config/scripts/mobile-release-check-scope.test.mjs', - 'config/scripts/pr-code-change-scope.mjs' + 'config/scripts/pr-code-change-scope.mjs', + 'config/scripts/pr-package-test-paths.mjs' ]) { expect(workflow.on.pull_request.paths.some((pattern) => matchesGlob(file, pattern))).toBe(true) } @@ -137,6 +138,7 @@ function fixture(detectorStep = detector) { for (const file of [ 'package.json', 'config/scripts/pr-code-change-scope.mjs', + 'config/scripts/pr-package-test-paths.mjs', 'config/scripts/pr-layout-oracle-route.mjs', 'config/scripts/mobile-release-check-scope.mjs', 'config/scripts/mobile-test-change-scope.mjs' diff --git a/config/scripts/mobile-test-change-scope.mjs b/config/scripts/mobile-test-change-scope.mjs index f773fb8fa02..3be5e7c5ec2 100644 --- a/config/scripts/mobile-test-change-scope.mjs +++ b/config/scripts/mobile-test-change-scope.mjs @@ -8,6 +8,7 @@ const NON_TEST_FILES = new Set([ 'mobile/README.md', '.github/workflows/mobile-ios-release.yml', 'config/scripts/pr-code-change-scope.mjs', + 'config/scripts/pr-package-test-paths.mjs', 'config/scripts/pr-code-change-scope.test.mjs', 'config/scripts/pr-layout-oracle-route.mjs', 'config/scripts/mobile-release-check-scope.mjs', diff --git a/config/scripts/mobile-test-change-scope.test.mjs b/config/scripts/mobile-test-change-scope.test.mjs index 06d5273ec74..d5cdad6abbb 100644 --- a/config/scripts/mobile-test-change-scope.test.mjs +++ b/config/scripts/mobile-test-change-scope.test.mjs @@ -12,6 +12,7 @@ it.each([ 'mobile/fastlane/Fastfile', '.github/workflows/mobile-ios-release.yml', 'config/scripts/pr-code-change-scope.mjs', + 'config/scripts/pr-package-test-paths.mjs', 'config/scripts/mobile-release-check-scope.test.mjs' ])('skips only known non-test inputs: %s', (file) => { expect(shouldRunMobileTests([file])).toBe(false) diff --git a/config/scripts/mobile-web-app-browser-pane-text-inputs.test.mjs b/config/scripts/mobile-web-app-browser-pane-text-inputs.test.mjs deleted file mode 100644 index 0482a51079f..00000000000 --- a/config/scripts/mobile-web-app-browser-pane-text-inputs.test.mjs +++ /dev/null @@ -1,108 +0,0 @@ -/** - * The browser pane's two text inputs, read the way C4.2's census reads them. - * - * The pane is not in any page route today, so no route closure reaches it and the census that - * enforces the seam is not run against it: it walks the source-control hub and the review route, - * and the pane is in neither. C6 raises both inputs anyway, because the failure is not cosmetic — - * an input under 16px makes an iOS browser zoom the page on focus, and `keyboard-occlusion.web.ts` - * reads a visual viewport scale other than 1 as "no keyboard" for the rest of the typing session. - * - * This file is the census run by hand over the pane's own modules, so the raise is checked by the - * rule that will judge it once a route lists the pane. - */ -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { fileURLToPath } from 'node:url' -import { describe, expect, it } from 'vitest' -import { - modulesDeclaringTextInput, - textInputFontSizeOffenders, - unresolvedTextInputStyles -} from './mobile-web-app-text-input-font-size-seam.mjs' - -const mobileDir = join(fileURLToPath(new URL('../..', import.meta.url)), 'mobile') - -/** - * The pane's text-input modules, named as the bundler resolves them: a `.web.ts` where one exists, - * which is what `mobileWebAppRouteClosure` reports for a real route. - */ -const PANE_CLOSURE = { - local: [ - 'src/browser/MobileBrowserPaneView.tsx', - 'src/browser/MobileBrowserAddressField.tsx', - 'src/browser/mobile-browser-pane-styles.ts', - 'src/browser/browser-address-field-styles.web.ts', - 'src/platform/text-input-font-size.web.ts' - ] -} - -const KEY_ROW_STYLES = 'src/browser/mobile-browser-pane-styles.ts' - -/** The modules in `PANE_CLOSURE` that render an input, which is what the rule below judges. */ -const PANE_INPUT_MODULES = [ - 'src/browser/MobileBrowserAddressField.tsx', - 'src/browser/MobileBrowserPaneView.tsx' -] - -function offendingFiles() { - return textInputFontSizeOffenders(mobileDir, PANE_CLOSURE).map((entry) => - entry.slice(0, entry.lastIndexOf(':')) - ) -} - -describe('the browser pane text inputs under the C4.2 census', () => { - /** - * The precondition the rest of this file rests on. - * - * `PANE_CLOSURE` is written by hand, because the pane is in no page route and there is no - * closure to derive it from. So "no unresolved styles" says the walk read the files listed, not - * that those files are the pane's inputs — a third input module added under `src/browser/` would - * leave every assertion below green and unchecked. This scans for it instead. - */ - it('lists every module under src/browser that renders an input', () => { - expect(modulesDeclaringTextInput(mobileDir, 'src/browser')).toEqual(PANE_INPUT_MODULES) - expect(PANE_CLOSURE.local).toEqual(expect.arrayContaining(PANE_INPUT_MODULES)) - }) - - it('reads every input the pane declares, so the verdicts below are complete', () => { - // The completeness half: an empty offender list means nothing if the walk found no input. - expect(unresolvedTextInputStyles(mobileDir, PANE_CLOSURE)).toEqual([]) - expect(PANE_CLOSURE.local).toContain('src/platform/text-input-font-size.web.ts') - }) - - // A scan that happens to find two things is not a scan that would find a third. Planted in a - // scratch tree rather than in src/browser, so no other census ever walks the plant. - it('would report a third input module, and ignores tests and non-JSX mentions', () => { - const scratch = mkdtempSync(join(tmpdir(), 'orca-browser-pane-inputs-')) - try { - mkdirSync(join(scratch, 'src/browser'), { recursive: true }) - for (const [name, source] of Object.entries({ - 'Planted.tsx': 'export const P = () => ', - 'Planted.test.tsx': 'it("x", () => )', - 'mentions-only.ts': "import { TextInput } from 'react-native' // TextInput, named twice" - })) { - writeFileSync(join(scratch, 'src/browser', name), source) - } - - expect(modulesDeclaringTextInput(scratch, 'src/browser')).toEqual(['src/browser/Planted.tsx']) - } finally { - rmSync(scratch, { recursive: true, force: true }) - } - }) - - it('takes the key row input through the seam', () => { - expect(offendingFiles()).not.toContain(KEY_ROW_STYLES) - }) - - /** - * The address field is split, and the split is what the census now reads. - * - * Native keeps the 12px the toolbar has always rendered, because no phone has a page to zoom; - * the browser gets the seam. `resolveLocal` follows the `.web.ts` the builder would have - * resolved, so the size judged here is the size the page runs rather than the one it does not. - */ - it('takes the address field through the seam, through its .web sibling', () => { - expect(offendingFiles()).toEqual([]) - }) -}) diff --git a/config/scripts/mobile-web-app-module-closure.test.mjs b/config/scripts/mobile-web-app-module-closure.test.mjs deleted file mode 100644 index 4be15640f3b..00000000000 --- a/config/scripts/mobile-web-app-module-closure.test.mjs +++ /dev/null @@ -1,20 +0,0 @@ -import { mkdir, realpath, writeFile } from 'node:fs/promises' -import { join } from 'node:path' -import { expect, it } from 'vitest' -import { mobileWebAppModuleClosure } from './build-mobile-web-app-bundle.mjs' -import { withScratch } from './mobile-web-app-bundle-test-fixture.mjs' - -it.each(['app/_layout', 'app/_layout.tsx'])( - 'resolves %s from the supplied directory without a TypeScript baseUrl', - async (entry) => { - await withScratch(async (scratch) => { - const root = await realpath(scratch) - await mkdir(join(root, 'app')) - await writeFile(join(root, 'app/_layout.tsx'), "export default 'native'") - await writeFile(join(root, 'app/_layout.web.tsx'), "export default 'web'") - const closure = await mobileWebAppModuleClosure([entry], { absWorkingDir: root }) - expect(closure.modules).toEqual(['app/_layout.web.tsx']) - expect(closure.local).toEqual(['app/_layout.web.tsx']) - }) - } -) diff --git a/config/scripts/mobile-web-app-session-terminal-closure.test.mjs b/config/scripts/mobile-web-app-session-terminal-closure.test.mjs index 8146aa13f0c..c6ff5223013 100644 --- a/config/scripts/mobile-web-app-session-terminal-closure.test.mjs +++ b/config/scripts/mobile-web-app-session-terminal-closure.test.mjs @@ -1,6 +1,6 @@ import { join } from 'node:path' import { fileURLToPath } from 'node:url' -import { describe, expect, it } from 'vitest' +import { beforeAll, describe, expect, it } from 'vitest' import { mobileWebAppRouteClosure } from './build-mobile-web-app-bundle.mjs' import { mobileWebAppDependenciesPresent } from './mobile-web-app-bundle-dependencies.mjs' import { mobileWebAppRouteChunkClosure } from './mobile-web-app-route-chunk-closure.mjs' @@ -478,8 +478,13 @@ const describeClosure = bundles ? describe : describe.skip describeClosure( "the session route's page closure with the terminal on it", () => { + let closure + beforeAll(async () => { + closure = await mobileWebAppRouteClosure(SESSION_ROUTE) + }, 300_000) + it('gains the document, xterm and the addons, and sheds the engine string', async () => { - const { local, modules } = await mobileWebAppRouteClosure(SESSION_ROUTE) + const { local, modules } = closure for (const gone of SHED) { expect(local, `${gone} is still in the closure`).not.toContain(gone) } @@ -506,7 +511,7 @@ describeClosure( }, 300_000) it('enters through the web sibling and the route body, not the switch', async () => { - const { local } = await mobileWebAppRouteClosure(SESSION_ROUTE) + const { local } = closure for (const entry of ROUTE_ENTRY) { expect(local, `${entry} is not in the closure`).toContain(entry) } @@ -518,7 +523,7 @@ describeClosure( }, 300_000) it('reaches the engine as one deferred module and never as part of the download', async () => { - const { modules } = await mobileWebAppRouteClosure(SESSION_ROUTE) + const { modules } = closure // The engine is here, as the one artifact the loader imports. expect(artifactModules(modules)).toHaveLength(1) // Package sources stay inside that artifact; unrelated module counts are not this boundary. @@ -537,16 +542,17 @@ describeClosure( }, 600_000) it('leaves the 16px seam census exactly where C7.2 left it', async () => { - const closure = await mobileWebAppRouteClosure(SESSION_ROUTE) // Two preconditions, because zero offenders is what a walk that read nothing also reports: // the seam's own web module has to be in the closure, and no style may be unresolved. expect(closure.local).toContain('src/platform/text-input-font-size.web.ts') + expect(closure.local.length).toBeGreaterThan(900) expect(unresolvedTextInputStyles(mobileDir, closure)).toEqual([]) expect(textInputFontSizeOffenders(mobileDir, closure)).toHaveLength(EXPECTED_OFFENDERS) + expect(closure.local).toContain('src/session/mobile-native-chat-input-styles.web.ts') + expect(closure.local).not.toContain('src/session/mobile-native-chat-input-styles.ts') }, 300_000) it('holds the editables the TextInput census cannot see to the same floor', async () => { - const closure = await mobileWebAppRouteClosure(SESSION_ROUTE) // The rich Markdown editor's surface is a `contenteditable` in a markup string, sized by a // rule in a stylesheet: `modulesDeclaringTextInput` matches JSX tags and never sees it, so it // shipped at 14 px and was measured at 14 px in both engines. The same floor, read by a rule diff --git a/config/scripts/mobile-web-app-session-text-inputs.test.mjs b/config/scripts/mobile-web-app-session-text-inputs.test.mjs deleted file mode 100644 index c8b1f76bb1c..00000000000 --- a/config/scripts/mobile-web-app-session-text-inputs.test.mjs +++ /dev/null @@ -1,76 +0,0 @@ -/** - * Every text input the session screen reaches, and the size it declares. - * - * iOS zooms the page on focus of any input under 16px and does not zoom back out, so the document - * spends the rest of that typing session at a scale other than 1 — which `keyboard-occlusion.web.ts` - * reads as "not a keyboard" on purpose, because geometry cannot separate a zoom from a keyboard. - * - * This closure is the one that cannot afford it. The terminal's own input is a hidden field the - * keyboard seam's geometry is the whole basis of, and this screen reaches nine inputs that declare - * a size — a custom-key capture field, a prompt modal, the chat's ask/composer/question fields, the - * quick-command editor and its search, and the terminal command bar. One of them left off the seam - * leaves every later focus on this screen measuring a zoomed document. - * - * The rule is the closure rather than the nine sites it happens to have today: a module entering it - * later is held to it without anyone remembering to add it here. - */ -import { fileURLToPath } from 'node:url' -import { describe, expect, it } from 'vitest' -import { mobileWebAppRouteClosure } from './build-mobile-web-app-bundle.mjs' -import { mobileWebAppDependenciesPresent } from './mobile-web-app-bundle-dependencies.mjs' -import { - TEXT_INPUT_FONT_SIZE_SEAM, - textInputFontSizeOffenders, - unresolvedTextInputStyles -} from './mobile-web-app-text-input-font-size-seam.mjs' - -const mobileDir = fileURLToPath(new URL('../../mobile/', import.meta.url)) -const describeClosure = mobileWebAppDependenciesPresent() ? describe : describe.skip - -const SESSION = 'app/h/[hostId]/session/[worktreeId].tsx' - -/** - * The style module this screen splits, as the page bundle resolves it. - * - * Named rather than left to the offender list because a split is the one fix that can be undone - * without reopening the offence: delete the `.web.ts` and the native sibling's size is what the - * page runs, which is a 15px chat composer and a zoomed document, and the offender list would say - * so — but only the next time someone reads it. Listed here, the closure says which file the page - * loads. - */ -const SPLIT_WEB_STYLES = ['src/session/mobile-native-chat-input-styles.web.ts'] - -describeClosure( - 'the text inputs the session screen reaches', - () => { - it('takes every input size through the seam', async () => { - const closure = await mobileWebAppRouteClosure(SESSION) - expect(textInputFontSizeOffenders(mobileDir, closure)).toEqual([]) - }) - - it('reads every input it found, so the list above is complete', async () => { - // The completeness half: an empty offender list is evidence only if every input was read. - const closure = await mobileWebAppRouteClosure(SESSION) - expect(unresolvedTextInputStyles(mobileDir, closure)).toEqual([]) - }) - - it('carries the seam, so the rule is not vacuous', async () => { - // Without this an empty offender list would also be what a closure reaching no text input at - // all produces, and the census would pass against a page that has nothing to raise. - const closure = await mobileWebAppRouteClosure(SESSION) - expect(closure.local).toContain(TEXT_INPUT_FONT_SIZE_SEAM) - expect(closure.local.length).toBeGreaterThan(900) - }) - - it('loads the web half of the split style module, not the native one', async () => { - const closure = await mobileWebAppRouteClosure(SESSION) - expect(closure.local).toEqual(expect.arrayContaining(SPLIT_WEB_STYLES)) - expect( - closure.local.filter((file) => - SPLIT_WEB_STYLES.some((web) => file === web.replace('.web.ts', '.ts')) - ) - ).toEqual([]) - }) - }, - 240_000 -) diff --git a/config/scripts/mobile-web-app-terminal-live-input-render.test.mjs b/config/scripts/mobile-web-app-terminal-live-input-render.test.mjs index b1821fd0683..20ae89f970b 100644 --- a/config/scripts/mobile-web-app-terminal-live-input-render.test.mjs +++ b/config/scripts/mobile-web-app-terminal-live-input-render.test.mjs @@ -36,7 +36,7 @@ import { LAYOUT_SOURCE } from './mobile-web-app-terminal-probe-route.mjs' * ref points at is inside that branch and never renders, `liveInputRef.current` is null, and * `liveInputRef.current?.setNativeProps(...)` is skipped by its own optional chain. The check's * error list is exactly empty for a page that never made the call. Its two siblings — - * `mobile-web-app-session-text-inputs.test.mjs` and `mobile-web-app-session-media-picker.test.mjs` + * `mobile-web-app-session-terminal-closure.test.mjs` and `mobile-web-app-session-media-picker.test.mjs` * — are source censuses over the route closure and mount nothing at all. * * So this file gives the hooks the one thing that route cannot: a mounted field. Everything else diff --git a/config/scripts/mobile-web-app-text-input-discovery.test.mjs b/config/scripts/mobile-web-app-text-input-discovery.test.mjs new file mode 100644 index 00000000000..6dc9241a898 --- /dev/null +++ b/config/scripts/mobile-web-app-text-input-discovery.test.mjs @@ -0,0 +1,22 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { expect, it } from 'vitest' +import { modulesDeclaringTextInput } from './mobile-web-app-text-input-font-size-seam.mjs' + +it('discovers input modules and ignores tests and non-JSX mentions', () => { + const scratch = mkdtempSync(join(tmpdir(), 'orca-text-input-discovery-')) + try { + mkdirSync(join(scratch, 'src/browser'), { recursive: true }) + for (const [name, source] of Object.entries({ + 'Planted.tsx': 'export const P = () => ', + 'Planted.test.tsx': 'it("x", () => )', + 'mentions-only.ts': "import { TextInput } from 'react-native' // TextInput, named twice" + })) { + writeFileSync(join(scratch, 'src/browser', name), source) + } + expect(modulesDeclaringTextInput(scratch, 'src/browser')).toEqual(['src/browser/Planted.tsx']) + } finally { + rmSync(scratch, { recursive: true, force: true }) + } +}) diff --git a/config/scripts/node-server-change-scope.mjs b/config/scripts/node-server-change-scope.mjs index 7b14c8dbbdf..9e66329e65d 100644 --- a/config/scripts/node-server-change-scope.mjs +++ b/config/scripts/node-server-change-scope.mjs @@ -18,6 +18,7 @@ const BUILD_SCRIPTS = [ 'config/scripts/build-windows-security-addon.mjs', 'config/scripts/run-node-server-tests.mjs', 'config/vitest.config.ts', + 'config/scripts/vitest-dom-setup.ts', 'config/scripts/happy-dom-offscreen-canvas.ts', 'config/scripts/happy-dom-mutation-observer-retention.ts', 'config/scripts/vitest-host-ports-setup.ts', diff --git a/config/scripts/node-server-change-scope.test.mjs b/config/scripts/node-server-change-scope.test.mjs index 156c2bcdeeb..dfaf3f2cb59 100644 --- a/config/scripts/node-server-change-scope.test.mjs +++ b/config/scripts/node-server-change-scope.test.mjs @@ -527,7 +527,7 @@ it('keeps every platform job and runs them when detection is skipped or fails', expect(workflow.on).toHaveProperty('workflow_dispatch') expect(workflow.on).toHaveProperty('workflow_call') expect(workflow.on).not.toHaveProperty('push') - expect(workflow.on).not.toHaveProperty('schedule') + expect(workflow.on.schedule).toHaveLength(1) expect(workflow.jobs.changes.if).toBe( "github.event_name == 'push' || (github.event_name == 'pull_request' && github.event.pull_request.draft != true)" ) diff --git a/config/scripts/orcad-windows-prebuild-workflow.test.mjs b/config/scripts/orcad-windows-prebuild-workflow.test.mjs index 6c39c265e50..97f54cf3bd7 100644 --- a/config/scripts/orcad-windows-prebuild-workflow.test.mjs +++ b/config/scripts/orcad-windows-prebuild-workflow.test.mjs @@ -225,6 +225,7 @@ node() { IFS= read -r verdict < out/orcad-prebuilds/manifest.json [ "$verdict" = valid ] ;; *--print-slot) echo win32-x64 ;; + *--require-slots*|*--smoke) grep -qx valid out/orcad-prebuilds/manifest.json ;; *) return 41 ;; esac } @@ -288,8 +289,8 @@ describe.skipIf(process.platform === 'win32')('Windows prebuild Bash fallback', ).toBe(validates) expect(commands.slice(-3)).toEqual([ 'node config/scripts/build-orcad-prebuilds.mjs --print-slot', - 'pnpm build:orcad-prebuilds --require-slots win32-x64', - 'pnpm build:orcad-prebuilds --smoke' + 'node config/scripts/build-orcad-prebuilds.mjs --require-slots win32-x64', + 'node config/scripts/build-orcad-prebuilds.mjs --smoke' ]) }) diff --git a/config/scripts/playwright-focused-workflow-selection.test.mjs b/config/scripts/playwright-focused-workflow-selection.test.mjs index 36ee3045e1e..12403ab4a53 100644 --- a/config/scripts/playwright-focused-workflow-selection.test.mjs +++ b/config/scripts/playwright-focused-workflow-selection.test.mjs @@ -75,29 +75,36 @@ it('focused IME and golden commands discover only their requested files with the `const { test } = require(${testPackage}); test('selected', () => {});` ) } - for (const args of commands) { - const result = await runProcess({ - program: process.execPath, - cwd: directory, - args: [ - join(dirname(require.resolve('playwright/package.json')), 'cli.js'), - 'test', - '--config', - config, - '--list', - '--reporter=json', - ...args - ], - env: { ...process.env, ORCA_BACKGROUND_LAUNCH: '1' }, - timeoutMs: 20_000 + const results = await Promise.allSettled( + commands.map(async (args) => { + const result = await runProcess({ + program: process.execPath, + cwd: directory, + args: [ + join(dirname(require.resolve('playwright/package.json')), 'cli.js'), + 'test', + '--config', + config, + '--list', + '--reporter=json', + ...args + ], + env: { ...process.env, ORCA_BACKGROUND_LAUNCH: '1' }, + timeoutMs: 20_000 + }) + expect(result.code, result.stderr).toBe(0) + const report = JSON.parse(result.stdout) + const expected = args.filter((arg) => arg.endsWith('.spec.ts')).sort() + expect( + report.suites.map((suite) => suite.file.replaceAll('\\', '/')).sort(), + JSON.stringify(args) + ).toEqual(expected) }) - expect(result.code, result.stderr).toBe(0) - const report = JSON.parse(result.stdout) - const expected = args.filter((arg) => arg.endsWith('.spec.ts')).sort() - expect( - report.suites.map((suite) => suite.file.replaceAll('\\', '/')).sort(), - JSON.stringify(args) - ).toEqual(expected) + ) + for (const result of results) { + if (result.status === 'rejected') { + throw result.reason + } } } finally { rmSync(directory, { recursive: true, force: true }) diff --git a/config/scripts/pr-auxiliary-preflight-admission.test.mjs b/config/scripts/pr-auxiliary-preflight-admission.test.mjs index d0c41ffc7c7..e52617fbca4 100644 --- a/config/scripts/pr-auxiliary-preflight-admission.test.mjs +++ b/config/scripts/pr-auxiliary-preflight-admission.test.mjs @@ -266,6 +266,7 @@ describe('Mobile CI tool preflight routing', () => { [tool, 'package.json'], [tool, 'config/vitest.config.ts'], [tool, 'config/scripts/pr-code-change-scope.mjs'], + [tool, 'config/scripts/pr-package-test-paths.mjs'], [tool, 'config/scripts/new-mobile-tool.mjs'], [tool, 'README.md'], [tool, 'mobile/vitest.config.ts'] @@ -301,6 +302,7 @@ function createMobileCiScopeRepository( for (const file of [ 'package.json', 'config/scripts/pr-code-change-scope.mjs', + 'config/scripts/pr-package-test-paths.mjs', 'config/scripts/pr-layout-oracle-route.mjs', 'config/scripts/git-pull-request-diff-base.mjs', 'config/scripts/pr-e2e-source-routing.mjs', diff --git a/config/scripts/pr-code-change-scope.mjs b/config/scripts/pr-code-change-scope.mjs index d006e10a6cb..29fb7490431 100644 --- a/config/scripts/pr-code-change-scope.mjs +++ b/config/scripts/pr-code-change-scope.mjs @@ -3,6 +3,7 @@ import { join } from 'node:path' import process from 'node:process' import { pathToFileURL } from 'node:url' import { isLayoutOracleSource } from './pr-layout-oracle-route.mjs' +import { LINUX_PACKAGE_TESTS, WINDOWS_PACKAGE_TESTS } from './pr-package-test-paths.mjs' const DOCS_ONLY_FILES = new Set([ 'README.md', @@ -50,7 +51,8 @@ const GLOBAL_FORCE_PREFIXES = [ '.github/actions/install-node-dependencies/', '.github/actions/restore-pnpm-verification/', '.github/actions/prepare-native-runtime/', - 'config/scripts/pr-code-change-scope' + 'config/scripts/pr-code-change-scope', + 'config/scripts/pr-package-test-paths' ] const GLOBAL_FORCE_FILES = new Set(['package.json', 'pnpm-lock.yaml']) @@ -334,6 +336,7 @@ const LINUX_PACKAGE_PREFIXES = [ 'config/scripts/process-failure-message.mjs', 'config/scripts/space-sharing-copy.mjs', '.github/actions/prepare-linux-package-fixture/', + '.github/actions/prepare-wayland-clipboard/', 'config/docker/cli-launch-contract/', 'config/docker/headless-pairing/', 'config/docker/headless-serve-shutdown/', @@ -343,6 +346,7 @@ const LINUX_PACKAGE_PREFIXES = [ 'config/scripts/run-daemon-shutdown-descendants-docker', 'config/scripts/static-appimage-package-contract', 'native/computer-use-linux/', + 'native/wayland-clipboard/', 'resources/linux/', 'config/scripts/run-headless-serve' ] @@ -361,85 +365,6 @@ const WINDOWS_PACKAGE_PREFIXES = [ 'tests/tools/windows-pty-native-capability-smoke/' ] -const LINUX_PACKAGE_TESTS = [ - 'src/main/browser/browser-client-page-renderer-lifecycle.electron.test.ts', - 'src/main/browser/browser-route-tcp-egress.electron.test.ts', - 'src/main/browser/browser-route-webrtc-egress.electron.test.ts', - 'src/main/browser/browser-route-h3-egress.electron.test.ts', - 'src/main/browser/browser-route-dns-prefetch.electron.test.ts', - 'src/main/persistence/profile-state/profile-state-writer-stall.electron.test.ts', - 'src/main/browser/browser-popup-compatibility.electron.test.ts', - 'src/main/browser/browser-popup-native-compatibility.electron.test.ts', - 'src/main/browser/browser-popup-background-compatibility.electron.test.ts' -] - -const WINDOWS_PACKAGE_TESTS = [ - 'src/main/zcode/zcode-plan-api-key-store-native-windows.test.ts', - ...LINUX_PACKAGE_TESTS, - 'config/scripts/rebuild-native-deps.test.mjs', - 'config/scripts/rebuild-native-deps-windows-process-tree.test.mjs', - 'config/scripts/rebuild-native-deps-node-pty.test.mjs', - 'config/scripts/nsis-process-check.test.mjs', - 'config/scripts/idle-cpu-process-sampling.test.mjs', - 'config/scripts/ensure-native-runtime-job-ownership.test.mjs', - 'config/scripts/verify-packaged-node-pty-job-ownership.test.mjs', - 'config/scripts/windows-pe-machine.test.mjs', - 'config/scripts/build-windows-cli-launcher.test.mjs', - 'config/scripts/verify-packaged-windows-agent-hook.test.mjs', - 'config/scripts/script-module-dependencies.test.mjs', - 'src/main/windows-registry-addon.test.ts', - 'src/shared/windows-security-addon.win32.test.ts', - 'src/main/providers/windows-conpty-wide-char-duplication.node-pty.test.ts', - 'src/main/providers/pty-repaint-wide-char-buffer.node-pty.test.ts', - 'src/shared/__tests__/process-host/windows-command-line.win32.test.ts', - 'src/shared/__tests__/process-host/windows-cmd-shim-resolution.test.ts', - 'src/shared/__tests__/process-host/windows-cmd-shim-resolution.win32.test.ts', - 'src/main/agent-hooks/windows-hook-payload-delivery.test.ts', - 'src/main/agent-hooks/managed-hook-stdin-lifecycle.test.ts', - 'src/main/jcode/hook-gate-script.test.ts', - 'src/main/agent-hooks/windows-direct-cmd-hook-command.test.ts', - 'src/main/agent-hooks/windows-native-hook-exe-hosts.test.ts', - 'src/main/claude/windows-native-hook-registration.test.ts', - 'src/main/agent-hooks/windows-cmd-hook-command-unicode.test.ts', - 'src/main/agent-hooks/windows-batch-hook-launcher.test.ts', - 'src/main/agent-hooks/windows-powershell-hook-launcher.test.ts', - 'src/main/claude/claude-status-line-windows.test.ts', - 'src/main/codex/windows-hook-command.test.ts', - 'src/main/codex/windows-hook-upgrade.test.ts', - 'src/main/codex/hook-service-managed-install.test.ts', - 'src/main/windows/windows-pty-job.win32.test.ts', - 'src/main/windows/windows-msys-job.win32.test.ts', - 'src/main/providers/agent-foreground-process-git-bash.win32.test.ts', - 'src/main/windows/windows-host-job.win32.test.ts', - 'src/main/windows/windows-process-tree-command-line-patch.test.ts', - 'src/main/windows/windows-process-table-native-addon.win32.test.ts', - 'src/main/daemon/daemon-pid-identity-native.win32.test.ts', - 'src/main/windows/windows-administrator-status.win32.test.ts', - 'src/main/windows/windows-administrator-restart.win32.test.ts', - 'src/main/persistence/profile-state/profile-state-access-windows-native.win32.test.ts', - 'src/main/windows-live-tree-kill.win32.test.ts', - 'src/main/wsl/wsl-runner.test.ts', - 'src/main/wsl/wsl-guest-environment.test.ts', - 'src/main/wsl/wsl-executable-path.win32.test.ts', - 'src/main/wsl/wsl-w1-w3-contract.test.ts', - 'src/shared/source-scan/source-tree-scan.test.ts', - 'src/main/cli/wsl-cli-powershell-boundary.test.ts', - 'src/main/computer/desktop-script-runtime-host.win32.test.ts', - 'src/main/cursor/hook-service.test.ts', - 'src/main/orca-profiles/profile-index-store.test.ts', - 'src/main/startup/windows-install-dir-acl-repair.win32.test.ts', - 'src/main/runtime/repo-worktree-admin-fingerprint.test.ts', - 'src/main/runtime/worktree-scan-admin-fingerprint-gate.test.ts', - 'src/shared/secure-file-fsync-flags.test.ts', - 'src/shared/secure-path-windows-acl.win32.test.ts', - 'src/main/runtime/unreadable-secret-store-preservation.win32.test.ts', - 'src/main/ipc/pty-codex-account-attribution.test.ts', - 'src/main/ipc/pty-spawn-env-codex-resume-provenance.test.ts', - 'src/main/ipc/preflight-provider-command-selection.test.ts', - 'src/main/ipc/preflight-runnable-local-cli.test.ts', - 'src/main/ssh/remote-node-runtime-store-windows.test.ts' -] - const DESKTOP_IRRELEVANT_PREFIXES = [ 'mobile/', 'cloud/', diff --git a/config/scripts/pr-code-change-scope.test.mjs b/config/scripts/pr-code-change-scope.test.mjs index 9a9d30852e2..6565e12977b 100644 --- a/config/scripts/pr-code-change-scope.test.mjs +++ b/config/scripts/pr-code-change-scope.test.mjs @@ -204,10 +204,9 @@ describe('per-job path classification', () => { }) it('runs native package jobs only for the platform that ships the changed native', () => { - expectClassification(['native/windows-cli-launcher/src/main.rs'], { - package_windows: true - }) - expectClassification(['native/computer-use-linux/runtime.py'], { + expectClassification(['native/windows-cli-launcher/src/main.rs'], { package_windows: true }) + expectClassification(['native/computer-use-linux/runtime.py'], { package: true }) + expectClassification(['native/wayland-clipboard/wayland-clipboard.c'], { package: true }) expectClassification(['native/computer-use-macos/Package.swift'], {}) @@ -220,6 +219,7 @@ describe('per-job path classification', () => { 'config/scripts/process-failure-message.mjs', 'config/scripts/space-sharing-copy.mjs', '.github/actions/prepare-linux-package-fixture/action.yml', + '.github/actions/prepare-wayland-clipboard/action.yml', 'config/docker/cli-launch-contract/Dockerfile', 'config/docker/cli-launch-contract/run-cli-case.sh', 'config/docker/headless-pairing/Dockerfile', diff --git a/config/scripts/pr-e2e-gate-contract.test.mjs b/config/scripts/pr-e2e-gate-contract.test.mjs index 46c5cff3629..88d70856dd8 100644 --- a/config/scripts/pr-e2e-gate-contract.test.mjs +++ b/config/scripts/pr-e2e-gate-contract.test.mjs @@ -210,8 +210,8 @@ describe('PR E2E gate contract', () => { 'src/renderer/src/lib/pane-manager/terminal-ime-anchor.ts', 'src/shared/terminal-unicode-provider.ts', // The xterm fork owns the helper textarea the IME attaches to; no file here says "ime". - 'config/patches/@xterm__xterm@6.1.0-beta.287.patch', - 'config/patches/xterm-src/browser/Terminal.ts', + 'config/patches/@xterm__xterm@6.1.0-beta.304.patch', + 'config/patches/xterm-src/@xterm__xterm@6.1.0-beta.304.src.patch', // The harness is source too: breaking the runner or a probe is how the lane goes blind. 'config/scripts/run-terminal-ibus-hangul-e2e.mjs', 'config/scripts/terminal-ime-engagement-receipt.mjs', @@ -237,6 +237,36 @@ describe('PR E2E gate contract', () => { } }) + it('routes only core xterm patches to both IME lanes across version updates', () => { + for (const file of [ + 'config/patches/@xterm__xterm@6.1.0-beta.304.patch', + 'config/patches/xterm-src/@xterm__xterm@6.1.0-beta.304.src.patch', + 'config/patches/@xterm__xterm@7.0.0.patch', + 'config/patches/xterm-src/@xterm__xterm@7.0.0.src.patch' + ]) { + const specs = selectPrE2eSpecs([file]) + expect(hasNativeImeSourceChange([file]), file).toBe(true) + expect(specs, file).toContain('tests/e2e/terminal-ime-exact-byte.spec.ts') + expect(specs, file).toContain('tests/e2e/terminal-ibus-hangul-native.spec.ts') + } + for (const file of [ + 'config/patches/i18next-cli@1.74.2.patch', + 'config/patches/node-pty@1.1.0.patch', + 'config/patches/monaco-editor@0.55.1.patch', + 'config/patches/@xterm__addon-webgl@0.20.0-beta.300.patch', + 'config/patches/xterm-src/@xterm__addon-search@0.17.0-beta.301.src.patch', + 'config/patches/xterm-upstream.json', + 'config/patches/@xterm__xterm@7.0.0.patch.bak' + ]) { + expect(hasNativeImeSourceChange([file]), file).toBe(false) + expect(selectPrE2eSpecs([file]), file).toEqual([]) + } + const corePatch = 'config/patches/@xterm__xterm@6.1.0-beta.304.patch' + const mixedChanges = ['config/patches/i18next-cli@1.74.2.patch', corePatch] + expect(hasNativeImeSourceChange(mixedChanges)).toBe(true) + expect(selectPrE2eSpecs(mixedChanges)).toEqual(selectPrE2eSpecs([corePatch])) + }) + it('keeps the native IME spec out of the lane that would silently skip it', () => { expect(DEDICATED_E2E_SPECS).toContain('tests/e2e/terminal-ibus-hangul-native.spec.ts') // Why it still has to be routed: the dedicated lane is selected by the same route, so the diff --git a/config/scripts/pr-e2e-source-routing.mjs b/config/scripts/pr-e2e-source-routing.mjs index 530abb1da06..24fed423c7a 100644 --- a/config/scripts/pr-e2e-source-routing.mjs +++ b/config/scripts/pr-e2e-source-routing.mjs @@ -9,10 +9,12 @@ import { LAYOUT_ORACLE_E2E_SPECS } from './pr-layout-oracle-route.mjs' const isProductSource = (file) => !/\.test\.tsx?$/.test(file) && !isUnitTestSupportSource(file) -// Why config/patches: the xterm fork owns the helper textarea an input method attaches to, so a -// patch edit can break composition without touching a file named "ime". +// The core xterm patch owns the helper textarea an input method attaches to. +const XTERM_CORE_PATCH = + /^config\/patches\/(?:@xterm__xterm@[^/]+\.patch|xterm-src\/@xterm__xterm@[^/]+\.src\.patch)$/ + const NATIVE_IME_PRODUCT_SOURCE = - /^(?:config\/patches\/|src\/shared\/terminal-unicode-provider\.ts$|src\/renderer\/src\/lib\/pane-manager\/terminal-ime-|src\/renderer\/src\/components\/terminal-pane\/(?:terminal-ime-|terminal-ios-hangul-|xterm-bypass-policy))/ + /^(?:src\/shared\/terminal-unicode-provider\.ts$|src\/renderer\/src\/lib\/pane-manager\/terminal-ime-|src\/renderer\/src\/components\/terminal-pane\/(?:terminal-ime-|terminal-ios-hangul-|xterm-bypass-policy))/ /** The harness itself: the session runner, the boundary probes, and the native specs. */ const NATIVE_IME_HARNESS = @@ -255,9 +257,10 @@ export const PR_E2E_SOURCE_ROUTES = [ ], matches: (file) => isProductSource(file) && - /^(?:config\/patches\/|src\/renderer\/src\/components\/terminal-pane\/(?:terminal-ime-|use-terminal-pane-lifecycle|xterm-bypass-policy|terminal-option-shortcut-policy))/.test( - file - ) + (XTERM_CORE_PATCH.test(file) || + /^src\/renderer\/src\/components\/terminal-pane\/(?:terminal-ime-|use-terminal-pane-lifecycle|xterm-bypass-policy|terminal-option-shortcut-policy)/.test( + file + )) }, { // Why a route beside terminal-input.ime-and-synthetic-forwarding rather than more specs on @@ -268,7 +271,8 @@ export const PR_E2E_SOURCE_ROUTES = [ id: 'terminal-ime.native-input-method', specs: ['tests/e2e/terminal-ibus-hangul-native.spec.ts'], matches: (file) => - (isProductSource(file) && NATIVE_IME_PRODUCT_SOURCE.test(file)) || + (isProductSource(file) && + (XTERM_CORE_PATCH.test(file) || NATIVE_IME_PRODUCT_SOURCE.test(file))) || NATIVE_IME_HARNESS.test(file) }, { diff --git a/config/scripts/pr-package-test-paths.mjs b/config/scripts/pr-package-test-paths.mjs new file mode 100644 index 00000000000..e19e1be0d51 --- /dev/null +++ b/config/scripts/pr-package-test-paths.mjs @@ -0,0 +1,78 @@ +export const LINUX_PACKAGE_TESTS = [ + 'src/main/browser/browser-client-page-renderer-lifecycle.electron.test.ts', + 'src/main/browser/browser-route-tcp-egress.electron.test.ts', + 'src/main/browser/browser-route-webrtc-egress.electron.test.ts', + 'src/main/browser/browser-route-h3-egress.electron.test.ts', + 'src/main/browser/browser-route-dns-prefetch.electron.test.ts', + 'src/main/persistence/profile-state/profile-state-writer-stall.electron.test.ts', + 'src/main/browser/browser-popup-compatibility.electron.test.ts', + 'src/main/browser/browser-popup-native-compatibility.electron.test.ts', + 'src/main/browser/browser-popup-background-compatibility.electron.test.ts' +] + +export const WINDOWS_PACKAGE_TESTS = [ + 'src/main/zcode/zcode-plan-api-key-store-native-windows.test.ts', + ...LINUX_PACKAGE_TESTS, + 'config/scripts/rebuild-native-deps.test.mjs', + 'config/scripts/rebuild-native-deps-windows-process-tree.test.mjs', + 'config/scripts/rebuild-native-deps-node-pty.test.mjs', + 'config/scripts/nsis-process-check.test.mjs', + 'config/scripts/idle-cpu-process-sampling.test.mjs', + 'config/scripts/ensure-native-runtime-job-ownership.test.mjs', + 'config/scripts/verify-packaged-node-pty-job-ownership.test.mjs', + 'config/scripts/windows-pe-machine.test.mjs', + 'config/scripts/build-windows-cli-launcher.test.mjs', + 'config/scripts/verify-packaged-windows-agent-hook.test.mjs', + 'config/scripts/script-module-dependencies.test.mjs', + 'src/main/windows-registry-addon.test.ts', + 'src/shared/windows-security-addon.win32.test.ts', + 'src/main/providers/windows-conpty-wide-char-duplication.node-pty.test.ts', + 'src/main/providers/pty-repaint-wide-char-buffer.node-pty.test.ts', + 'src/shared/__tests__/process-host/windows-command-line.win32.test.ts', + 'src/shared/__tests__/process-host/windows-cmd-shim-resolution.test.ts', + 'src/shared/__tests__/process-host/windows-cmd-shim-resolution.win32.test.ts', + 'src/main/agent-hooks/windows-hook-payload-delivery.test.ts', + 'src/main/agent-hooks/managed-hook-stdin-lifecycle.test.ts', + 'src/main/jcode/hook-gate-script.test.ts', + 'src/main/agent-hooks/windows-direct-cmd-hook-command.test.ts', + 'src/main/agent-hooks/windows-native-hook-exe-hosts.test.ts', + 'src/main/claude/windows-native-hook-registration.test.ts', + 'src/main/agent-hooks/windows-cmd-hook-command-unicode.test.ts', + 'src/main/agent-hooks/windows-batch-hook-launcher.test.ts', + 'src/main/agent-hooks/windows-powershell-hook-launcher.test.ts', + 'src/main/claude/claude-status-line-windows.test.ts', + 'src/main/codex/windows-hook-command.test.ts', + 'src/main/codex/windows-hook-upgrade.test.ts', + 'src/main/codex/hook-service-managed-install.test.ts', + 'src/main/windows/windows-pty-job.win32.test.ts', + 'src/main/windows/windows-msys-job.win32.test.ts', + 'src/main/providers/agent-foreground-process-git-bash.win32.test.ts', + 'src/main/windows/windows-host-job.win32.test.ts', + 'src/main/windows/windows-process-tree-command-line-patch.test.ts', + 'src/main/windows/windows-process-table-native-addon.win32.test.ts', + 'src/main/daemon/daemon-pid-identity-native.win32.test.ts', + 'src/main/windows/windows-administrator-status.win32.test.ts', + 'src/main/windows/windows-administrator-restart.win32.test.ts', + 'src/main/persistence/profile-state/profile-state-access-windows-native.win32.test.ts', + 'src/main/windows-live-tree-kill.win32.test.ts', + 'src/main/wsl/wsl-runner.test.ts', + 'src/main/wsl/wsl-guest-environment.test.ts', + 'src/main/wsl/wsl-executable-path.win32.test.ts', + 'src/main/wsl/wsl-w1-w3-contract.test.ts', + 'src/shared/source-scan/source-tree-scan.test.ts', + 'src/main/cli/wsl-cli-powershell-boundary.test.ts', + 'src/main/computer/desktop-script-runtime-host.win32.test.ts', + 'src/main/cursor/hook-service.test.ts', + 'src/main/orca-profiles/profile-index-store.test.ts', + 'src/main/startup/windows-install-dir-acl-repair.win32.test.ts', + 'src/main/runtime/repo-worktree-admin-fingerprint.test.ts', + 'src/main/runtime/worktree-scan-admin-fingerprint-gate.test.ts', + 'src/shared/secure-file-fsync-flags.test.ts', + 'src/shared/secure-path-windows-acl.win32.test.ts', + 'src/main/runtime/unreadable-secret-store-preservation.win32.test.ts', + 'src/main/ipc/pty-codex-account-attribution.test.ts', + 'src/main/ipc/pty-spawn-env-codex-resume-provenance.test.ts', + 'src/main/ipc/preflight-provider-command-selection.test.ts', + 'src/main/ipc/preflight-runnable-local-cli.test.ts', + 'src/main/ssh/remote-node-runtime-store-windows.test.ts' +] diff --git a/config/scripts/process-host-build-dist.test.mjs b/config/scripts/process-host-build-dist.test.mjs index c79b912606d..b4b560b37d5 100644 --- a/config/scripts/process-host-build-dist.test.mjs +++ b/config/scripts/process-host-build-dist.test.mjs @@ -82,6 +82,26 @@ function runBuild(packageDir, { script = buildScript, cwd = realPackageDir } = { // Emits replacement output, then takes over or deletes the build lock as a competing build would. function installLockStealingCompiler(packageDir, { recreateLock }) { + const compromised = join(packageDir, 'lock-compromised') + const script = join(packageDir, 'observe-lock-loss.mjs') + writeFileSync( + script, + `import { writeFileSync } from 'node:fs' +import lockfile from ${JSON.stringify(pathToFileURL(packageRequire.resolve('proper-lockfile')).href)} +const lock = lockfile.lock +lockfile.lock = (file, options) => lock(file, { + ...options, + update: 1000, + onCompromised(error) { + options.onCompromised(error) + writeFileSync(${JSON.stringify(compromised)}, '') + } +}) +const { buildPackageDist } = await import(${JSON.stringify(pathToFileURL(buildScript).href)}) +try { await buildPackageDist(process.argv[2]) } +catch (error) { console.error(error.message); process.exitCode = 1 } +` + ) const compilerDir = join(packageDir, 'node_modules', 'typescript') rmSync(compilerDir, { recursive: true }) mkdirSync(join(compilerDir, 'bin'), { recursive: true }) @@ -91,7 +111,7 @@ function installLockStealingCompiler(packageDir, { recreateLock }) { ) writeFileSync( join(compilerDir, 'bin', 'tsc'), - `const { mkdirSync, rmSync, utimesSync, writeFileSync } = require('node:fs') + `const { existsSync, mkdirSync, rmSync, utimesSync, writeFileSync } = require('node:fs') const { join } = require('node:path') const outDir = process.argv[process.argv.indexOf('--outDir') + 1] mkdirSync(outDir, { recursive: true }) @@ -99,10 +119,14 @@ writeFileSync(join(outDir, 'entry.js'), 'module.exports = "from a build that los writeFileSync(join(outDir, 'added.js'), '') rmSync('.dist-build.lock', { recursive: true, force: true }) ${recreateLock ? "mkdirSync('.dist-build.lock')\nconst foreignMtime = new Date(Date.now() + 60_000)\nutimesSync('.dist-build.lock', foreignMtime, foreignMtime)" : ''} -// Hold compilation past proper-lockfile's 15-second refresh so compromise is observed. -setTimeout(() => {}, 17_000) +// Wait for the real lock monitor's verdict before compilation completes. +const wait = setInterval(() => { + if (existsSync(${JSON.stringify(compromised)})) clearInterval(wait) +}, 10) +setTimeout(() => process.exit(2), 20_000).unref() ` ) + return script } async function build(packageDir, expectResult = expect) { @@ -302,9 +326,9 @@ while (!existsSync(${JSON.stringify(finished)}) || reads === 0) { const packageDir = fixture(onTestFinished, seededDist) const before = distSnapshot(packageDir) const entry = readFileSync(join(packageDir, 'dist', 'entry.js'), 'utf8') - installLockStealingCompiler(packageDir, { recreateLock }) + const script = installLockStealingCompiler(packageDir, { recreateLock }) - const result = await runBuild(packageDir) + const result = await runBuild(packageDir, { script }) expectResult(result.code).toBe(1) expectResult(result.stderr).toContain('build lock was compromised') diff --git a/config/scripts/shebang-script-line-ending-pin.test.mjs b/config/scripts/shebang-script-line-ending-pin.test.mjs deleted file mode 100644 index 075eea6456d..00000000000 --- a/config/scripts/shebang-script-line-ending-pin.test.mjs +++ /dev/null @@ -1,70 +0,0 @@ -import { execFileSync } from 'node:child_process' -import { existsSync, readFileSync } from 'node:fs' -import { join, resolve } from 'node:path' -import { describe, expect, it } from 'vitest' - -/** - * Guard the `.gitattributes` pin that keeps `config/scripts` scripts on LF. - * - * `core.autocrlf=true` ships in the Git-for-Windows system config, so without a - * pin a Windows checkout gets CRLF. Vite's SSR transform locates the shebang - * with `/^#!.*\n/` — `\r` is a JS regex line terminator, so `.` never matches it - * and the pattern misses on CRLF. The hoisted import/export preamble then lands - * at offset 0, ahead of the shebang, which in turn defeats the `code[0] === '#'` - * guard that blanks it. A literal `#!` survives into the middle of the module and - * every suite importing the script dies at load with a SyntaxError. - * - * Scoped to `config/scripts` because that is where tests import scripts. Other - * shebanged `.mjs` in the tree are spawned, not imported, so they cannot hit this. - */ -const projectDir = resolve(import.meta.dirname, '../..') -const SCRIPT_DIRECTORY = 'config/scripts' - -function git(args) { - return execFileSync('git', args, { cwd: projectDir, encoding: 'utf8' }) -} - -/** `git check-attr -z` emits NUL-separated path/attr/value triples. */ -function eolAttributes(paths) { - const fields = git(['check-attr', '-z', 'eol', '--', ...paths]).split('\0') - const found = new Map() - for (let index = 0; index + 2 < fields.length; index += 3) { - found.set(fields[index], fields[index + 2]) - } - return found -} - -function shebangScripts() { - return git(['ls-files', '-z', '--', `${SCRIPT_DIRECTORY}/*.mjs`]) - .split('\0') - .filter((path) => path && existsSync(join(projectDir, path))) - .filter((path) => readFileSync(join(projectDir, path), 'utf8').startsWith('#!')) -} - -describe('config/scripts line-ending pin', () => { - it('pins every shebanged script to LF', () => { - const scripts = shebangScripts() - expect(scripts.length).toBeGreaterThan(0) - - const attributes = eolAttributes(scripts) - const unpinned = scripts.filter((path) => attributes.get(path) !== 'lf') - - expect( - unpinned, - 'A shebanged script left on the platform default gets CRLF on Windows, ' + - 'which makes every suite importing it fail to load. Pin it in .gitattributes.' - ).toEqual([]) - }) - - // Why: without these the assertion above still passes against a pattern so broad - // it says nothing, or so narrow it only covers the files that exist today. - it.each([ - ['config/scripts/example.mjs', 'lf'], - ['config/scripts/nested/deeper/example.mjs', 'lf'], - ['config/scripts-extra/example.mjs', 'unspecified'], - ['vendor/config/scripts/example.mjs', 'unspecified'], - ['config/scripts/example.mjsx', 'unspecified'] - ])('resolves %s to eol=%s', (path, expected) => { - expect(eolAttributes([path]).get(path)).toBe(expected) - }) -}) diff --git a/config/scripts/vitest-dom-setup.ts b/config/scripts/vitest-dom-setup.ts new file mode 100644 index 00000000000..738a876a21a --- /dev/null +++ b/config/scripts/vitest-dom-setup.ts @@ -0,0 +1,7 @@ +export {} + +// DOM compatibility patches are only needed after Vitest installs a DOM environment. +if (globalThis.document !== undefined) { + await import('./happy-dom-offscreen-canvas') + await import('./happy-dom-mutation-observer-retention') +} diff --git a/config/scripts/vitest-host-ports-setup.ts b/config/scripts/vitest-host-ports-setup.ts index 980775ec3f1..ab17cac58b4 100644 --- a/config/scripts/vitest-host-ports-setup.ts +++ b/config/scripts/vitest-host-ports-setup.ts @@ -15,14 +15,17 @@ const { mkdtempSync, rmSync } = process.getBuiltinModule('fs') * that asserts on host behaviour installs its own and wins, because this runs first. */ -// One directory per test environment, not per test: suites that write a file in one -// step and read it back in the next would otherwise lose it between them. Removed on -// teardown — vitest builds one environment per test file, so without this a full run -// leaves thousands of directories behind. -const userData = mkdtempSync(join(tmpdir(), 'orca-vitest-userdata-')) +// Most suites never read a host data path; create its per-file directory only when needed. +let userData: string | undefined + +function userDataPath(): string { + return (userData ??= mkdtempSync(join(tmpdir(), 'orca-vitest-userdata-'))) +} afterAll(() => { - rmSync(userData, { recursive: true, force: true }) + if (userData) { + rmSync(userData, { recursive: true, force: true }) + } }) // Deliberately not a plaintext passthrough: encryptString must return something a @@ -37,7 +40,7 @@ const SEAL_PREFIX = 'vitest-sealed:' */ export function fakeAppEnvironment(overrides: Partial = {}): AppEnvironment { return { - getPath: (name) => (name === 'home' ? homedir() : name === 'temp' ? tmpdir() : userData), + getPath: (name) => (name === 'home' ? homedir() : name === 'temp' ? tmpdir() : userDataPath()), getAppPath: () => process.cwd(), getVersion: () => '0.0.0-test', isPackaged: () => false, diff --git a/config/scripts/workflow-ref-mirror-case-safety.test.mjs b/config/scripts/workflow-ref-mirror-case-safety.test.mjs index a8333d76947..b9d9c752974 100644 --- a/config/scripts/workflow-ref-mirror-case-safety.test.mjs +++ b/config/scripts/workflow-ref-mirror-case-safety.test.mjs @@ -10,13 +10,6 @@ const projectDir = resolve(import.meta.dirname, '../..') const readWorkflow = (relativePath) => parse(readFileSync(join(projectDir, relativePath), 'utf8')) -// Every step that mirrors this repo's whole ref namespace onto a runner disk to -// prove a commit is reachable from a branch or tag before signing it. -const REF_MIRRORS = [ - ['.github/workflows/adhoc-mac-build.yml', 'build-adhoc-mac', 'Vet the requested ref'], - ['.github/workflows/dev-channel-win-build.yml', 'build-win', 'Vet the requested inputs'] -] - describe('ref-mirroring vet steps', () => { it.each(['daily', 'hourly', 'adhoc'])('%s builds only need the current commit', (channel) => { const job = readWorkflow(`.github/workflows/${channel}-mac-build.yml`).jobs[ @@ -98,30 +91,4 @@ describe('ref-mirroring vet steps', () => { rmSync(directory, { recursive: true, force: true }) } }) - - it('checks out only the vetted commit without remirroring refs', () => { - const steps = readWorkflow('.github/workflows/adhoc-mac-build.yml').jobs['build-adhoc-mac'] - .steps - const checkout = steps.find((step) => step.name === 'Checkout the requested ref') - expect(checkout.with.ref).toBe('${{ steps.vetted.outputs.sha }}') - expect(checkout.with['fetch-depth']).toBe(1) - expect(checkout.with['persist-credentials']).toBe(false) - }) - - // Why: macOS and Windows runner disks are case-insensitive, and this repo has - // branches that differ only in casing. The files backend cannot store both, and - // it fails the whole fetch rather than the one ref — so the vet step dies before - // any build runs. reftable keys refs in a table instead of file paths. - it.each(REF_MIRRORS)( - '%s creates its scratch repo with the reftable backend', - (path, job, step) => { - const run = readWorkflow(path).jobs[job].steps.find( - (candidate) => candidate.name === step - ).run - - expect(run).toContain('+refs/heads/*:refs/heads/*') - expect(run).toMatch(/git init\b[^\n]*--ref-format=reftable/) - expect(run).not.toMatch(/git init -q --bare "\$scratch"/) - } - ) }) diff --git a/config/scripts/workflow-ref-reachability.test.mjs b/config/scripts/workflow-ref-reachability.test.mjs index 81a8168f1d4..7c508e5b9f6 100644 --- a/config/scripts/workflow-ref-reachability.test.mjs +++ b/config/scripts/workflow-ref-reachability.test.mjs @@ -96,6 +96,8 @@ describe('release ref trust with case-twin names', () => { }) it('checks out the vetted SHA shallowly without mirroring case-twin refs again', async () => { + expect(macCheckout.with.ref).toBe('${{ steps.vetted.outputs.sha }}') + expect(macCheckout.with['persist-credentials']).toBe(false) const checkout = join(directory, 'checkout') const env = { ...identity, ...macCheckout.env } await git(['init', checkout], env) diff --git a/config/tsconfig.node.json b/config/tsconfig.node.json index 66d9b7fe541..fafa7267bc5 100644 --- a/config/tsconfig.node.json +++ b/config/tsconfig.node.json @@ -8,6 +8,9 @@ "./scripts/vitest-caller-identity-env-setup.ts", "./scripts/vitest-real-agent-home-write-guard.ts", "./scripts/vitest-bun-node-builtins.ts", + "./scripts/vitest-dom-setup.ts", + "./scripts/happy-dom-offscreen-canvas.ts", + "./scripts/happy-dom-mutation-observer-retention.ts", "./scripts/vitest-node-runtime-pool.ts", "../src/main/**/*", "../src/renderer/src/lib/skill-freshness-display-status.ts", diff --git a/config/vitest.config.ts b/config/vitest.config.ts index c4a1b27fbd5..7c0ac6e84fe 100644 --- a/config/vitest.config.ts +++ b/config/vitest.config.ts @@ -37,8 +37,7 @@ const testOptions = { setupFiles: [ resolve('config/scripts/vitest-real-agent-home-write-guard.ts'), resolve('config/scripts/vitest-bun-node-builtins.ts'), - resolve('config/scripts/happy-dom-offscreen-canvas.ts'), - resolve('config/scripts/happy-dom-mutation-observer-retention.ts'), + resolve('config/scripts/vitest-dom-setup.ts'), resolve('config/scripts/vitest-host-ports-setup.ts'), resolve('config/scripts/vitest-caller-identity-env-setup.ts') ], diff --git a/mobile/src/session/mobile-native-chat-merged-snapshot-parity.test.ts b/mobile/src/session/mobile-native-chat-merged-snapshot-parity.test.ts index 89b22ac3461..985fd2da711 100644 --- a/mobile/src/session/mobile-native-chat-merged-snapshot-parity.test.ts +++ b/mobile/src/session/mobile-native-chat-merged-snapshot-parity.test.ts @@ -209,7 +209,7 @@ const range = (from: number, to: number): number[] => describe.each(['after-end', 'during-turn'] as const)( 'a short chat, the send while stopping written %s: merged frames draw as a fresh snapshot does', (sendWhileStopping) => { - const journal = stopJournal(8, sendWhileStopping) + const journal = stopJournal(2, sendWhileStopping) it('draws the send the exit took back with its stop row, from the frame that took it back', () => { const fresh = drawn( @@ -221,7 +221,7 @@ describe.each(['after-end', 'during-turn'] as const)( ]) }) - // Exhaustive frame enumeration shares CI CPUs with the typechecks. + // Both history turns revise status; one completes and the other is interrupted. it.each([1, 2, 3])( 'from every subscribe point, %i row(s) per frame', (rowsPerFrame) => { diff --git a/mobile/src/test-support/rpc-recording/golden-value-pool.ts b/mobile/src/test-support/rpc-recording/golden-value-pool.ts index 03a8fa04c23..f869e3fca8a 100644 --- a/mobile/src/test-support/rpc-recording/golden-value-pool.ts +++ b/mobile/src/test-support/rpc-recording/golden-value-pool.ts @@ -89,9 +89,12 @@ export function internRecording(recording: Recording): { } { const pool: ValuePool = {} const canonical = new Map() + // Checkpoints repeat their append-only histories; hash each distinct entry once. + const hashes = new Map() const intern = (entry: RecordedValue, at: string): string => { const json = canonicalJson(entry) - const hash = canonicalJsonHash(json) + const hash = hashes.get(json) ?? canonicalJsonHash(json) + hashes.set(json, hash) const seen = canonical.get(hash) if (seen !== undefined && seen !== json) { throw new Error(`Golden value hash collision at ${hash} (${at})`) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6c814a86101..7bbcec05d80 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -173,7 +173,7 @@ patchedDependencies: '@xterm/addon-serialize@0.15.0-beta.301': b35533fe252e7e45433150170348889f4e08a6c17f7017ac34ea694d831fec7f '@xterm/addon-webgl@0.20.0-beta.300': 5349718f2c19d9319a81552580bf9618ded9b00f3707a5c6cdbbae1910849cd9 '@xterm/xterm@6.1.0-beta.304': 9e1c200b2332745ef9e01e64696e9cda6f629937c37a0ee652323f25ccc8df5c - i18next-cli@1.74.2: 7955b89d3aa229f477408608d331f78c85148a85a85913729f89fac65ad8b207 + i18next-cli@1.74.2: 2ee32eef742615cd133e1a6940f027deb3723a8430bb7953bba3a6769b0aac47 lint-staged@16.4.0: 7333b3837f80a7fbd045964db6d76ba4fc118e49134bdbabb00585b6b7b60673 monaco-editor@0.55.1: 53b71f7d8a7c00b76ffdb73ce83d18e31692f193f5fafa6a390e48f5b3fdd636 node-pty@1.1.0: c2fe19cde7aef223738a7503ae429dd9b03432130b064aa64a601900b33c151f @@ -484,7 +484,7 @@ importers: version: 9.1.7 i18next-cli: specifier: 1.74.2 - version: 1.74.2(patch_hash=7955b89d3aa229f477408608d331f78c85148a85a85913729f89fac65ad8b207)(@types/node@25.9.5)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@7.0.2) + version: 1.74.2(patch_hash=2ee32eef742615cd133e1a6940f027deb3723a8430bb7953bba3a6769b0aac47)(@types/node@25.9.5)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@7.0.2) katex: specifier: ^0.16.47 version: 0.16.47 @@ -12939,7 +12939,7 @@ snapshots: husky@9.1.7: {} - i18next-cli@1.74.2(patch_hash=7955b89d3aa229f477408608d331f78c85148a85a85913729f89fac65ad8b207)(@types/node@25.9.5)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@7.0.2): + i18next-cli@1.74.2(patch_hash=2ee32eef742615cd133e1a6940f027deb3723a8430bb7953bba3a6769b0aac47)(@types/node@25.9.5)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@7.0.2): dependencies: '@croct/json5-parser': 0.2.2 '@swc/core': 1.16.2 diff --git a/src/cli/browser-cookie-credentials-empty-value.test.ts b/src/cli/browser-cookie-credentials-empty-value.test.ts deleted file mode 100644 index 727348b9c06..00000000000 --- a/src/cli/browser-cookie-credentials-empty-value.test.ts +++ /dev/null @@ -1,117 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' - -const callMock = vi.fn() - -vi.mock('./runtime-client', () => { - class RuntimeClient { - call = callMock - getCliStatus = vi.fn() - openOrca = vi.fn() - } - - class RuntimeClientError extends Error { - readonly code: string - - constructor(code: string, message: string) { - super(message) - this.code = code - } - } - - class RuntimeRpcFailureError extends RuntimeClientError { - readonly response: unknown - - constructor(response: unknown) { - super('runtime_error', 'runtime_error') - this.response = response - } - } - - return { - RuntimeClient, - RuntimeClientError, - RuntimeRpcFailureError - } -}) - -import { main } from './index' -import { okFixture, queueFixtures } from './test-fixtures' - -// Why: CookieSet.value and SetCredentials.pass accept any string, empty included, -// while name and user require a non-empty one. -describe('orca cli cookie set and set credentials preserve an empty value', () => { - beforeEach(() => { - callMock.mockReset() - process.exitCode = undefined - }) - - afterEach(() => { - vi.restoreAllMocks() - }) - - it('passes an empty --value through to browser.cookie.set', async () => { - queueFixtures(callMock, okFixture('req_cookie_set', { success: true })) - vi.spyOn(console, 'log').mockImplementation(() => {}) - - await main( - ['cookie', 'set', '--name', 'sid', '--value', '', '--worktree', 'all', '--json'], - '/tmp/not-an-orca-worktree' - ) - - expect(callMock).toHaveBeenCalledTimes(1) - expect(callMock).toHaveBeenCalledWith('browser.cookie.set', { - name: 'sid', - value: '', - worktree: undefined - }) - }) - - it('passes an empty --pass through to browser.setCredentials', async () => { - queueFixtures(callMock, okFixture('req_set_credentials', { success: true })) - vi.spyOn(console, 'log').mockImplementation(() => {}) - - await main( - ['set', 'credentials', '--user', 'token', '--pass', '', '--worktree', 'all', '--json'], - '/tmp/not-an-orca-worktree' - ) - - expect(callMock).toHaveBeenCalledTimes(1) - expect(callMock).toHaveBeenCalledWith('browser.setCredentials', { - user: 'token', - pass: '', - worktree: undefined - }) - }) - - it('still rejects an empty --name before RPC dispatch', async () => { - const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) - const priorExitCode = process.exitCode - - await main( - ['cookie', 'set', '--name', '', '--value', 'x', '--worktree', 'all'], - '/tmp/not-an-orca-worktree' - ) - - expect(callMock).not.toHaveBeenCalled() - expect(errorSpy.mock.calls.flat().join('\n')).toContain('Missing required --name') - expect(process.exitCode).toBe(1) - - process.exitCode = priorExitCode - }) - - it('still rejects a missing --pass before RPC dispatch', async () => { - const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) - const priorExitCode = process.exitCode - - await main( - ['set', 'credentials', '--user', 'token', '--worktree', 'all'], - '/tmp/not-an-orca-worktree' - ) - - expect(callMock).not.toHaveBeenCalled() - expect(errorSpy.mock.calls.flat().join('\n')).toContain('Missing required --pass') - expect(process.exitCode).toBe(1) - - process.exitCode = priorExitCode - }) -}) diff --git a/src/cli/browser-storage-empty-value.test.ts b/src/cli/browser-empty-values.test.ts similarity index 61% rename from src/cli/browser-storage-empty-value.test.ts rename to src/cli/browser-empty-values.test.ts index b93145e93e4..23b110d46cd 100644 --- a/src/cli/browser-storage-empty-value.test.ts +++ b/src/cli/browser-empty-values.test.ts @@ -132,3 +132,82 @@ describe('orca cli storage set preserves an empty value', () => { process.exitCode = priorExitCode }) }) + +// Why: CookieSet.value and SetCredentials.pass accept any string, empty included, +// while name and user require a non-empty one. +describe('orca cli cookie set and set credentials preserve an empty value', () => { + beforeEach(() => { + callMock.mockReset() + process.exitCode = undefined + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it('passes an empty --value through to browser.cookie.set', async () => { + queueFixtures(callMock, okFixture('req_cookie_set', { success: true })) + vi.spyOn(console, 'log').mockImplementation(() => {}) + + await main( + ['cookie', 'set', '--name', 'sid', '--value', '', '--worktree', 'all', '--json'], + '/tmp/not-an-orca-worktree' + ) + + expect(callMock).toHaveBeenCalledTimes(1) + expect(callMock).toHaveBeenCalledWith('browser.cookie.set', { + name: 'sid', + value: '', + worktree: undefined + }) + }) + + it('passes an empty --pass through to browser.setCredentials', async () => { + queueFixtures(callMock, okFixture('req_set_credentials', { success: true })) + vi.spyOn(console, 'log').mockImplementation(() => {}) + + await main( + ['set', 'credentials', '--user', 'token', '--pass', '', '--worktree', 'all', '--json'], + '/tmp/not-an-orca-worktree' + ) + + expect(callMock).toHaveBeenCalledTimes(1) + expect(callMock).toHaveBeenCalledWith('browser.setCredentials', { + user: 'token', + pass: '', + worktree: undefined + }) + }) + + it('still rejects an empty --name before RPC dispatch', async () => { + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + const priorExitCode = process.exitCode + + await main( + ['cookie', 'set', '--name', '', '--value', 'x', '--worktree', 'all'], + '/tmp/not-an-orca-worktree' + ) + + expect(callMock).not.toHaveBeenCalled() + expect(errorSpy.mock.calls.flat().join('\n')).toContain('Missing required --name') + expect(process.exitCode).toBe(1) + + process.exitCode = priorExitCode + }) + + it('still rejects a missing --pass before RPC dispatch', async () => { + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + const priorExitCode = process.exitCode + + await main( + ['set', 'credentials', '--user', 'token', '--worktree', 'all'], + '/tmp/not-an-orca-worktree' + ) + + expect(callMock).not.toHaveBeenCalled() + expect(errorSpy.mock.calls.flat().join('\n')).toContain('Missing required --pass') + expect(process.exitCode).toBe(1) + + process.exitCode = priorExitCode + }) +}) diff --git a/src/cli/index.test.ts b/src/cli/index.test.ts index 2d279d38c29..1687cc215ad 100644 --- a/src/cli/index.test.ts +++ b/src/cli/index.test.ts @@ -41,7 +41,6 @@ vi.mock('child_process', async () => { }) import { COMMAND_SPECS, main } from './index' -import { formatFlagHelp } from './help' import { GLOBAL_FLAGS, specPaths } from './args' import { okFixture, queueFixtures } from './test-fixtures' @@ -460,97 +459,15 @@ describe('nested command group help', () => { ) }) -describe('orca root help', () => { - it('advertises machine-readable agent discovery', async () => { - const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) - - await main([], '/tmp/repo') - - expect(logSpy.mock.calls.flat().join('\n')).toContain('agent-context') - logSpy.mockRestore() - }) - - it('advertises host-local account management', async () => { - const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) - - await main([], '/tmp/repo') - - expect(logSpy.mock.calls.flat().join('\n')).toContain( - 'account add Add a managed agent account on this Orca host' - ) - expect(logSpy.mock.calls.flat().join('\n')).toContain( - 'account list List managed agent accounts on this Orca host' - ) - logSpy.mockRestore() - }) - - it('labels retired coordinator scheduler commands at the root', async () => { +describe('orca help', () => { + it('prints root help without contacting the runtime', async () => { const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) await main(['--help'], '/tmp/repo') - const output = String(logSpy.mock.calls[0]?.[0]) - expect(output).toContain( - 'orchestration coordinator-start Retired: load the current orchestration skill' - ) - expect(output).toContain( - 'orchestration coordinator-stop Retired: load the current orchestration skill' - ) - expect(output).not.toContain('Start the legacy automatic coordinator loop') - expect(output).not.toContain('Stop the legacy automatic coordinator loop') - logSpy.mockRestore() - }) - - it('advertises computer-use capabilities discovery', async () => { - const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) - - await main(['--help'], '/tmp/repo') - - expect(logSpy.mock.calls[0][0]).toContain( - 'computer capabilities Show computer-use provider capabilities' - ) - expect(logSpy.mock.calls[0][0]).toContain( - 'computer permissions Show or open computer-use permission setup' - ) - expect(logSpy.mock.calls[0][0]).toContain( - 'computer press-key Press a single key such as Return or Escape' - ) - expect(logSpy.mock.calls[0][0]).toContain( - 'project setup-existing-folder Make a project available on a host by importing an existing folder' - ) - expect(logSpy.mock.calls[0][0]).toContain( - 'project setup-create Create independent project host setup metadata' - ) - expect(logSpy.mock.calls[0][0]).toContain( - 'project setup-update Update project host setup metadata' - ) - expect(logSpy.mock.calls[0][0]).toContain( - 'project setup-delete Remove a project host setup' - ) - expect(logSpy.mock.calls[0][0]).toContain('Agent Sessions And Worktrees:') - expect(logSpy.mock.calls[0][0]).toContain( - 'orca agent launch --agent codex --model gpt-6-astra --effort high' - ) - expect(logSpy.mock.calls[0][0]).toContain( - 'orchestration worker-start Start a supervised worker locally or on a connected Orca server' - ) - expect(logSpy.mock.calls[0][0]).toContain( - 'orchestration ask Ask the coordinator a blocking question' - ) - expect(logSpy.mock.calls[0][0]).toContain( - 'orchestration worker-abandon Fence an uncertain worker without claiming it stopped' - ) - expect(logSpy.mock.calls[0][0]).toContain( - "orchestration worker-release Release a settled worker's terminal after archiving its output" - ) - expect(logSpy.mock.calls[0][0]).toContain( - 'orchestration worker-retain Keep a worker terminal live for debugging' - ) - expect(logSpy.mock.calls[0][0]).toContain( - 'orchestration worker-list Report worker terminal resource accounting' - ) - expect(logSpy.mock.calls[0][0]).not.toContain('orchestration worker-cleanup') + expect(logSpy.mock.calls[0][0]).toContain('Usage: orca ') expect(callMock).not.toHaveBeenCalled() + logSpy.mockRestore() }) it('progressively discloses Linear commands', async () => { @@ -559,10 +476,8 @@ describe('orca root help', () => { await main(['--help'], '/tmp/repo') const rootHelp = String(logSpy.mock.calls[0][0]) - expect(rootHelp).toContain('Linear:') - expect(rootHelp).toContain('linear Read and write Linear issues for agents') + expect(rootHelp).toContain('linear') expect(rootHelp).not.toContain('linear issue') - expect(rootHelp).not.toContain('linear search') logSpy.mockClear() await main(['linear', '--help'], '/tmp/repo') @@ -570,172 +485,15 @@ describe('orca root help', () => { const groupHelp = String(logSpy.mock.calls[0][0]) expect(groupHelp).toContain('orca linear') expect(groupHelp).toContain('issue') - expect(groupHelp).toContain('search') expect(groupHelp).not.toContain('--comments') - expect(groupHelp).not.toContain('--attachments') logSpy.mockClear() await main(['linear', 'issue', '--help'], '/tmp/repo') const issueHelp = String(logSpy.mock.calls[0][0]) - expect(issueHelp).toContain('orca linear issue []') - expect(issueHelp).toContain('--comments Include threaded Linear comments') - expect(issueHelp).toContain('--attachments Include attachment metadata and URLs') - expect(issueHelp).toContain('--activity Include issue field-change history') - expect(issueHelp).toContain('--workspace Connected Linear workspace id') - expect(issueHelp).toContain('--id Linear issue key, id, or URL') - - logSpy.mockClear() - await main(['linear', 'search', '--help'], '/tmp/repo') - - const searchHelp = String(logSpy.mock.calls[0][0]) - expect(searchHelp).toContain('orca linear search ') - expect(searchHelp).toContain('--workspace Connected Linear workspace id, or all') - expect(searchHelp).toContain('--query Text to search across Linear issues') - - logSpy.mockClear() - await main(['linear', 'list-issues', '--help'], '/tmp/repo') - - const listIssuesHelp = String(logSpy.mock.calls[0][0]) - expect(listIssuesHelp).toContain( - '--cursor Opaque cursor from a previous list-issues page; issued cursors bind the workspace, raw Linear cursors need --workspace' - ) - expect(listIssuesHelp).toContain('--workspace Connected Linear workspace id, or all') - expect(listIssuesHelp).toContain('0=none, 1=urgent, 2=high, 3=medium, 4=low') - expect(listIssuesHelp).toContain( - '--limit Max issues to return; omit to return every match' - ) - expect(listIssuesHelp).not.toContain('Line cursor from a previous read') - expect(callMock).not.toHaveBeenCalled() - }) - - it('documents the machine-readable terminal topology opt-in', async () => { - const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) - logSpy.mockClear() - - await main(['terminal', 'list', '--help'], '/tmp/repo') - - const help = String(logSpy.mock.calls[0][0]) - expect(help).toContain('[--include-visual-layouts] [--json]') - expect(help).toContain('--include-visual-layouts Include tab and pane topology in JSON output') - expect(help).toContain('JSON omits visualLayouts by default') - expect(callMock).not.toHaveBeenCalled() - }) - - it('describes worker-read cursors as opaque', async () => { - const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) - logSpy.mockClear() - - await main(['orchestration', 'worker-read', '--help'], '/tmp/repo') - - const help = String(logSpy.mock.calls[0][0]) - expect(help).toContain( - '--cursor Opaque cursor returned by a previous worker-read page' - ) - expect(help).not.toContain('Line cursor from a previous read') - expect(callMock).not.toHaveBeenCalled() - }) - - it('describes worker-list cursors as opaque page cursors', async () => { - const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) - logSpy.mockClear() - - await main(['orchestration', 'worker-list', '--help'], '/tmp/repo') - - const help = String(logSpy.mock.calls[0][0]) - expect(help).toContain('[--cursor ]') - expect(help).toContain('--cursor Opaque page cursor copied from page.nextCursor') - expect(help).toContain('Continue with the opaque page.nextCursor value unchanged.') - expect(help).not.toContain('--cursor ') - expect(help).not.toContain('Line cursor from a previous read') - expect(callMock).not.toHaveBeenCalled() - }) - - it('advertises Linear issue linking on worktree create and set help', async () => { - const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) - logSpy.mockClear() - - await main(['worktree', 'create', '--help'], '/tmp/repo') - - expect(String(logSpy.mock.calls[0][0])).toContain('--linear-issue ') - - logSpy.mockClear() - await main(['worktree', 'set', '--help'], '/tmp/repo') - - const setHelp = String(logSpy.mock.calls[0][0]) - expect(setHelp).toContain('--linear-issue ') - expect(setHelp).toContain('--linear-issue Linked Linear issue identifier or URL') - expect(callMock).not.toHaveBeenCalled() - }) - - it('advertises explicit orchestration task display labels', async () => { - const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) - logSpy.mockClear() - - await main(['orchestration', 'task-create', '--help'], '/tmp/repo') - - const help = String(logSpy.mock.calls[0][0]) - expect(help).toContain('[--task-title ] [--display-name ]') - expect(help).toContain('--task-title Concise title for the orchestration task') - expect(help).toContain('--display-name UI label shown for dispatched worker rows') - expect(callMock).not.toHaveBeenCalled() - }) - - it('hides removed parent-workspace help and scopes create parent selectors', async () => { - const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) - logSpy.mockClear() - - await main(['--help'], '/tmp/repo') - - const rootHelp = String(logSpy.mock.calls[0][0]) - expect(rootHelp).not.toContain('--parent-workspace') - expect(rootHelp).toContain('[--parent-worktree ] [--no-parent]') - expect(rootHelp).toContain('identity:') - - logSpy.mockClear() - await main(['worktree', 'create', '--help'], '/tmp/repo') - - const createHelp = String(logSpy.mock.calls[0][0]) - expect(createHelp).not.toContain('--parent-workspace') - expect(createHelp).not.toContain('checkout/workspace') - expect(createHelp).not.toContain('caller workspace') - expect(createHelp).not.toContain('current workspace') - expect(createHelp).not.toContain('active Orca workspace') - expect(createHelp).not.toContain('folderWorkspaceId') - expect(createHelp).toContain('folder:') - expect(createHelp).toContain('folder:') - expect(createHelp).toContain('worktree:') - expect(createHelp).toContain( - '--no-parent only affects Orca lineage; omit --base-branch to use the repo default base' - ) - - logSpy.mockClear() - await main(['worktree', 'set', '--help'], '/tmp/repo') - - const setHelp = String(logSpy.mock.calls[0][0]) - expect(setHelp).not.toContain('--parent-workspace') - expect(setHelp).not.toContain('folder:') - expect(formatFlagHelp('parent-worktree')).toContain('identity:') - expect(setHelp).not.toContain('worktree:') - expect(callMock).not.toHaveBeenCalled() - }) - - it('points both create commands at the agent launch for starting agents', async () => { - const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) - logSpy.mockClear() - - await main(['worktree', 'create', '--help'], '/tmp/repo') - - expect(String(logSpy.mock.calls[0][0])).toContain('This creates a new checkout.') - expect(String(logSpy.mock.calls[0][0])).toContain( - 'For a fresh agent in an existing worktree, use `orca agent launch`' - ) - - logSpy.mockClear() - await main(['terminal', 'create', '--help'], '/tmp/repo') - - const terminalHelp = String(logSpy.mock.calls[0][0]) - expect(terminalHelp).toContain('To start an agent, use `orca agent launch`') + expect(issueHelp).toContain('orca linear issue') + expect(issueHelp).toContain('--comments') expect(callMock).not.toHaveBeenCalled() + logSpy.mockRestore() }) }) diff --git a/src/cli/specs/file.test.ts b/src/cli/specs/file.test.ts deleted file mode 100644 index 9ef94f20614..00000000000 --- a/src/cli/specs/file.test.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { findCommandSpec } from '../args' -import { formatCommandHelp } from '../help' -import { COMMAND_SPECS } from './index' - -function optionLine(path: string[], flag: string): string | undefined { - const spec = findCommandSpec(COMMAND_SPECS, path) - if (!spec) { - throw new Error(`no spec for ${path.join(' ')}`) - } - return formatCommandHelp(spec) - .split('\n') - .find((line) => line.trimStart().startsWith(`--${flag} `)) -} - -describe('orca file command help', () => { - it.each([['open'], ['diff'], ['open-changed']])( - 'file %s describes --focus as bringing the user to the file', - (command) => { - expect(optionLine(['file', command], 'focus')).toBe( - " --focus Bring the user to the file (switches Orca's window to its worktree)" - ) - } - ) - - it('leaves terminal create --focus describing its terminal session', () => { - expect(optionLine(['terminal', 'create'], 'focus')).toBe( - ' --focus Reveal the created terminal session in Orca' - ) - }) -}) diff --git a/src/cli/specs/search.test.ts b/src/cli/specs/search.test.ts deleted file mode 100644 index 007da8c9fc3..00000000000 --- a/src/cli/specs/search.test.ts +++ /dev/null @@ -1,129 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { SEARCH_COMMAND_SPECS } from './search' -import { effectiveAllowedFlags, findCommandSpec, GLOBAL_FLAGS } from '../args' -import { buildAgentContext } from '../agent-context' -import { suggestCommands } from '../command-suggestion' -import { HANDLER_COMMAND_KEYS } from '../dispatch' -import { formatCommandHelp, printHelp } from '../help' -import { ROOT_HELP_TEXT_PRIMARY } from '../root-help-text-primary' -import { ROOT_HELP_TEXT_SECONDARY } from '../root-help-text-secondary' -import { COMMAND_SPECS } from './index' -import { CLI_COMMAND_NAMES } from '../../main/startup/cli-command-names' - -const searchSpec = SEARCH_COMMAND_SPECS[0]! -const help = formatCommandHelp(searchSpec) - -describe('orca search command spec', () => { - it('is one command, not a group, because the query is a bare positional', () => { - expect(SEARCH_COMMAND_SPECS).toHaveLength(1) - expect(searchSpec.path).toEqual(['search']) - expect(searchSpec.positionalArgs).toEqual(['query']) - }) - - it('is registered in the live spec table, the dispatcher and the launch redirect', () => { - expect(COMMAND_SPECS).toContain(searchSpec) - expect(HANDLER_COMMAND_KEYS.has('search')).toBe(true) - expect(CLI_COMMAND_NAMES).toContain('search') - }) - - it('accepts exactly the flags that map onto the search contract', () => { - expect([...searchSpec.allowedFlags].sort()).toEqual([ - 'agent', - 'cursor', - 'debug', - 'environment', - 'fresh', - 'help', - 'index-status', - 'json', - 'limit', - 'pairing-code', - 'path', - 'query', - 'scope', - 'since', - 'sort' - ]) - }) - - it('keeps --agent and --path repeatability scoped to search', () => { - expect(searchSpec.repeatableFlags).toEqual(['agent', 'path']) - for (const spec of COMMAND_SPECS) { - if (spec !== searchSpec) { - expect(spec.repeatableFlags ?? []).not.toContain('agent') - expect(spec.repeatableFlags ?? []).not.toContain('path') - } - } - }) - - it('does not accept or advertise browser page targeting', () => { - expect(effectiveAllowedFlags(searchSpec)).not.toContain('page') - expect(help).not.toContain('--page') - }) - - it('describes every search flag rather than falling back to the bare name', () => { - const searchOnly = searchSpec.allowedFlags.filter((flag) => !GLOBAL_FLAGS.includes(flag)) - expect(searchOnly).toHaveLength(11) - for (const flag of searchOnly) { - expect(help).toContain(`--${flag}`) - expect(help.split('\n')).not.toContain(` --${flag}`) - } - }) - - it('describes --agent as a search filter, not a terminal agent to launch', () => { - expect(help).toContain('Restrict to one agent; repeat for several') - expect(help).not.toContain('TUI agent') - }) - - it('tells the reader to quote a multi-word query', () => { - expect(searchSpec.notes?.join('\n')).toContain('Quote a multi-word query') - }) - - it('states that it searches one host and offers no all-computers search', () => { - expect(searchSpec.notes?.join('\n')).toContain('There is no all-computers search.') - }) - - it('shows both the query and the index report in its usage', () => { - expect(searchSpec.usage).toContain('orca search ') - expect(searchSpec.usage).toContain('orca search --index-status') - }) -}) - -describe('orca search discovery surfaces', () => { - it('is listed in the root help', () => { - expect(ROOT_HELP_TEXT_PRIMARY).toContain('Agent Sessions:') - expect(ROOT_HELP_TEXT_PRIMARY).toContain( - ' search Search the full text of agent sessions on one Orca host' - ) - expect(ROOT_HELP_TEXT_SECONDARY).toContain(' orca search --index-status [--json]') - }) - - it('prints its own help for `orca search --help`', () => { - const lines: string[] = [] - const restore = console.log - console.log = (value: unknown) => void lines.push(String(value)) - try { - printHelp(COMMAND_SPECS, ['search']) - } finally { - console.log = restore - } - expect(lines.join('\n')).toContain('Usage: orca search ') - }) - - it('resolves for dispatch', () => { - expect(findCommandSpec(COMMAND_SPECS, ['search'])).toBe(searchSpec) - }) - - it('exposes the command to agent discovery with its positional and flags', () => { - const command = buildAgentContext(COMMAND_SPECS).commands.find( - (entry) => entry.command === 'search' - ) - expect(command?.positionalArgs).toEqual(['query']) - expect(command?.flags).toContain('index-status') - expect(command?.flags).not.toContain('page') - }) - - it('is offered as a suggestion for a near-miss command', () => { - expect(suggestCommands(COMMAND_SPECS, ['serch'])).toContain('search') - }) -}) diff --git a/src/cli/terminal-format-draft.test.ts b/src/cli/terminal-format-draft.test.ts deleted file mode 100644 index 3f38e48dd21..00000000000 --- a/src/cli/terminal-format-draft.test.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { formatTerminalRead } from './terminal-format' - -describe('formatTerminalRead draft separation', () => { - it('labels composer drafts outside terminal output', () => { - const output = formatTerminalRead({ - terminal: { - handle: 'term_1', - status: 'running', - tail: ['Build passed', '❯'], - truncated: false, - nextCursor: null, - source: 'screen', - draft: 'proceed with the release' - } - }) - - expect(output).toContain('draft: "proceed with the release"') - expect(output).toContain('\n\nBuild passed\n❯') - }) -}) diff --git a/src/cli/terminal-format.test.ts b/src/cli/terminal-format.test.ts index ee0a985441e..58904929a55 100644 --- a/src/cli/terminal-format.test.ts +++ b/src/cli/terminal-format.test.ts @@ -7,6 +7,7 @@ import type { import { formatTerminalClose, formatTerminalFocus, + formatTerminalRead, formatTerminalSend, formatTerminalShow, formatTerminalWait @@ -268,3 +269,22 @@ describe('blocked-reason rendering against a mixed-version host', () => { ) }) }) + +describe('formatTerminalRead draft separation', () => { + it('labels composer drafts outside terminal output', () => { + const output = formatTerminalRead({ + terminal: { + handle: 'term_1', + status: 'running', + tail: ['Build passed', '❯'], + truncated: false, + nextCursor: null, + source: 'screen', + draft: 'proceed with the release' + } + }) + + expect(output).toContain('draft: "proceed with the release"') + expect(output).toContain('\n\nBuild passed\n❯') + }) +}) diff --git a/src/main/agent-hooks/server-transport-interference.test.ts b/src/main/agent-hooks/server-transport-interference.test.ts index e8d5a8e357b..dc39cebed46 100644 --- a/src/main/agent-hooks/server-transport-interference.test.ts +++ b/src/main/agent-hooks/server-transport-interference.test.ts @@ -2,11 +2,12 @@ // short of its own Content-Length. The listener fails open on every request error, so the only // way this stays diagnosable is if the truncation is classified before it is swallowed. import { connect } from 'node:net' -import { ServerResponse } from 'node:http' +import { IncomingMessage, ServerResponse } from 'node:http' import { afterEach, describe, expect, it, vi } from 'vitest' import type { HookTransportInterferenceReport } from '../../shared/agent-hook-transport-interference' import { AgentHookServer } from './server' import { OPENCODE_STARTUP_PROMPT_CLAIM_PATH } from '../../shared/opencode-startup-prompt' +import { HOOK_REQUEST_SLOWLORIS_MS } from '../../shared/agent-hook-listener/listener-limits' async function postTruncatedHook( port: number, @@ -47,15 +48,29 @@ async function postStalledHook( token: string, pathname = '/hook/claude' ): Promise { - const socket = connect({ port, host: '127.0.0.1' }) - await new Promise((resolve) => socket.on('connect', () => resolve())) - socket.write( - `POST ${pathname} HTTP/1.1\r\nHost: 127.0.0.1\r\nContent-Type: application/x-www-form-urlencoded\r\nX-Orca-Agent-Hook-Token: ${token}\r\nContent-Length: 100000\r\n\r\n` - ) - await new Promise((resolve) => { - socket.on('close', () => resolve()) - socket.on('error', () => resolve()) + const setRequestTimeout = IncomingMessage.prototype.setTimeout + // Keep the native timeout/destroy path while checking its full production budget. + const timeout = vi.spyOn(IncomingMessage.prototype, 'setTimeout').mockImplementation(function ( + this: IncomingMessage, + milliseconds, + callback + ) { + expect(milliseconds).toBe(HOOK_REQUEST_SLOWLORIS_MS) + return setRequestTimeout.call(this, 25, callback) }) + try { + const socket = connect({ port, host: '127.0.0.1' }) + await new Promise((resolve) => socket.on('connect', () => resolve())) + socket.write( + `POST ${pathname} HTTP/1.1\r\nHost: 127.0.0.1\r\nContent-Type: application/x-www-form-urlencoded\r\nX-Orca-Agent-Hook-Token: ${token}\r\nContent-Length: 100000\r\n\r\n` + ) + await new Promise((resolve) => { + socket.on('close', () => resolve()) + socket.on('error', () => resolve()) + }) + } finally { + timeout.mockRestore() + } await new Promise((resolve) => setTimeout(resolve, 50)) } diff --git a/src/main/ai-vault-search/session-search-transcript-dependencies.test.ts b/src/main/ai-vault-search/session-search-transcript-dependencies.test.ts index 367a19c96c6..7544390583d 100644 --- a/src/main/ai-vault-search/session-search-transcript-dependencies.test.ts +++ b/src/main/ai-vault-search/session-search-transcript-dependencies.test.ts @@ -24,17 +24,17 @@ import { } from '../ai-vault/session-transcript-consumers' import { SELF_CONTAINED_TRANSCRIPT_AGENTS } from './session-search-index-consumer' -// Spying on every fs export also mocks ReadStream internals and stalls real reads. vi.mock('node:fs', async (importOriginal) => { - const actual = await importOriginal() - return { - ...actual, - readFile: vi.fn(actual.readFile), - readFileSync: vi.fn(actual.readFileSync), - open: vi.fn(actual.open), - openSync: vi.fn(actual.openSync), - createReadStream: vi.fn(actual.createReadStream) + const original = await importOriginal() + // Recursive spies alter native stream prototypes and prevent readers from finishing. + const tracked = { + readFile: vi.fn(original.readFile), + readFileSync: vi.fn(original.readFileSync), + open: vi.fn(original.open), + openSync: vi.fn(original.openSync), + createReadStream: vi.fn(original.createReadStream) } + return { ...original, ...tracked, default: { ...original.default, ...tracked } } }) vi.mock('node:fs/promises', { spy: true }) vi.mock('../native-chat/wsl-transcript-fs-access', { spy: true }) diff --git a/src/main/browser/browser-popup-background-cdp-probe.ts b/src/main/browser/browser-popup-background-cdp-probe.ts index 171125823ce..4552730417e 100644 --- a/src/main/browser/browser-popup-background-cdp-probe.ts +++ b/src/main/browser/browser-popup-background-cdp-probe.ts @@ -98,14 +98,30 @@ export async function clickBackgroundPopupProbe( page: Page, probe: BackgroundPopupProbe ): Promise { - if (probe.context === 'opaque') { - // Electron does not forward opaque OOPIF input to the root route gesture gate. - await page.locator('#background-gesture').click() - } const locator = probe.context === 'subframe' || probe.context === 'opaque' ? page.frameLocator('#background-probe').locator('#background-open') : page.locator('#background-open') + if (probe.context === 'opaque') { + // A hidden OOPIF can finish loading before browser input reaches its new frame. + await locator.evaluate((element) => { + element.removeAttribute('data-background-input-ready') + element.addEventListener('pointermove', (event) => { + if (event.isTrusted) { + element.setAttribute('data-background-input-ready', 'true') + } + }) + }) + await page.locator('#background-gesture').click() + await expect + .poll(async () => { + await locator.hover() + return locator.getAttribute('data-background-input-ready') + }) + .toBe('true') + // Opaque frame input does not reach Electron's root route gesture gate. + await page.locator('#background-gesture').click() + } await locator.click( probe.input === 'middle' ? { button: 'middle' } : { modifiers: [backgroundModifier] } ) diff --git a/src/main/daemon/daemon-pty-startup-delivery.test.ts b/src/main/daemon/daemon-pty-startup-delivery.test.ts index 6691ca0a514..e005df68524 100644 --- a/src/main/daemon/daemon-pty-startup-delivery.test.ts +++ b/src/main/daemon/daemon-pty-startup-delivery.test.ts @@ -73,14 +73,19 @@ describe('DaemonPtyAdapter startup delivery', () => { const missingShell = join(dir, 'missing-fish') const fallbackName = basename(localPtyUtils.resolveUnixShellPath(missingShell)) context.skip(!['bash', 'zsh'].includes(fallbackName), 'Requires a Bash/zsh fallback') - await adapter.spawn({ - cols: 80, - rows: 24, - command: 'codex', - env: { SHELL: source === 'environment' ? missingShell : '/bin/sh' }, - ...(source === 'override' ? { shellOverride: missingShell } : {}) - }) - await new Promise((resolve) => setTimeout(resolve, 350)) + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) + try { + await adapter.spawn({ + cols: 80, + rows: 24, + command: 'codex', + env: { SHELL: source === 'environment' ? missingShell : '/bin/sh' }, + ...(source === 'override' ? { shellOverride: missingShell } : {}) + }) + await vi.advanceTimersByTimeAsync(350) + } finally { + vi.useRealTimers() + } expect(lastSubprocess.write).not.toHaveBeenCalled() expect(lastSpawnOpts).toEqual( expect.objectContaining({ startupCommandDelivery: 'shell-ready' }) @@ -96,9 +101,13 @@ describe('DaemonPtyAdapter startup delivery', () => { { command: 'codex', startupCommandDelivery: 'fast' as const }, { command: "codex 'linked issue context'", startupCommandDelivery: 'shell-ready' as const } ])('waits past 300ms and submits once after readiness: %j', async (startup) => { - await adapter.spawn({ cols: 80, rows: 24, ...startup, env: { SHELL: '/bin/zsh' } }) - - await new Promise((resolve) => setTimeout(resolve, 350)) + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) + try { + await adapter.spawn({ cols: 80, rows: 24, ...startup, env: { SHELL: '/bin/zsh' } }) + await vi.advanceTimersByTimeAsync(350) + } finally { + vi.useRealTimers() + } expect(lastSubprocess.write).not.toHaveBeenCalled() expect(lastSpawnOpts).toEqual( expect.objectContaining({ startupCommandDelivery: 'shell-ready' }) diff --git a/src/main/daemon/slow-daemon-session-verification.test.ts b/src/main/daemon/slow-daemon-session-verification.test.ts index 9668f1de26d..1781d423135 100644 --- a/src/main/daemon/slow-daemon-session-verification.test.ts +++ b/src/main/daemon/slow-daemon-session-verification.test.ts @@ -1,6 +1,7 @@ import './mock-descendant-sweep' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { connect, createServer, type Server, type Socket } from 'node:net' +import { once } from 'node:events' import { tmpdir } from 'node:os' import { join } from 'node:path' import { mkdirSync, mkdtempSync, rmSync } from 'node:fs' @@ -15,10 +16,7 @@ import type { SubprocessHandle } from './session-subprocess-handle' // time out the 3s startup health check against a daemon that was alive and // owning sessions, and the unhealthy path killed it. The fix re-verifies // with listSessions, which has far larger budgets (5s hello, 30s request). -// This test reproduces the production asymmetry against a REAL daemon by -// inserting a response delay that exceeds the health-check budget but fits -// the verification budgets, and asserts the guard's two inputs disagree the -// way the fix depends on. +// Real sockets preserve the handshake; virtual time crosses both production budgets. const RESPONSE_DELAY_MS = 3_500 function createMockSubprocess(): SubprocessHandle { @@ -52,6 +50,7 @@ function startDelayProxy(listenPath: string, upstreamPath: string): Server { clientSocket.write(chunk) } }, RESPONSE_DELAY_MS) + proxy.emit('response-queued') }) const teardown = (): void => { clientSocket.destroy() @@ -88,6 +87,7 @@ describe('slow daemon session verification', () => { }) afterEach(async () => { + vi.useRealTimers() for (const client of clients.splice(0)) { client.disconnect() } @@ -121,17 +121,33 @@ describe('slow daemon session verification', () => { }) proxy = startDelayProxy(proxySocketPath, daemonSocketPath) + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) // The exact pre-fix kill trigger: the daemon is alive but too slow for // the health-check budget. - await expect(healthCheckDaemon(proxySocketPath, tokenPath)).resolves.toBe(false) + const healthResponse = once(proxy, 'response-queued') + const health = healthCheckDaemon(proxySocketPath, tokenPath) + await healthResponse + await vi.advanceTimersByTimeAsync(3_000) + await expect(health).resolves.toBe(false) // The fix's re-verification against the SAME slow daemon: the larger // client budgets absorb the latency and prove the session is alive. const verificationClient = new DaemonClient({ socketPath: proxySocketPath, tokenPath }) clients.push(verificationClient) - await verificationClient.ensureConnected() - const result = await verificationClient.request('listSessions', undefined) + const helloResponse = once(proxy, 'response-queued') + const connected = verificationClient.ensureConnected() + await helloResponse + const streamResponse = once(proxy, 'response-queued') + await vi.advanceTimersByTimeAsync(RESPONSE_DELAY_MS) + await streamResponse + await vi.advanceTimersByTimeAsync(RESPONSE_DELAY_MS) + await connected + const sessionsResponse = once(proxy, 'response-queued') + const sessions = verificationClient.request('listSessions', undefined) + await sessionsResponse + await vi.advanceTimersByTimeAsync(RESPONSE_DELAY_MS) + const result = await sessions const liveSessionCount = result.sessions.filter((session) => session.isAlive).length expect(liveSessionCount).toBe(1) } diff --git a/src/main/git/repo-ref-maintenance-real-git.test.ts b/src/main/git/repo-ref-maintenance-real-git.test.ts index 7c3041e6dfe..fc74a1c2d18 100644 --- a/src/main/git/repo-ref-maintenance-real-git.test.ts +++ b/src/main/git/repo-ref-maintenance-real-git.test.ts @@ -1,8 +1,8 @@ import { execFileSync } from 'node:child_process' -import { mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises' +import { cp, mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { afterEach, describe, expect, it } from 'vitest' +import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest' import { countLooseRefs } from '../../shared/loose-ref-count' import { RepoRefMaintenance } from '../../shared/repo-ref-maintenance' import { PACK_INDEX_MAINTENANCE_COOLDOWN_MS } from '../../shared/repo-pack-index-maintenance-policy' @@ -20,6 +20,8 @@ const roots: string[] = [] // three real `pack-refs` runs before the deferral budget is spent. const QUIET_MS = 25 const THRESHOLD = 20 +let packFixtureRoot = '' +let packFixtureBlobs: string[] = [] function git(cwd: string, args: string[], input?: string): string { return execFileSync('git', args, { @@ -45,11 +47,33 @@ function hasWriteOption(option: string): boolean { } async function createFragmentedPacks(repoPath: string): Promise { + await cp( + join(packFixtureRoot, '.git', 'objects', 'pack'), + join(repoPath, '.git', 'objects', 'pack'), + { + recursive: true + } + ) + return packFixtureBlobs +} + +beforeAll(async () => { + // Each test gets its own pack files; generating the same 64 packs only needs to happen once. + packFixtureRoot = await mkdtemp(join(tmpdir(), 'orca-fragmented-packs-')) + git(packFixtureRoot, ['init', '--quiet']) + packFixtureBlobs = await buildFragmentedPacks(packFixtureRoot) +}) + +afterAll(async () => { + await rm(packFixtureRoot, { recursive: true, force: true }) +}) + +async function buildFragmentedPacks(repoPath: string): Promise { const objects = join(repoPath, '.git', 'objects') - const blobs: string[] = [] - for (let index = 0; index < PACK_INDEX_THRESHOLD; index += 1) { - const blob = git(repoPath, ['hash-object', '-w', '--stdin'], `packed-${index}\n`) - blobs.push(blob) + const files = Array.from({ length: PACK_INDEX_THRESHOLD }, (_, index) => `packed-${index}`) + await Promise.all(files.map((file) => writeFile(join(repoPath, file), `${file}\n`))) + const blobs = git(repoPath, ['hash-object', '-w', ...files]).split('\n') + for (const blob of blobs) { git(repoPath, ['pack-objects', join(objects, 'pack', 'pack')], `${blob}\n`) await rm(join(objects, blob.slice(0, 2), blob.slice(2))) } diff --git a/src/main/git/worktree-rebase-update-refs-real-git.test.ts b/src/main/git/worktree-rebase-update-refs-real-git.test.ts index 8da9da2388b..c655836f415 100644 --- a/src/main/git/worktree-rebase-update-refs-real-git.test.ts +++ b/src/main/git/worktree-rebase-update-refs-real-git.test.ts @@ -2,7 +2,7 @@ import { mkdir, mkdtemp, readFile, realpath, rm, writeFile } from 'node:fs/promi import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { runProcess } from '@orca/process-host' +import { GitBinaryTestFixture } from '../../shared/git-binary.test-fixture' vi.mock('./runner', () => ({ gitExecFileAsync: vi.fn(), gitExecFileSync: vi.fn() })) vi.mock('./local-repo-ref-maintenance', () => ({ @@ -11,51 +11,19 @@ vi.mock('./local-repo-ref-maintenance', () => ({ import { forceDeleteLocalBranch } from './worktree-branch-removal' -const image = process.env.ORCA_GIT_COMPAT_IMAGE -const binary = process.env.ORCA_GIT_COMPAT_BINARY ?? 'git' -const dockerUser = - typeof process.getuid === 'function' && typeof process.getgid === 'function' - ? ['--user', `${process.getuid()}:${process.getgid()}`] - : [] +let fixture: GitBinaryTestFixture let root = '' let repo = '' let checkout = '' async function git(args: string[], cwd = repo): Promise<{ stdout: string; stderr: string }> { - const result = await runProcess({ - program: image ? 'docker' : binary, - args: image - ? [ - 'run', - '--rm', - '--network=none', - ...dockerUser, - '-v', - `${root}:${root}`, - '-w', - cwd, - image, - '-c', - `safe.directory=${cwd}`, - ...args - ] - : args, - cwd, - env: { - ...process.env, - GIT_CONFIG_GLOBAL: join(root, 'empty-config'), - GIT_CONFIG_NOSYSTEM: '1' - }, - maxOutputBytes: 2 * 1024 * 1024 - }) - if (result.code !== 0) { - throw Object.assign(new Error(result.stderr), { code: result.code, stderr: result.stderr }) - } - return result + return fixture.exec(args, cwd, { GIT_CONFIG_GLOBAL: join(root, 'empty-config') }) } beforeEach(async () => { root = await realpath(await mkdtemp(join(tmpdir(), 'orca-rebase-reservation-'))) + fixture = new GitBinaryTestFixture(root) + await fixture.start() repo = join(root, 'repo') checkout = join(root, 'checkout') await mkdir(repo) @@ -77,7 +45,11 @@ beforeEach(async () => { }) afterEach(async () => { - await rm(root, { recursive: true, force: true }) + try { + await fixture.stop() + } finally { + await rm(root, { recursive: true, force: true }) + } }) describe('rebase branch reservations with real Git', () => { diff --git a/src/main/git/worktree-safety-real-git.test.ts b/src/main/git/worktree-safety-real-git.test.ts index 9a3e2fdfffa..c32f546d309 100644 --- a/src/main/git/worktree-safety-real-git.test.ts +++ b/src/main/git/worktree-safety-real-git.test.ts @@ -1,9 +1,7 @@ -import { execFile } from 'node:child_process' import { mkdir, mkdtemp, realpath, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { promisify } from 'node:util' -import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' const { runner } = vi.hoisted(() => ({ runner: vi.fn() })) vi.mock('./runner', () => ({ @@ -15,43 +13,15 @@ vi.mock('./status', () => ({ runWithGitReadCacheInvalidation: (run: () => unknow import { getBranchConflictKind, getBranchConflictKindViaExec } from './repo-branch-conflict' import { clearGitCapabilityStateForTests } from './git-capability-state' +import { GitBinaryTestFixture } from '../../shared/git-binary.test-fixture' -const execFileAsync = promisify(execFile) -const image = process.env.ORCA_GIT_COMPAT_IMAGE -const binary = process.env.ORCA_GIT_COMPAT_BINARY ?? 'git' const expectedVersion = process.env.ORCA_GIT_COMPAT_VERSION -const dockerUser = - typeof process.getuid === 'function' && typeof process.getgid === 'function' - ? ['--user', `${process.getuid()}:${process.getgid()}`] - : [] +let fixture: GitBinaryTestFixture let root = '' let repo = '' async function git(args: string[], cwd = repo): Promise<{ stdout: string; stderr: string }> { - return image - ? execFileAsync( - 'docker', - [ - 'run', - '--rm', - '--network=none', - ...dockerUser, - '-v', - `${root}:${root}`, - '-w', - cwd, - image, - '-c', - `safe.directory=${cwd}`, - ...args - ], - { maxBuffer: 2 * 1024 * 1024 } - ) - : execFileAsync(binary, args, { - cwd, - env: { ...process.env, HOME: root, XDG_CONFIG_HOME: root, GIT_CONFIG_NOSYSTEM: '1' }, - maxBuffer: 2 * 1024 * 1024 - }) + return fixture.exec(args, cwd) } async function initializeRepo(cwd: string): Promise { @@ -68,23 +38,30 @@ async function initializeRepo(cwd: string): Promise { } beforeAll(async () => { - const { stdout } = await execFileAsync( - image ? 'docker' : binary, - image ? ['run', '--rm', '--network=none', ...dockerUser, image, '--version'] : ['--version'] - ) + root = await realpath(await mkdtemp(join(tmpdir(), 'orca-worktree-safety-'))) + fixture = new GitBinaryTestFixture(root) + await fixture.start() + const { stdout } = await fixture.exec(['--version']) expect(stdout).toContain(expectedVersion ? `git version ${expectedVersion}` : 'git version ') }) +afterAll(async () => { + try { + await fixture.stop() + } finally { + await rm(root, { recursive: true, force: true }) + } +}) + beforeEach(async () => { - root = await realpath(await mkdtemp(join(tmpdir(), 'orca-worktree-safety-'))) - repo = join(root, 'repo') + repo = await mkdtemp(join(root, 'repo-')) await initializeRepo(repo) clearGitCapabilityStateForTests() runner.mockImplementation((args: string[], options: { cwd?: string }) => git(args, options.cwd)) }) afterEach(async () => { - await rm(root, { recursive: true, force: true }) + await rm(repo, { recursive: true, force: true }) }) describe('worktree safety with the real Git binary', () => { diff --git a/src/main/hook-termination-real-process.test.ts b/src/main/hook-termination-real-process.test.ts index 65d1b0910d1..a314a2d99f4 100644 --- a/src/main/hook-termination-real-process.test.ts +++ b/src/main/hook-termination-real-process.test.ts @@ -30,14 +30,14 @@ async function survivorsAfterDeadline( try { const result = await runHook('archive', dir, REPO, dir, undefined, 400) expect(result.success).toBe(false) - // SIGTERM lands at the deadline, SIGKILL two seconds later. - await new Promise((resolve) => setTimeout(resolve, 3_500)) expect(existsSync(pidFile)).toBe(true) pids = readFileSync(pidFile, 'utf8').trim().split(/\s+/).map(Number) // Without this, a script that recorded only the shell leaves `pids[1]` undefined, `alive` // throws, and the missing descendant reads as dead — a test that passes on nothing. expect(pids).toHaveLength(2) expect(pids.every((pid) => Number.isSafeInteger(pid) && pid > 0)).toBe(true) + // SIGTERM lands at the deadline; resistant descendants need the real SIGKILL grace. + await expect.poll(() => pids.some(alive), { timeout: 5_000, interval: 25 }).toBe(false) return { shell: alive(pids[0]!), child: alive(pids[1]!), output: result.output, pids } } finally { for (const pid of pids) { diff --git a/src/main/ipc/jira.ts b/src/main/ipc/jira.ts index 47727598a61..2ebd3b27988 100644 --- a/src/main/ipc/jira.ts +++ b/src/main/ipc/jira.ts @@ -72,13 +72,9 @@ export function registerJiraHandlers(): void { return selectSite(siteId) }) - ipcMain.handle('jira:status', async () => { - return getStatus() - }) + ipcMain.handle('jira:status', async () => getStatus()) - ipcMain.handle('jira:readStatus', async () => { - return getStatus() - }) + ipcMain.handle('jira:readStatus', async () => getStatus()) ipcMain.handle('jira:testConnection', async (_event, args?: { siteId?: string }) => { return testConnection(normalizeJiraSiteId(args?.siteId)) diff --git a/src/main/native-chat/transcript-opencode2-sqlite.test.ts b/src/main/native-chat/transcript-opencode2-sqlite.test.ts index 7ddd653071d..df36bbc78a1 100644 --- a/src/main/native-chat/transcript-opencode2-sqlite.test.ts +++ b/src/main/native-chat/transcript-opencode2-sqlite.test.ts @@ -26,11 +26,10 @@ function fixture() { CREATE TABLE session_message (id TEXT PRIMARY KEY, session_id TEXT, type TEXT, seq INTEGER, data TEXT, time_created INTEGER, time_updated INTEGER); INSERT INTO session_v2 VALUES ('session');`) + const insertMessage = db.prepare('INSERT INTO session_message VALUES (?, ?, ?, ?, ?, ?, ?)') let sequence = 0 const insert = (id: string, type: string, data: unknown, time = 1) => - db - .prepare('INSERT INTO session_message VALUES (?, ?, ?, ?, ?, ?, ?)') - .run(id, 'session', type, ++sequence, JSON.stringify(data), time, time) + insertMessage.run(id, 'session', type, ++sequence, JSON.stringify(data), time, time) return { db, path, insert } } @@ -159,6 +158,8 @@ describe('OpenCode 2 native transcript', () => { it('refuses excessive sparse scans instead of returning a partial transcript', () => { const { db, path, insert } = fixture() + // The regression is the scan cap; avoid quadratic fixture scans between batches. + db.exec('CREATE INDEX session_message_sequence ON session_message (session_id, seq)') db.exec('BEGIN') for (let index = 0; index < 10001; index++) { insert(String(index), 'idle', {}) diff --git a/src/main/persistence/profile-state/profile-state-backup-worker.test.ts b/src/main/persistence/profile-state/profile-state-backup-worker.test.ts index a2d40b8328c..2f684a619cd 100644 --- a/src/main/persistence/profile-state/profile-state-backup-worker.test.ts +++ b/src/main/persistence/profile-state/profile-state-backup-worker.test.ts @@ -191,7 +191,10 @@ describe('profile state backup worker', () => { const { directory, job } = fixture() const failedWorker = script(directory, source) await expect( - runProfileStateBackupWorker(job, { workerPath: failedWorker, timeoutMs: 500 }) + runProfileStateBackupWorker(job, { + workerPath: failedWorker, + timeoutMs: error === 'timed out' ? 500 : undefined + }) ).rejects.toThrow(error) expect(existsSync(job.targetPath)).toBe(false) rmSync(directory, { recursive: true }) diff --git a/src/main/provider-process/provider-process-supervisor.integration.test.ts b/src/main/provider-process/provider-process-supervisor.integration.test.ts index 98e03a679d5..141935da0f6 100644 --- a/src/main/provider-process/provider-process-supervisor.integration.test.ts +++ b/src/main/provider-process/provider-process-supervisor.integration.test.ts @@ -348,15 +348,28 @@ describe.runIf(process.platform !== 'win32')('POSIX provider supervisor processe }) it('never spawns the provider when its owner is not its parent at start', async () => { - const marker = join(tempDir(), 'provider-started') + const dir = tempDir() + const marker = join(dir, 'provider-spawned') + const preload = join(dir, 'record-spawn.js') + writeFileSync( + preload, + String.raw` + const childProcess = require('node:child_process') + const spawn = childProcess.spawn + childProcess.spawn = (...args) => { + require('node:fs').writeFileSync(process.env.ORCA_TEST_PROVIDER_PID_FILE, 'spawned') + return spawn(...args) + } + ` + ) const { exit } = launchSupervisor( { ownerPid: process.pid === 1 ? 2 : 1 }, - {}, - { command: 'touch', args: [marker] } + { ORCA_TEST_PROVIDER_PID_FILE: marker }, + { command: 'touch', args: [join(dir, 'provider-started')] }, + ['--require', preload] ) await expect(exit).resolves.toEqual({ code: 1, signal: null }) - await new Promise((resolve) => setTimeout(resolve, 200)) expect(existsSync(marker)).toBe(false) }) @@ -397,10 +410,12 @@ describe.runIf(process.platform !== 'win32')('POSIX provider supervisor processe expect(alive(provider)).toBe(false) }) - it('gives a provider 1 s after stdin end, then 3 s after SIGTERM before SIGKILL', async () => { + it('gives a provider its stdin-end grace, then its SIGTERM grace before SIGKILL', async () => { const signalFile = join(tempDir(), 'provider-sigterm-at') + const stdinEndGraceMs = 200 + const sigtermGraceMs = 300 const { supervisor, exit } = launchSupervisor( - {}, + { stdinEndGraceMs, sigtermGraceMs }, { ORCA_TEST_PROVIDER_SIGNAL_FILE: signalFile }, { command: process.execPath, args: ['-e', RECORDS_SIGTERM_PROVIDER] } ) @@ -414,9 +429,9 @@ describe.runIf(process.platform !== 'win32')('POSIX provider supervisor processe expect(exited).toEqual({ code: 137, signal: null }) // Timers may fire a tick early against another process's clock. - expect(signalledAt - endedAt).toBeGreaterThanOrEqual(1_000 - 20) - expect(exitedAt - signalledAt).toBeGreaterThanOrEqual(3_000 - 20) - expect(exitedAt - endedAt).toBeLessThan(PROVIDER_SUPERVISOR_MAX_STOP_MS + 1_000) + expect(signalledAt - endedAt).toBeGreaterThanOrEqual(stdinEndGraceMs - 20) + expect(exitedAt - signalledAt).toBeGreaterThanOrEqual(sigtermGraceMs - 20) + expect(exitedAt - endedAt).toBeLessThan(stdinEndGraceMs + sigtermGraceMs + 1_000) expect(alive(provider)).toBe(false) }) @@ -513,10 +528,10 @@ describe.runIf(process.platform !== 'win32')('POSIX provider supervisor processe it('lets a one-shot answer after its stdin ends and relays its exit code', async () => { const signalFile = join(tempDir(), 'provider-signal') const { supervisor, exit } = launchSupervisor( - { lifetime: 'one-shot' }, + { lifetime: 'one-shot', stdinEndGraceMs: 100 }, { ORCA_TEST_PROVIDER_SIGNAL_FILE: signalFile, - ORCA_TEST_PROVIDER_ANSWER_DELAY_MS: String(PROVIDER_STDIN_END_GRACE_MS * 1.5) + ORCA_TEST_PROVIDER_ANSWER_DELAY_MS: '250' }, { command: process.execPath, args: ['-e', ONE_SHOT_PROVIDER] } ) @@ -536,12 +551,13 @@ describe.runIf(process.platform !== 'win32')('POSIX provider supervisor processe ] as const)( 'reaps a one-shot that ignores its stdin end when its owner %s', async (_, signal, env) => { + const stdinEndGraceMs = 100 const { owner, pids } = await launchUnderOwner( - { lifetime: 'one-shot', sigtermGraceMs: 300 }, + { lifetime: 'one-shot', stdinEndGraceMs, sigtermGraceMs: 300 }, { ORCA_TEST_OWNER_ENDS_STDIN: '1', ...env } ) // Past the session grace: a stdin end alone must not have stopped it. - await new Promise((resolve) => setTimeout(resolve, PROVIDER_STDIN_END_GRACE_MS + 300)) + await new Promise((resolve) => setTimeout(resolve, stdinEndGraceMs + 150)) expect(alive(-pids.provider)).toBe(true) owner.kill(signal) diff --git a/src/main/runtime/orca-runtime-files-artifact-facade.test.ts b/src/main/runtime/orca-runtime-files-artifact-facade.test.ts index cf4a01ac462..c2ad9bd35cc 100644 --- a/src/main/runtime/orca-runtime-files-artifact-facade.test.ts +++ b/src/main/runtime/orca-runtime-files-artifact-facade.test.ts @@ -1,6 +1,7 @@ import { afterEach, expect, it, vi } from 'vitest' import { RuntimeFileCommands } from './orca-runtime-files' import { OrcaRuntimeService } from './orca-runtime' +import { RuntimeFileAccess } from './files/file-access' import { RuntimeFileReads } from './files/file-reads' import { RuntimeTerminalArtifacts } from './terminal/artifacts/terminal-artifacts' import type { RuntimeFileCommandHost } from './files/file-command-host' @@ -25,7 +26,7 @@ const reachedArtifactOwner = (): never => { afterEach(() => vi.restoreAllMocks()) -it('preserves artifact facade arity, optional slots, and the same artifact receiver', () => { +it('preserves artifact optional slots and the same artifact receiver', () => { const read = vi .spyOn(RuntimeTerminalArtifacts.prototype, 'readTerminalArtifactFile') .mockImplementation(reachedArtifactOwner) @@ -40,13 +41,6 @@ it('preserves artifact facade arity, optional slots, and the same artifact recei .mockImplementation(reachedArtifactOwner) const commands = new RuntimeFileCommands(host) - expect([ - commands.readTerminalArtifactFile.length, - commands.readTerminalArtifactPreview.length, - commands.writeTerminalArtifactFile.length, - commands.revokeTerminalFileGrantsForClient.length - ]).toEqual([4, 5, 5, 1]) - expect(() => commands.readTerminalArtifactFile('worktree', 'grant', '/artifact')).toThrow( 'artifact_owner_reached' ) @@ -91,81 +85,37 @@ it('preserves artifact facade arity, optional slots, and the same artifact recei } }) -const originalMethodMetadata = [ - ['openMobileFile', 4, true], - ['openMobileDiff', 4, true], - ['listRuntimeMarkdownDocuments', 1, true], - ['pathsExistRuntimeFiles', 2, true], - ['statRuntimeFile', 2, true], - ['readMobileFile', 2, true], - ['readFileExplorerPreview', 3, true], - ['readDocPreviewFile', 6, true], - ['readFileExplorerChunk', 4, true], - ['writeFileExplorerFile', 6, true], - ['writeFileExplorerFileBase64', 6, true], - ['writeFileExplorerFileBase64Chunk', 7, true], - ['createFileExplorerFile', 5, true], - ['createFileExplorerDir', 5, true], - ['createFileExplorerDirNoClobber', 5, true], - ['commitFileExplorerUpload', 6, true], - ['renameFileExplorerPath', 6, true], - ['copyFileExplorerPath', 6, true], - ['deleteFileExplorerPath', 6, true], - ['readFileExplorerDir', 2, true], - ['watchFileExplorer', 2, true], - ['closeFileExplorerWatchersForPath', 2, true], - ['restoreFileExplorerWatchersAfterFailedRemoval', 2, true], - ['forgetFileExplorerWatchersAfterRemoval', 2, false], - ['resolveTerminalPath', 7, true] -] as const +it.each(['command facade', 'installed runtime'])( + 'forwards omitted file arguments and optional positions through the %s', + (surface) => { + const returned: ReturnType = Promise.resolve({ + worktree: 'worktree', + relativePath: 'file', + kind: 'text', + opened: true + }) + const open = vi.spyOn(RuntimeFileAccess.prototype, 'openMobileFile').mockReturnValue(returned) + const commands = + surface === 'command facade' ? new RuntimeFileCommands(host) : new OrcaRuntimeService(null) + const position = { line: 12, column: 5 } -it('preserves the original public command arities and native async functions', () => { - const commands = new RuntimeFileCommands(host) - for (const [name, arity, async] of originalMethodMetadata) { - expect(commands[name].length, name).toBe(arity) - expect(Object.prototype.toString.call(commands[name]), name).toBe( - async ? '[object AsyncFunction]' : '[object Function]' - ) - } -}) - -it('installs the original public file command metadata on the actual runtime', () => { - const runtime = new OrcaRuntimeService(null) - const commands = { - openMobileFile: runtime.openMobileFile, - openMobileDiff: runtime.openMobileDiff, - listRuntimeMarkdownDocuments: runtime.listRuntimeMarkdownDocuments, - pathsExistRuntimeFiles: runtime.pathsExistRuntimeFiles, - statRuntimeFile: runtime.statRuntimeFile, - readMobileFile: runtime.readMobileFile, - readFileExplorerPreview: runtime.readFileExplorerPreview, - readDocPreviewFile: runtime.readDocPreviewFile, - readFileExplorerChunk: runtime.readFileExplorerChunk, - writeFileExplorerFile: runtime.writeFileExplorerFile, - writeFileExplorerFileBase64: runtime.writeFileExplorerFileBase64, - writeFileExplorerFileBase64Chunk: runtime.writeFileExplorerFileBase64Chunk, - createFileExplorerFile: runtime.createFileExplorerFile, - createFileExplorerDir: runtime.createFileExplorerDir, - createFileExplorerDirNoClobber: runtime.createFileExplorerDirNoClobber, - commitFileExplorerUpload: runtime.commitFileExplorerUpload, - renameFileExplorerPath: runtime.renameFileExplorerPath, - copyFileExplorerPath: runtime.copyFileExplorerPath, - deleteFileExplorerPath: runtime.deleteFileExplorerPath, - readFileExplorerDir: runtime.readFileExplorerDir, - watchFileExplorer: runtime.watchFileExplorer, - resolveTerminalPath: runtime.resolveTerminalPath - } - for (const [name, arity, async] of originalMethodMetadata) { - if (!(name in commands)) { - continue + expect(commands.openMobileFile('worktree', 'file')).toBe(returned) + expect(commands.openMobileFile('worktree', 'file', 'caller')).toBe(returned) + expect(commands.openMobileFile('worktree', 'file', undefined, position)).toBe(returned) + expect(commands.openMobileFile('worktree', 'file', undefined, undefined)).toBe(returned) + expect(open.mock.calls).toEqual([ + ['worktree', 'file'], + ['worktree', 'file', 'caller'], + ['worktree', 'file', undefined, position], + ['worktree', 'file', undefined, undefined] + ]) + const owner = open.mock.contexts[0] + expect(owner).toBeInstanceOf(RuntimeFileAccess) + for (const context of open.mock.contexts) { + expect(context).toBe(owner) } - const installed = Object.getOwnPropertyDescriptor(commands, name)?.value - expect(installed.length, name).toBe(arity) - expect(Object.prototype.toString.call(installed), name).toBe( - async ? '[object AsyncFunction]' : '[object Function]' - ) } -}) +) it('preserves omitted argument slots, owner receivers, and the returned promise', () => { const returned: ReturnType = Promise.resolve({ diff --git a/src/main/ssh/orcad-state-mutation-fence-heartbeat.test.ts b/src/main/ssh/orcad-state-mutation-fence-heartbeat.test.ts index 5896eda7ec0..fdc44d078a7 100644 --- a/src/main/ssh/orcad-state-mutation-fence-heartbeat.test.ts +++ b/src/main/ssh/orcad-state-mutation-fence-heartbeat.test.ts @@ -1,6 +1,6 @@ /** * A state mutation can outlast the activation fence's stale window, so it keeps the fence - * fresh while it runs. Run for real with a one-second beat and the real steal command. + * fresh while it runs. Run a short real heartbeat and the real steal command. */ import { existsSync, @@ -24,6 +24,8 @@ import { getRemoteHostPlatform } from './ssh-remote-platform' const posix = getRemoteHostPlatform('linux-x64') const STALE_SECONDS = 3 +const HEARTBEAT_SECONDS = 0.1 +const OBSERVE_HEARTBEATS_MS = 300 async function sh(command: string): Promise { return (await runProcess({ program: '/bin/sh', args: ['-c', command] })).stdout.trim() @@ -44,7 +46,10 @@ describe.skipIf(process.platform === 'win32')( }) // The mutation runs under the fence its run holds, generation `holder-1`. const mutation = (script: string): string => - serializedStateMutationCommand(base, script, 1, { lockDir: fence, token: 'holder-1' }) + serializedStateMutationCommand(base, script, HEARTBEAT_SECONDS, { + lockDir: fence, + token: 'holder-1' + }) afterEach(() => { rmSync(base, { recursive: true, force: true }) }) @@ -66,15 +71,15 @@ describe.skipIf(process.platform === 'win32')( .toBeTruthy() await backdate() - await pause(2_500) + await expect.poll(age, { timeout: 5_000, interval: 25 }).toBeLessThan(STALE_SECONDS) expect(age()).toBeLessThan(STALE_SECONDS) expect(await steal()).toBe('BUSY') // The host process dies (OOM, reboot of the session): nothing refreshes the fence now. process.kill(Number(readFileSync(pidFile, 'utf8').trim()), 'SIGKILL') - await pause(1_500) + await pause(OBSERVE_HEARTBEATS_MS) await backdate() - await pause(2_500) + await pause(OBSERVE_HEARTBEATS_MS) expect(age()).toBeGreaterThan(STALE_SECONDS) expect(await steal()).toMatch(/OK$/u) } finally { @@ -85,10 +90,14 @@ describe.skipIf(process.platform === 'win32')( it('stops refreshing a fence another run took over mid-mutation, and keeps its token', async () => { const run = spawnProcess({ program: '/bin/sh', args: ['-c', mutation('sleep 6')] }) try { - await pause(1_500) + await expect + .poll(() => existsSync(join(base, 'orcad-state-mutation.lock', 'pid'))) + .toBe(true) + await backdate() + await expect.poll(age, { timeout: 5_000, interval: 25 }).toBeLessThan(STALE_SECONDS) writeFileSync(join(fence, ORCAD_FENCE_OWNER_FILENAME), 'successor') await backdate() - await pause(2_500) + await pause(OBSERVE_HEARTBEATS_MS) expect(age()).toBeGreaterThan(STALE_SECONDS) expect(readFileSync(join(fence, ORCAD_FENCE_OWNER_FILENAME), 'utf8')).toBe('successor') } finally { @@ -105,13 +114,13 @@ describe.skipIf(process.platform === 'win32')( }, 20_000) it('stops refreshing once the mutation finishes, and never creates a missing fence', async () => { - await sh(mutation('sleep 2')) + await sh(mutation('sleep 0.3')) await backdate() - await pause(2_500) + await pause(OBSERVE_HEARTBEATS_MS) expect(age()).toBeGreaterThan(STALE_SECONDS) rmSync(fence, { recursive: true }) - await sh(serializedStateMutationCommand(base, 'sleep 2', 1, null)) + await sh(serializedStateMutationCommand(base, ':', HEARTBEAT_SECONDS, null)) expect(existsSync(fence)).toBe(false) }, 20_000) } diff --git a/src/renderer/src/components/native-chat/NativeChatMessageList.rail-history-jump.test.tsx b/src/renderer/src/components/native-chat/NativeChatMessageList.rail-history-jump.test.tsx index d2a19807321..f4d141ea388 100644 --- a/src/renderer/src/components/native-chat/NativeChatMessageList.rail-history-jump.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatMessageList.rail-history-jump.test.tsx @@ -113,6 +113,16 @@ describe('jumping from the rail while following the end', () => { let lastEventScrollTop = 0 beforeEach(() => { + vi.useFakeTimers({ + toFake: [ + 'Date', + 'performance', + 'setTimeout', + 'clearTimeout', + 'requestAnimationFrame', + 'cancelAnimationFrame' + ] + }) const undoLayout = stubLayout({ scrollGeometry: true, offsetChain: true }) const stubbedHeight = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'offsetHeight') restore = [ @@ -169,6 +179,7 @@ describe('jumping from the rail while following the end', () => { }) afterEach(() => { + vi.useRealTimers() vi.restoreAllMocks() Object.defineProperty(layout, 'aboveTranscriptPx', { configurable: true, @@ -202,8 +213,7 @@ describe('jumping from the rail while following the end', () => { fireEvent.scroll(element) } await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 0)) - await new Promise((resolve) => requestAnimationFrame(() => resolve())) + vi.advanceTimersByTime(16) }) } diff --git a/src/renderer/src/components/onboarding/OnboardingFlow.test.tsx b/src/renderer/src/components/onboarding/OnboardingFlow.test.tsx index 97dbc731a05..312a82c3b7b 100644 --- a/src/renderer/src/components/onboarding/OnboardingFlow.test.tsx +++ b/src/renderer/src/components/onboarding/OnboardingFlow.test.tsx @@ -218,7 +218,7 @@ describe('OnboardingFlow', () => { ) }) - it('renders onboarding inside a centered modal shell', () => { + it('exposes an accessible onboarding dialog', () => { const html = renderOnboardingFlow({ onboarding: getDefaultOnboardingState(), onOnboardingChange: vi.fn() @@ -226,11 +226,5 @@ describe('OnboardingFlow', () => { expect(html).toContain('role="dialog"') expect(html).toContain('aria-modal="true"') - expect(html).toContain('data-onboarding-modal="true"') - expect(html).toContain('h-[calc(100vh-2rem)]') - expect(html).toContain('rounded-xl') - expect(html).toContain('h-7 w-auto shrink-0 invert dark:invert-0') - expect(html).not.toContain('min-h-screen') - expect(html).not.toContain('background-color:#12181e') }) }) diff --git a/src/renderer/src/components/onboarding/OnboardingInlineCommandTerminal.test.ts b/src/renderer/src/components/onboarding/OnboardingInlineCommandTerminal.test.ts deleted file mode 100644 index e2abc2db64c..00000000000 --- a/src/renderer/src/components/onboarding/OnboardingInlineCommandTerminal.test.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import { - getNextTerminalReadyRetryAttempt, - READY_MAX_ATTEMPTS -} from './OnboardingInlineCommandTerminal' - -vi.mock('@/components/terminal-pane/TerminalPane', () => ({ - default: () => { - throw new Error('This suite must not render a terminal pane') - } -})) - -describe('getNextTerminalReadyRetryAttempt', () => { - it('stops scheduling readiness checks after the capped number of attempts', () => { - let attempt = 0 - let scheduledRetries = 0 - - while (true) { - const nextAttempt = getNextTerminalReadyRetryAttempt(attempt) - if (nextAttempt === null) { - break - } - scheduledRetries += 1 - attempt = nextAttempt - } - - expect(scheduledRetries).toBe(READY_MAX_ATTEMPTS) - expect(attempt).toBe(READY_MAX_ATTEMPTS) - expect(getNextTerminalReadyRetryAttempt(READY_MAX_ATTEMPTS)).toBeNull() - }) -}) diff --git a/src/renderer/src/components/settings/GeneralPane.test.ts b/src/renderer/src/components/settings/GeneralPane.test.ts index 05c963feed3..e5d5f6a8a94 100644 --- a/src/renderer/src/components/settings/GeneralPane.test.ts +++ b/src/renderer/src/components/settings/GeneralPane.test.ts @@ -9,6 +9,12 @@ import { } from './GeneralPane' import { matchesSettingsSearch } from './settings-search' +vi.mock('@/store', () => ({ + useAppStore: () => { + throw new Error('Pure settings helpers must not access the app store') + } +})) + vi.mock('@/components/terminal-pane/TerminalPane', () => ({ default: () => { throw new Error('This suite must not render a terminal pane') diff --git a/src/renderer/src/components/settings/browser-cookie-import-label.test.ts b/src/renderer/src/components/settings/browser-cookie-import-label.test.ts deleted file mode 100644 index 108a2e4c8d2..00000000000 --- a/src/renderer/src/components/settings/browser-cookie-import-label.test.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { beforeEach, describe, expect, it } from 'vitest' - -import en from '@/i18n/locales/en.json' -import { i18n, translate } from '@/i18n/i18n' - -// Keys for the "From " import-cookies menu entries. The label is built -// by interpolating the browser name into a single localized string; a regression -// where the name was concatenated directly onto "From" dropped the space and -// rendered e.g. "FromGoogle Chrome". -const IMPORT_FROM_KEYS = [ - 'auto.components.settings.BrowserProfileRow.c5a273a809', - 'auto.components.settings.BrowserUsePane.5301857d88' -] -const LAST_IMPORTED_FROM_KEY = 'auto.components.settings.BrowserUsePane.112f70adc4' - -function catalogEntry(key: string): unknown { - return key.split('.').reduce((node, part) => { - if (typeof node === 'object' && node !== null) { - return (node as Record)[part] - } - return undefined - }, en) -} - -describe('import-cookies "From " label', () => { - beforeEach(async () => { - await i18n.changeLanguage('en') - }) - - it('keeps a space between "From" and the browser name', () => { - for (const key of IMPORT_FROM_KEYS) { - expect(translate(key, 'From {{value0}}', { value0: 'Google Chrome' })).toBe( - 'From Google Chrome' - ) - expect(translate(key, 'From {{value0}}', { value0: 'Safari' })).toBe('From Safari') - } - }) - - it('stores the catalog entry with an interpolation placeholder', () => { - for (const key of IMPORT_FROM_KEYS) { - expect(catalogEntry(key)).toBe('From {{value0}}') - } - expect(catalogEntry(LAST_IMPORTED_FROM_KEY)).toBe('Last imported from {{value0}}') - }) - - it('keeps a space in the last-imported source label', () => { - expect( - translate(LAST_IMPORTED_FROM_KEY, 'Last imported from {{value0}}', { - value0: 'Google Chrome' - }) - ).toBe('Last imported from Google Chrome') - }) -}) diff --git a/src/renderer/src/components/settings/osc52-clipboard-copy.test.ts b/src/renderer/src/components/settings/osc52-clipboard-copy.test.ts deleted file mode 100644 index 2940148b50e..00000000000 --- a/src/renderer/src/components/settings/osc52-clipboard-copy.test.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { describe, expect, it } from 'vitest' -import en from '@/i18n/locales/en.json' -import es from '@/i18n/locales/es.json' -import ja from '@/i18n/locales/ja.json' -import ko from '@/i18n/locales/ko.json' -import zh from '@/i18n/locales/zh.json' - -// Why assert the catalog rather than the component: en.json is bundled as the `en` -// resource, so a catalog value wins over translate()'s code fallback. Editing a fallback -// alone renders nothing, and nothing else in CI compares the two. -describe('OSC 52 setting copy', () => { - const locales = { en, es, ja, ko, zh } - - for (const [name, locale] of Object.entries(locales)) { - it(`names Zellij and Grok in both ${name} OSC 52 setting descriptions`, () => { - const pane = locale.auto.components.settings.TerminalPane - for (const copy of [pane['69c64a479c'], pane['6e6480a7df']]) { - expect(copy).toContain('Zellij') - expect(copy).toContain('Grok') - } - }) - } - - // The notice is a one-shot the user sees exactly once, so a stale catalog value would - // ship unnoticed. Pin en.json to the code fallbacks verbatim. - it('keeps the migration notice catalog identical to its code fallbacks', () => { - const notice = en.auto.components.terminal.pane.osc52.clipboard.default.on.notice - expect(notice.title).toBe('TUI clipboard writes are now on by default') - expect(notice.description).toBe( - 'Zellij, tmux, Neovim and other terminal programs can now copy to your clipboard. Turn it off in Terminal settings.' - ) - expect(notice.action).toBe('Open Setting') - }) - - for (const [name, locale] of Object.entries({ es, ja, ko, zh })) { - it(`translates the ${name} migration notice rather than leaving English placeholders`, () => { - const notice = locale.auto.components.terminal.pane.osc52.clipboard.default.on.notice - const english = en.auto.components.terminal.pane.osc52.clipboard.default.on.notice - expect(notice.title).not.toBe(english.title) - expect(notice.description).not.toBe(english.description) - // Product names must survive translation — they are how the user recognizes the change. - expect(notice.description).toContain('Zellij') - }) - } -}) diff --git a/src/renderer/src/components/settings/setting-ownership.test.ts b/src/renderer/src/components/settings/setting-ownership.test.ts deleted file mode 100644 index 2edad0cb3cf..00000000000 --- a/src/renderer/src/components/settings/setting-ownership.test.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { getSettingOwnershipSummary } from './setting-ownership' - -describe('getSettingOwnershipSummary', () => { - it('documents Source Control AI as client defaults with host-scoped model choices', () => { - const summary = getSettingOwnershipSummary('sourceControlAiDefaults') - - expect(summary.ownership).toBe('client-default') - expect(summary.description).toContain('shared by this client') - expect(summary.description).toContain('model choices and discovery stay scoped to the host') - }) - - it('documents repository Source Control AI as project-host setup scoped', () => { - const summary = getSettingOwnershipSummary('repositorySourceControlAi') - - expect(summary.ownership).toBe('project-host-setup') - expect(summary.description).toContain('this project setup') - }) - - it('documents agent launch defaults as owned by the selected execution host', () => { - const summary = getSettingOwnershipSummary('agentLaunchDefaults') - - expect(summary.ownership).toBe('host-collection') - expect(summary.description).toContain('saved on the selected host') - }) - - it('keeps workspace directories and provider accounts explicitly host-aware', () => { - expect(getSettingOwnershipSummary('workspaceDirectory').ownership).toBe('host-override') - expect(getSettingOwnershipSummary('providerAccounts').ownership).toBe('provider-host') - }) - - it('documents quick commands as host-owned collections', () => { - const summary = getSettingOwnershipSummary('terminalQuickCommands') - - expect(summary.ownership).toBe('host-collection') - expect(summary.description).toContain('selected Orca host') - expect(summary.description).toContain('remain available in remote workspaces') - }) -}) diff --git a/src/renderer/src/components/sidebar/wsl-host-copy-catalog.test.ts b/src/renderer/src/components/sidebar/wsl-host-copy-catalog.test.ts deleted file mode 100644 index 5c966e9c604..00000000000 --- a/src/renderer/src/components/sidebar/wsl-host-copy-catalog.test.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { readFileSync } from 'node:fs' -import { join } from 'node:path' -import { describe, expect, it } from 'vitest' -import { z } from 'zod' - -// Why read the catalog: the shipped build resolves a key from en.json, not from the inline -// default, so changing copy under an existing key leaves the old words on screen. -const HostRemoveDialogCopySchema = z.object({ - auto: z.object({ - components: z.object({ - sidebar: z.object({ HostRemoveDialog: z.record(z.string(), z.string()) }) - }) - }) -}) - -const dialog = HostRemoveDialogCopySchema.parse( - JSON.parse(readFileSync(join(__dirname, '../../i18n/locales/en.json'), 'utf8')) -).auto.components.sidebar.HostRemoveDialog - -describe('WSL host removal copy', () => { - it('ships the copy that says removal ends the host’s terminals', () => { - expect(dialog.wslHostEndsTerminals).toBe( - 'Removes {{value0}} from Orca, ends its open terminals and stops Orca’s server in that distro. Files in the distro are untouched.' - ) - expect(dialog).not.toHaveProperty('wslHost') - }) -}) diff --git a/src/renderer/src/components/tab-bar/tab-session-history-switch.test.ts b/src/renderer/src/components/tab-bar/tab-session-history-switch.test.ts index 57c31c14722..e9c67415fee 100644 --- a/src/renderer/src/components/tab-bar/tab-session-history-switch.test.ts +++ b/src/renderer/src/components/tab-bar/tab-session-history-switch.test.ts @@ -46,7 +46,10 @@ vi.mock('@/runtime/local-runtime-capabilities', async (importOriginal) => ({ vi.mock('@/runtime/host-structured-agents', async (importOriginal) => ({ ...(await importOriginal>()), readHostStructuredAgents: () => - ['claude', 'codex', 'grok', 'omp', 'opencode', 'pi'].map((agent) => ({ agent, capabilities: {} })) + ['claude', 'codex', 'grok', 'omp', 'opencode', 'pi'].map((agent) => ({ + agent, + capabilities: {} + })) })) vi.mock('@/lib/renderer-app-platform', () => ({ getRendererAppPlatform: () => mocks.platform })) diff --git a/src/renderer/src/i18n/ja-technical-literal-mistranslations.test.ts b/src/renderer/src/i18n/ja-technical-literal-mistranslations.test.ts deleted file mode 100644 index c2134ebf818..00000000000 --- a/src/renderer/src/i18n/ja-technical-literal-mistranslations.test.ts +++ /dev/null @@ -1,67 +0,0 @@ -/** - * Guards literal-sense mistranslations (Push as 押す, run as 走る, Linear as - * リニア, ...) so bootstrap re-translation cannot silently regress them. - */ -import { describe, expect, it } from 'vitest' -import ja from './locales/ja.json' - -function collectValues(node: unknown, out: string[] = []): string[] { - if (typeof node === 'string') { - out.push(node) - } else if (node && typeof node === 'object') { - for (const value of Object.values(node)) { - collectValues(value, out) - } - } - return out -} - -function findByKey(node: unknown, key: string): string | undefined { - if (!node || typeof node !== 'object') { - return undefined - } - if (Array.isArray(node)) { - for (const item of node) { - const found = findByKey(item, key) - if (found !== undefined) { - return found - } - } - return undefined - } - const record = node as Record - if (typeof record[key] === 'string') { - return record[key] as string - } - for (const value of Object.values(record)) { - const found = findByKey(value, key) - if (found !== undefined) { - return found - } - } - return undefined -} - -describe('ja technical literal / sense fixes', () => { - it('translates the source control Push action as プッシュ, not 押す', () => { - expect(findByKey(ja, '95550cff15')).toBe('プッシュ') - }) - - it('translates the source control Pull action as プル, not 引く', () => { - expect(findByKey(ja, 'd64292a938')).toBe('プル') - }) - - it.each([ - ['走る', 'run as running on foot'], - ['走っている', 'running as running on foot'], - ['走らないで', "Don't run as don't run on foot"], - ['逃走', 'missed run as escape'], - ['労働者', 'worker as a labourer'], - ['対立', 'merge conflict as a dispute'], - ['使用法', 'usage as instructions for use'], - ['リニア', 'the Linear product name as the word linear'], - ['シャチ', 'the orca-linear skill name as the animal'] - ])('never renders %s (%s)', (literal) => { - expect(collectValues(ja).filter((value) => value.includes(literal))).toEqual([]) - }) -}) diff --git a/src/renderer/src/i18n/ko-ui-semantic-mistranslations.test.ts b/src/renderer/src/i18n/ko-ui-semantic-mistranslations.test.ts deleted file mode 100644 index 7f0e40ddea4..00000000000 --- a/src/renderer/src/i18n/ko-ui-semantic-mistranslations.test.ts +++ /dev/null @@ -1,68 +0,0 @@ -import fs from 'node:fs' - -import { describe, expect, it } from 'vitest' -import ko from './locales/ko.json' - -const correctedValues = { - 'auto.components.settings.AutoRenameBranchFromWorkSetting.d9b65054ef': - ') 작업을 요약하는 짧은 이름으로 변경됩니다. Orca가 직접 이름 붙인 브랜치만 이름을 바꾸며, 푸시된 후에는 이름을 바꾸지 않습니다.', - 'auto.components.settings.source.control.action.recipe.options.commitMessage': - '스테이징된 변경 사항에서 commit 메시지를 생성합니다.', - 'auto.components.settings.DevToolsPane.orcaCloudDescription': - '자사 클라우드 로그인의 개발자 전용 미리보기. 프로덕션에서는 숨겨집니다. 개발 환경에서는 ORCA_CLOUD_API_URL과 ORCA_CLOUD_CLIENT_ID가 설정되면 사이드바 계정 전환기에도 표시됩니다.', - 'auto.components.settings.EphemeralVmsPane.whatTitle': '이 스킬로 함께 하는 작업', - 'auto.components.settings.EphemeralVmsPane.recipes': '레시피', - 'auto.components.right.sidebar.SourceControl.a5e5a11090': - '모든 변경 사항 취소 실패 — 취소 전에 파일의 스테이징을 해제하지 못했습니다.', - 'auto.components.right.sidebar.SourceControl.6d7f2a47e5': '폴더의 변경 사항 취소', - 'auto.components.right.sidebar.source.control.discard.confirmation.40e9357b2a': - '이렇게 하면 HEAD에서 파일을 복원하고 파일 삭제를 취소합니다. 이 작업은 취소할 수 없습니다.', - 'auto.components.right.sidebar.source.control.primary.action.5a477d80cb': - '모든 변경 사항 스테이징', - 'auto.components.settings.DeveloperPermissionsPane.f903bf20b5': - '터미널과 개발 도구가 로컬 네트워크의 서비스에 연결할 수 있도록 허용합니다. macOS는 이 권한의 현재 상태를 Orca에 보고하지 않습니다.', - 'auto.components.sidebar.AddRemoteHostDialog.sshImportSynced': - '{{value0}} 호스트{{value1}}을(를) Orca에 추가했습니다.', - 'auto.components.editor.CheckRunJobs.1c0a4d7e02': '성공', - 'components.native-chat.approval.allow': '허용', - 'components.native-chat.approval.deny': '거부', - 'sessionHistory.settings.computersConsent': - '각 컴퓨터는 자체 에이전트 대화와 도구 출력의 검색 가능한 사본을 보관합니다. 이 데이터는 해당 컴퓨터 밖으로 전송되지 않습니다.', - 'sessionHistory.settings.open': '열기', - 'auto.components.settings.contrast.custom': '사용자 지정', - 'components.settings.TerminalInteraction.copyTrimsGutterDescription': - '복사한 텍스트에서 에이전트 출력의 왼쪽 여백을 제거합니다. 선택한 모든 줄에 공통된 들여쓰기만 제거됩니다.', - 'auto.components.settings.TerminalAppearanceSection.db270cc9a9': '커서 모양', - 'auto.components.settings.TerminalAppearanceSection.b9f1804422': '커서 불투명도', - 'auto.components.settings.TerminalWindowSection.a2d9f095a7': '커서 텍스트' -} as const - -function getLocaleValue(path: string): unknown { - return path.split('.').reduce((node, segment) => { - if (!node || typeof node !== 'object' || Array.isArray(node)) { - return undefined - } - return (node as Record)[segment] - }, ko) -} - -describe('Korean UI semantic mistranslation fixes', () => { - it('keeps the corrected values in the Korean catalog', () => { - for (const [path, expected] of Object.entries(correctedValues)) { - expect(getLocaleValue(path), path).toBe(expected) - } - }) - - it('keeps the corrected values in the translation overrides', () => { - const overrides = JSON.parse( - fs.readFileSync( - new URL('../../../../config/scripts/locale-ko-key-overrides.json', import.meta.url), - 'utf8' - ) - ) as Record - - for (const [path, expected] of Object.entries(correctedValues)) { - expect(overrides[path]?.ko, path).toBe(expected) - } - }) -}) diff --git a/src/renderer/src/i18n/locale-english-regression.test.ts b/src/renderer/src/i18n/locale-english-regression.test.ts deleted file mode 100644 index 8f120f1d23c..00000000000 --- a/src/renderer/src/i18n/locale-english-regression.test.ts +++ /dev/null @@ -1,97 +0,0 @@ -/** - * #10770 merged from a base predating #8549, so its stale locale copies - * overwrote ~185 already-translated strings per locale back to English. A - * present catalog value always beats the English `translate()` fallback, so - * those strings render English at runtime with nothing to signal the loss. - * - * Later translation passes (#11205 and friends) independently re-covered most of - * ja/ko/zh, so the re-applied set is only what is still English at HEAD: es 182, - * zh 55, ja 32, ko 17. The keys sampled below stayed English in all four. - * - * These are keys whose English source is UNCHANGED, so an English catalog value - * can only be a revert. Locale-wide coverage is deliberately not asserted here: - * genuinely-untranslated new keys are normal, and a blanket gate would fail on - * every future English-source addition. - */ -import { describe, expect, it } from 'vitest' -import en from './locales/en.json' -import es from './locales/es.json' -import ja from './locales/ja.json' -import ko from './locales/ko.json' -import zh from './locales/zh.json' - -const catalogs = { es, ja, ko, zh } - -const WORKSPACE_CLEANUP_BROWSE_TRANSLATIONS = { - zh: { - selectionWithheldOne: '当前筛选条件隐藏了 1 个已选工作区,已取消选择。', - selectionWithheld: '当前筛选条件隐藏了 {{value0}} 个已选工作区,已取消选择。', - selectAllCountOne: '选择 1 个通过安全检查的工作区', - selectAllCount: '选择全部 {{value0}} 个通过安全检查的工作区' - }, - ko: { - selectionWithheldOne: - '선택한 워크스페이스 1개가 현재 필터에 의해 숨겨져 선택이 해제되었습니다.', - selectionWithheld: - '선택한 워크스페이스 {{value0}}개가 현재 필터에 의해 숨겨져 선택이 해제되었습니다.', - selectAllCountOne: '안전 검사를 통과한 워크스페이스 1개 선택', - selectAllCount: '안전 검사를 통과한 워크스페이스 {{value0}}개 모두 선택' - } -} as const - -function lookup(catalog: unknown, key: string): string | undefined { - const value = key - .split('.') - .reduce( - (node, part) => - node && typeof node === 'object' ? (node as Record)[part] : undefined, - catalog - ) - return typeof value === 'string' ? value : undefined -} - -// Sampled across the reverted set: an unconditional sidebar filter row, plugin -// command failures on two surfaces, a settings description, and a status-bar -// metric — so a partial re-revert cannot pass by covering one namespace. -// (workingSetDescription was re-translated upstream for ja/ko; it still guards -// es/zh, and every other key here was English in all four.) -const REVERTED_KEYS = [ - 'auto.components.sidebar.SidebarFilter.detachedHead', - 'auto.App.pluginCommandFailed', - 'auto.components.WorktreeJumpPalette.pluginCommandFailed', - 'auto.hooks.useSettingsNavigationMetadata.pluginsDescription', - 'auto.components.status.bar.resource.memory.metric.workingSetDescription', - 'auto.components.settings.EphemeralVmsPane.recipesHelp' -] - -describe('locale catalogs reverted by a stale branch base (#10770)', () => { - it.each(Object.entries(catalogs))('%s translates the reverted keys', (_code, catalog) => { - for (const key of REVERTED_KEYS) { - const english = lookup(en, key) - const localized = lookup(catalog, key) - expect(english, `${key} missing from en.json`).toBeDefined() - expect(localized, `${key} missing from catalog`).toBeDefined() - expect(localized?.trim()).not.toBe('') - expect(localized, `${key} fell back to the English source`).not.toBe(english) - } - }) - - // The same stale base regressed English itself: the catalog kept pre-plugins - // copy while EphemeralVmsPane.tsx already shipped the newer sentence, and a - // present catalog value wins over the source fallback. - it('keeps the English catalog in step with its live source string', () => { - expect(lookup(en, 'auto.components.settings.EphemeralVmsPane.recipesHelp')).toBe( - 'Recipes from orca.yaml and enabled plugins show up here, ready to launch a workspace on.' - ) - }) - - it.each(Object.entries(WORKSPACE_CLEANUP_BROWSE_TRANSLATIONS))( - '%s retains the merged workspace cleanup browse translations', - (code, translations) => { - const catalog = code === 'zh' ? zh : ko - for (const [key, expected] of Object.entries(translations)) { - expect(lookup(catalog, `components.workspace.cleanup.browse.${key}`)).toBe(expected) - } - } - ) -}) diff --git a/src/renderer/src/i18n/native-chat-locales.test.ts b/src/renderer/src/i18n/native-chat-locales.test.ts deleted file mode 100644 index e8652e66a5f..00000000000 --- a/src/renderer/src/i18n/native-chat-locales.test.ts +++ /dev/null @@ -1,90 +0,0 @@ -import { describe, expect, it } from 'vitest' -import en from './locales/en.json' -import es from './locales/es.json' -import fr from './locales/fr.json' -import ja from './locales/ja.json' -import ko from './locales/ko.json' -import zh from './locales/zh.json' - -const localizedCatalogs = { es, ja, ko, zh } -const englishSetting = en.auto.components.settings.ExperimentalPane.nativeChat -const englishSearch = en.auto.components.settings.experimental.search.nativeChat -const englishComposer = en.components['native-chat'].composer -const localizedEffortValues = ['minimal', 'low', 'medium', 'high', 'xhigh', 'max', 'ultra'] as const - -describe('native chat locale copy', () => { - it.each(Object.entries({ en, es, fr, ja, ko, zh }))( - '%s covers chat naming controls and ordinary labels', - (_code, catalog) => { - const names = catalog.settings.chat.names - expect(Object.keys(names).sort()).toEqual(Object.keys(en.settings.chat.names).sort()) - for (const value of Object.values(names)) { - expect(value.trim()).not.toBe('') - } - expect(names.description).toContain('Claude Chat') - expect(names.description).toContain('Codex Chat') - expect(catalog.auto.components.settings.Settings['17bdee4ff1']).not.toContain('Git') - expect(catalog.auto.components.settings.Settings['43b68e10f0']).not.toContain('Git') - } - ) - - it.each(Object.entries(localizedCatalogs))( - '%s falls back to current Chat UI copy until it is translated', - (_code, catalog) => { - const setting = catalog.auto.components.settings.ExperimentalPane.nativeChat - const search = catalog.auto.components.settings.experimental.search.nativeChat - expect(englishSetting.description).toContain('Chat UI') - expect(englishSetting.copy).toContain('structured chat') - expect(englishSearch.description).toContain('Chat UI') - expect(setting).not.toHaveProperty('description') - expect(setting).not.toHaveProperty('copy') - expect(setting).not.toHaveProperty('defaultCopy') - expect(search).not.toHaveProperty('description') - expect(search.grok).toBe('grok') - const composer = catalog.components['native-chat'].composer - for (const key of [ - 'model', - 'effort', - 'fastMode', - 'thinking', - 'sessionOptions', - 'chooseInAgentPicker', - 'toggleOption', - 'sentNotConfirmed' - ] as const) { - expect(composer[key].trim()).not.toBe('') - expect(composer[key]).not.toBe(englishComposer[key]) - } - for (const key of ['fast', ...localizedEffortValues] as const) { - expect(composer.optionValue[key].trim()).not.toBe('') - expect(composer.optionValue[key]).not.toBe(englishComposer.optionValue[key]) - } - for (const key of ['minimal', 'medium', 'xhigh'] as const) { - expect(composer.optionValueShort[key].trim()).not.toBe('') - expect(composer.optionValueShort[key]).not.toBe(englishComposer.optionValueShort[key]) - } - } - ) -}) - -describe('account-aware Send fixes', () => { - it.each(Object.entries({ en, es, fr, ja, ko, zh }))( - '%s includes the same fixes for Send and post-send failures', - (_locale, catalog) => { - const words = catalog.components['native-chat'].failureWords - expect(words.claudeSystemNotSignedIn).toContain('`{{loginCommand}}`') - expect(words.codexSystemNotSignedIn).toContain('`{{loginCommand}}`') - expect(words.claudeManagedNotSignedIn).not.toContain('/login') - expect(words.codexManagedNotSignedIn).not.toContain('`codex login`') - expect(words.claudeManagedNotSignedIn).not.toContain('{{loginCommand}}') - expect(words.codexManagedNotSignedIn).not.toContain('{{loginCommand}}') - expect(words.agentCommandNotSignedIn).toContain('{{agent}}') - expect(words.agentCommandNotSignedIn).toContain('`{{loginCommand}}`') - expect(words.interactiveAgentNotSignedIn).toContain('`{{slashCommand}}`') - expect(words.cliMissing).toContain('{{agent}}') - for (const key of ['claudeManagedNotSignedIn', 'codexManagedNotSignedIn'] as const) { - expect(words[key].trim()).not.toBe('') - } - } - ) -}) diff --git a/src/renderer/src/i18n/relative-duration-copy-localization.test.ts b/src/renderer/src/i18n/relative-duration-copy-localization.test.ts deleted file mode 100644 index 65e5175e639..00000000000 --- a/src/renderer/src/i18n/relative-duration-copy-localization.test.ts +++ /dev/null @@ -1,70 +0,0 @@ -/** - * Guards the localized relative-duration copy for the Cmd+J session-age badge and the - * native-chat turn row. The English source values ("{{value0}}m/h/d", "Working for N") are - * ambiguous, so bootstrap re-translation rendered them as a metre (米), a location particle - * (で / 에서), or a French infinitive. These are the reviewed values. - */ -import { describe, expect, it } from 'vitest' -import fr from './locales/fr.json' -import ja from './locales/ja.json' -import ko from './locales/ko.json' -import zh from './locales/zh.json' - -const catalogs = { fr, ja, ko, zh } as const - -function isStringRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null && !Array.isArray(value) -} - -function getByPath(node: unknown, path: string): string | undefined { - let current: unknown = node - for (const key of path.split('.')) { - if (!isStringRecord(current)) { - return undefined - } - current = current[key] - } - return typeof current === 'string' ? current : undefined -} - -type Locale = keyof typeof catalogs - -const AGE_PATHS = 'components.cmd-j.paletteSessionAge' -const STATUS_PATHS = 'components.native-chat.status' - -const cases: [Locale, string, string][] = [ - // Cmd+J session-age badge: the unit must be a real duration word, not a bare Latin "m/h/d". - ['zh', `${AGE_PATHS}.minutes`, '{{value0}}分'], - ['zh', `${AGE_PATHS}.hours`, '{{value0}}小时'], - ['zh', `${AGE_PATHS}.days`, '{{value0}}天'], - ['zh', `${AGE_PATHS}.underOneMinute`, '<1分钟'], - ['ja', `${AGE_PATHS}.hours`, '{{value0}}時間'], - ['ja', `${AGE_PATHS}.days`, '{{value0}}日'], - ['ja', `${AGE_PATHS}.underOneMinute`, '<1分'], - ['ko', `${AGE_PATHS}.minutes`, '{{value0}}분'], - ['ko', `${AGE_PATHS}.hours`, '{{value0}}시간'], - ['ko', `${AGE_PATHS}.days`, '{{value0}}일'], - // Native-chat turn row: elapsed time, not a recipient — "working/worked for ", - // never the location reading ("works at/for N"). - ['zh', `${STATUS_PATHS}.workingFor`, '已工作 {{value0}}'], - ['zh', `${STATUS_PATHS}.workedFor`, '工作了 {{value0}}'], - ['ja', `${STATUS_PATHS}.workingFor`, '{{value0}} 処理中'], - ['ja', `${STATUS_PATHS}.workedFor`, '{{value0}} で完了'], - ['ko', `${STATUS_PATHS}.workingFor`, '{{value0}} 작업 중'], - ['ko', `${STATUS_PATHS}.workedFor`, '{{value0}} 작업 완료'], - ['fr', `${STATUS_PATHS}.workingFor`, 'En cours depuis {{value0}}'], - ['fr', `${STATUS_PATHS}.workedFor`, 'A travaillé pendant {{value0}}'], - // "turn" is a conversation turn (回合 / 턴 / tour), not a road turn (转弯 / 회전 / virage). - ['zh', `${STATUS_PATHS}.toggleDetails`, '切换回合详情'], - ['ko', `${STATUS_PATHS}.toggleDetails`, '턴 세부정보 전환'], - ['fr', `${STATUS_PATHS}.toggleDetails`, 'Basculer les détails du tour'], - // Workspace-cleanup durations keep the day unit localized too. - ['ja', 'components.workspace.cleanup.browse.chip.idleDays', 'アイドル {{value0}}日以上'], - ['ko', 'components.workspace.cleanup.relativeTime.daysAgo', '{{value0}}일 전'] -] - -describe('relative-duration copy localization', () => { - it.each(cases)('%s %s', (locale, path, expected) => { - expect(getByPath(catalogs[locale], path), path).toBe(expected) - }) -}) diff --git a/src/renderer/src/i18n/settings-status-label-localization.test.ts b/src/renderer/src/i18n/settings-status-label-localization.test.ts deleted file mode 100644 index 1bb8fa312c0..00000000000 --- a/src/renderer/src/i18n/settings-status-label-localization.test.ts +++ /dev/null @@ -1,168 +0,0 @@ -/** - * Status pills in Settings and Stats used to be built from bare string literals - * inside local helper functions, so they stayed English no matter which language - * was selected. The coverage audit does not reach them: it inspects JSX - * attributes and object properties, not values returned by helpers. - * - * These assertions pin the catalog contract for those helpers. A future refactor - * that drops `translate()` and returns a literal again would leave the key - * missing from `en.json` and fail here. - */ -import { describe, expect, it } from 'vitest' -import en from './locales/en.json' -import es from './locales/es.json' -import ja from './locales/ja.json' -import ko from './locales/ko.json' -import zh from './locales/zh.json' - -const LOCALE_CATALOGS = { es, ja, ko, zh } - -function lookupIn(catalog: unknown, key: string): string | undefined { - const value = key - .split('.') - .reduce( - (node, part) => - node && typeof node === 'object' ? (node as Record)[part] : undefined, - catalog - ) - return typeof value === 'string' ? value : undefined -} - -function lookup(key: string): string | undefined { - return lookupIn(en, key) -} - -function placeholdersOf(value: string): string[] { - return [...new Set(value.match(/\{\{[^}]+\}\}/g) ?? [])].sort() -} - -const REQUIRED_KEYS: Record = { - // DeveloperPermissionsPane — macOS permission rows - 'auto.components.settings.DeveloperPermissionsPane.statusGranted': 'Granted', - 'auto.components.settings.DeveloperPermissionsPane.statusDenied': 'Denied', - 'auto.components.settings.DeveloperPermissionsPane.statusNotRequested': 'Not requested', - 'auto.components.settings.DeveloperPermissionsPane.statusRestricted': 'Restricted', - 'auto.components.settings.DeveloperPermissionsPane.statusUnsupported': 'macOS only', - 'auto.components.settings.DeveloperPermissionsPane.statusEntitled': 'Entitled', - 'auto.components.settings.DeveloperPermissionsPane.statusCheckManually': 'Check manually', - 'auto.components.settings.DeveloperPermissionsPane.actionRequest': 'Request', - 'auto.components.settings.DeveloperPermissionsPane.actionTriggerPrompt': 'Trigger Prompt', - 'auto.components.settings.DeveloperPermissionsPane.actionOpenSettings': 'Open Settings', - // ComputerUsePane — permission rows - 'auto.components.settings.ComputerUsePane.statusGranted': 'Granted', - 'auto.components.settings.ComputerUsePane.statusUnsupported': 'macOS only', - 'auto.components.settings.ComputerUsePane.statusNotEnabled': 'Not enabled', - // Linear setup checklist — unverifiable skill scan (Settings pane + Task Sources card) - 'auto.components.settings.LinearAgentSkillGuide.setupUnverified': 'Cannot verify', - 'auto.components.settings.TaskSourceProviderCard.statusUnverified': 'Cannot verify', - // Source-control CLI integration cards - 'auto.components.settings.cli.source.control.integration.cards.statusConnected': 'Connected', - 'auto.components.settings.cli.source.control.integration.cards.statusUnavailable': 'Unavailable', - 'auto.components.settings.cli.source.control.integration.cards.statusNotInstalled': - 'Not installed', - 'auto.components.settings.cli.source.control.integration.cards.statusNotAuthenticated': - 'Not authenticated', - // Stats — provider rows - 'auto.components.stats.usage.overview.sections.statusScanning': 'Scanning', - 'auto.components.stats.usage.overview.sections.statusEnabled': 'Enabled', - 'auto.components.stats.usage.overview.sections.statusOff': 'Off' -} - -const INTERPOLATED_KEYS: Record = { - 'auto.components.stats.StatsPane.trackingSince': ['{{value0}}'], - 'auto.components.settings.computerUseSummary.permissionsRequired_one': [], - 'auto.components.settings.computerUseSummary.permissionsRequired_other': ['{{value0}}'], - 'auto.components.settings.computerUseSummary.unavailableDescription': ['{{value0}}'], - 'auto.components.settings.OrchestrationSkillAgentCoverage.fullCoverage_one': [], - 'auto.components.settings.OrchestrationSkillAgentCoverage.fullCoverage_other': ['{{value0}}'], - 'auto.components.settings.OrchestrationSkillAgentCoverage.partialCoverage': [ - '{{value0}}', - '{{value1}}' - ] -} - -// Base keys i18next resolves through CLDR plural categories (`_`). -const PLURAL_KEYS = [ - 'auto.components.settings.computerUseSummary.permissionsRequired', - 'auto.components.settings.OrchestrationSkillAgentCoverage.fullCoverage' -] - -function pluralCategories(locale: string): string[] { - return [...new Intl.PluralRules(locale).resolvedOptions().pluralCategories] -} - -// A locale owes `_other` plus whichever of its categories English also spells -// out. Categories English never defines (es `many`, only reached past 1e6) fall -// back to the English source instead of rendering wrong, so they aren't gated. -function requiredPluralSuffixes(locale: string, base: string): string[] { - return pluralCategories(locale).filter( - (category) => category === 'other' || lookup(`${base}_${category}`) !== undefined - ) -} - -describe('settings and stats status labels', () => { - it.each(Object.entries(REQUIRED_KEYS))('keeps %s in the English catalog', (key, expected) => { - expect(lookup(key)).toBe(expected) - }) - - it.each(Object.entries(INTERPOLATED_KEYS))( - 'keeps the placeholders of %s intact', - (key, placeholders) => { - const value = lookup(key) - expect(value).toBeDefined() - for (const placeholder of placeholders) { - expect(value).toContain(placeholder) - } - } - ) - - it('keeps the orchestration usage summaries in the catalog', () => { - for (const id of [ - 'handoffSummary', - 'worktreeHandoffSummary', - 'childSequenceSummary', - 'childParallelSummary', - 'prSplitSummary' - ]) { - expect(lookup(`auto.lib.orchestration.usage.examples.${id}`)).toBeTruthy() - } - }) -}) - -// Translation lands incrementally and a missing key renders the English -// `translate()` fallback, so absence is fine — a *present* value that drops a -// placeholder or invents one interpolates to nothing at runtime, and a plural -// family translated only halfway silently reverts to English for some counts. -describe('shipped locale catalogs', () => { - it.each(Object.entries(LOCALE_CATALOGS))( - '%s keeps the English placeholders of every message it translates', - (_locale, catalog) => { - for (const key of Object.keys(INTERPOLATED_KEYS)) { - const localized = lookupIn(catalog, key) - if (localized === undefined) { - continue - } - const english = lookup(key) - expect(english, `${key} missing from en.json`).toBeDefined() - expect(placeholdersOf(localized), `${key} placeholders drifted`).toEqual( - placeholdersOf(english ?? '') - ) - } - } - ) - - it.each(Object.entries(LOCALE_CATALOGS))( - '%s completes every plural family it translates', - (locale, catalog) => { - for (const base of PLURAL_KEYS) { - const suffixes = [...new Set([...pluralCategories(locale), ...pluralCategories('en')])] - if (!suffixes.some((suffix) => lookupIn(catalog, `${base}_${suffix}`))) { - continue - } - for (const suffix of requiredPluralSuffixes(locale, base)) { - expect(lookupIn(catalog, `${base}_${suffix}`), `${base}_${suffix} missing`).toBeTruthy() - } - } - } - ) -}) diff --git a/src/renderer/src/i18n/technical-literal-catalog-values.test.ts b/src/renderer/src/i18n/technical-literal-catalog-values.test.ts deleted file mode 100644 index 8ce7eb7cb5b..00000000000 --- a/src/renderer/src/i18n/technical-literal-catalog-values.test.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { describe, expect, it } from 'vitest' - -import en from './locales/en.json' -import es from './locales/es.json' -import ko from './locales/ko.json' -import zh from './locales/zh.json' - -const repairedEntries = [ - ['es', 'auto.components.sidebar.AddRepoCreateStep.0ae45b8238'], - ['es', 'auto.components.settings.IntegrationsPane.e74de656ce'], - ['es', 'auto.components.settings.IntegrationsPane.51000487c4'], - ['es', 'auto.components.settings.RepositoryHooksSection.925f9e0dc4'], - ['es', 'auto.components.settings.RepositoryHooksSection.c90b858573'], - ['es', 'auto.components.settings.RepositoryHooksSection.32f417fe17'], - ['es', 'auto.components.settings.RepositoryIconPicker.03ca1a4e9b'], - ['es', 'auto.components.right.sidebar.index.6306b48afd'], - ['es', 'auto.components.feature.wall.ReviewAnimatedVisual.8df4d52b68'], - ['es', 'auto.components.editor.CombinedDiffViewer.8368d256ec'], - ['es', 'auto.components.editor.MarkdownTemplatePicker.22cd94426f'], - ['ko', 'auto.components.terminal.pane.stale.agent.row.090d607412'], - ['ko', 'auto.components.settings.IntegrationsPane.51000487c4'], - ['ko', 'auto.components.settings.git.search.upstream'], - ['ko', 'auto.components.right.sidebar.index.6306b48afd'], - ['ko', 'auto.components.feature.wall.FeatureWallSetupWorkflowActions.5c5b65044e'], - ['ko', 'auto.components.editor.CombinedDiffViewer.8368d256ec'], - ['ko', 'auto.components.editor.MarkdownTemplatePicker.22cd94426f'], - ['zh', 'auto.lib.terminal.shortcut.capture.notification.0ab0cd001a'], - ['zh', 'auto.components.github.IssueSourceSelector.643d7e9496'], - ['zh', 'auto.components.sparse.SparseCheckoutPresetSelect.ddbcaef7be'], - ['zh', 'auto.components.sidebar.AddRepoCreateStep.0ae45b8238'], - ['zh', 'auto.components.settings.AutoRenameBranchPromptEditor.ebb942a2ec'], - ['zh', 'auto.components.settings.BaseRefPicker.915ad97875'], - ['zh', 'auto.components.settings.IntegrationsPane.e74de656ce'], - ['zh', 'auto.components.settings.IntegrationsPane.51000487c4'], - ['zh', 'auto.components.settings.RepositoryHooksSection.39da2ae12f'], - ['zh', 'auto.components.settings.RepositoryHooksSection.925f9e0dc4'], - ['zh', 'auto.components.settings.RepositoryHooksSection.c90b858573'], - ['zh', 'auto.components.settings.RepositoryHooksSection.32f417fe17'], - ['zh', 'auto.components.settings.SparsePresetSettingsSection.fde7ff2cc3'], - ['zh', 'auto.components.settings.git.search.upstream'], - ['zh', 'auto.components.settings.mobile.emulator.search.84e5706975'], - ['zh', 'auto.components.settings.repository.search.603c68b68c'], - ['zh', 'auto.components.right.sidebar.index.6306b48afd'], - ['zh', 'auto.components.feature.wall.BrowserAnimatedVisual.7da6eed7bf'], - ['zh', 'auto.components.feature.wall.WorkbenchAnimatedVisual.defe550fe2'], - ['zh', 'auto.components.feature.wall.WorkbenchAnimatedVisual.4371cc9931'], - ['zh', 'auto.components.feature.wall.ReviewAnimatedVisual.8df4d52b68'], - ['zh', 'auto.components.editor.CombinedDiffViewer.8368d256ec'], - ['zh', 'auto.components.editor.MarkdownTemplatePicker.22cd94426f'] -] as const - -const catalogs = { es, ko, zh } as const - -function readValue(catalog: Record, key: string): unknown { - return key.split('.').reduce((value, part) => { - if (typeof value !== 'object' || value === null || Array.isArray(value)) { - return undefined - } - return (value as Record)[part] - }, catalog) -} - -describe('technical literal catalog repairs (#13121)', () => { - it('keeps every repaired value identical to English', () => { - expect(repairedEntries).toHaveLength(41) - - for (const [locale, key] of repairedEntries) { - expect(readValue(catalogs[locale], key), `${locale}:${key}`).toBe(readValue(en, key)) - } - }) -}) diff --git a/src/renderer/src/i18n/zh-technical-literal-mistranslations.test.ts b/src/renderer/src/i18n/zh-technical-literal-mistranslations.test.ts deleted file mode 100644 index 6273265d5b3..00000000000 --- a/src/renderer/src/i18n/zh-technical-literal-mistranslations.test.ts +++ /dev/null @@ -1,139 +0,0 @@ -/** - * Issue #9574 — high-confidence Simplified Chinese semantic fixes. - * Guards the anchor GH PR mistranslation and a sample of technical literals / - * clear sense errors so bootstrap re-translation cannot silently regress them. - */ -import { describe, expect, it } from 'vitest' -import zh from './locales/zh.json' - -function findByKey(node: unknown, key: string): string | undefined { - if (!node || typeof node !== 'object') { - return undefined - } - if (Array.isArray(node)) { - for (const item of node) { - const found = findByKey(item, key) - if (found !== undefined) { - return found - } - } - return undefined - } - const record = node as Record - if (typeof record[key] === 'string') { - return record[key] as string - } - for (const value of Object.values(record)) { - const found = findByKey(value, key) - if (found !== undefined) { - return found - } - } - return undefined -} - -describe('zh technical literal / sense fixes (#9574)', () => { - it('keeps GH PR as a technical literal (not 生长激素受体)', () => { - expect(findByKey(zh, '1b91db7e14')).toBe('GH PR') - }) - - it('keeps CLI / product technical strings un-translated', () => { - expect(findByKey(zh, 'fe119187bb')).toBe('--model sonnet') - expect(findByKey(zh, '5c5b65044e')).toBe('pnpm install') - expect(findByKey(zh, '5af8251002')).toBe('SCSS') - expect(findByKey(zh, '97e96cc027')).toBe('/goal') - expect(findByKey(zh, 'f62ce91ade')).toBe('origin') - expect(findByKey(zh, '79afc6772b')).toBe('orca.yaml') - }) - - it('uses the correct sense for short UI verbs/nouns', () => { - expect(findByKey(zh, 'ac037cfac2')).toBe('移动') // Move, not 手机 - expect(findByKey(zh, '1b24a32d3a')).toBe('内存') // Memory, not 记忆 - expect(findByKey(zh, '8cde1a2fb0')).toBe('暂存') // Stage, not 阶段 - expect(findByKey(zh, 'af2b07bda5')).toBe('状态:{{value0}}') // State, not 州 - expect(findByKey(zh, 'e070e8aeba')).toBe('竖线') // Bar cursor - expect(findByKey(zh, '52854a5608')).toBe('块状') // Block cursor - }) - - it('preserves brand names in product copy', () => { - expect(findByKey(zh, '855a76343a')).toBe('从 Ghostty 导入') - expect(findByKey(zh, 'ff450194cd')).toBe('Kagi 私密会话链接') - }) -}) - -// #12881 — the status bar provider usage surfaces settle on 使用情况, matching the Kimi/MiniMax -// entries that already used it. Scope is those surfaces, not the word "usage" everywhere: a -// measured volume ("Daily usage" 每日使用量, "Refresh Claude usage" 刷新 Claude 使用量) stays 使用量. -describe('zh provider usage wording (#12881)', () => { - it('renders every " Usage" label as " 使用情况"', () => { - for (const [key, brand] of [ - // status bar item menu - ['3885eb74d8', 'Claude'], - ['c0909c686e', 'Codex'], - ['c1df0d67ec', 'Gemini'], - ['antigravityUsage', 'Antigravity'], - ['8c86cd77b0', 'OpenCode Go'], - ['5e59007df4', 'Kimi'], - ['3bbf140864', 'MiniMax'], - ['grokUsageMenu', 'Grok'], - // Settings > Appearance search index — mirrors the menu labels - ['9dc15020d7', 'Claude'], - ['54b1acf24f', 'Codex'], - ['5bfb874d05', 'Gemini'], - ['antigravityUsageTitle', 'Antigravity'], - ['bc046e7899', 'OpenCode Go'], - ['3a6c028ea8', 'Kimi'], - ['0f08f6b483', 'MiniMax'], - ['f8e2a1c4b6', 'Grok'], - // Settings > Accounts search index - ['733f9e2a93', 'MiniMax'], - ['f4a8c2e1b7', 'Grok (xAI)'] - ]) { - expect(findByKey(zh, key), key).toBe(`${brand} 使用情况`) - } - }) - - it('leaves the Kimi and Moonshot search keywords untransliterated', () => { - expect(findByKey(zh, '40e5c3c285')).toBe('kimi') // not 基米 - expect(findByKey(zh, '35565867cb')).toBe('moonshot') // not 登月计划 ("moon landing programme") - }) - - it('keeps the status bar toggle descriptions on 使用情况', () => { - expect(findByKey(zh, 'antigravityUsageDescription')).toBe( - '在状态栏中显示 Antigravity 订阅使用情况。' - ) - expect(findByKey(zh, 'e7d1b0f3a5')).toBe('显示来自 Grok CLI OAuth 的 Grok 每周额度使用情况。') - expect(findByKey(zh, 'grokToggleDescription')).toBe( - '通过 Grok CLI 登录后显示 Grok 订阅额度使用情况。' - ) - expect(findByKey(zh, 'antigravityToggleDescription')).toBe( - '显示当前工作区的 Antigravity 订阅使用情况。' - ) - }) - - it('uses one wording for the "Open usage details" labels', () => { - for (const [key, brand] of [ - ['fda8146810', 'Kimi'], - ['629251f4b6', 'OpenCode Go'], - ['d2375976eb', 'Gemini'], - ['06741a2f3d', 'MiniMax'], - ['antigravityUsageDetails', 'Antigravity'], - ['grokUsageAria', 'Grok'] - ]) { - expect(findByKey(zh, key), key).toBe(`打开 ${brand} 使用详情`) - } - }) -}) - -// Duplicate is an action ("make a copy of"), not the adjective 重复. In the file explorer row -// menu it sat next to Copy — both read 复制, two different actions under one label. -describe('zh Duplicate vs Copy wording', () => { - it('separates the file explorer Duplicate action from Copy', () => { - expect(findByKey(zh, '0fec99bfd7')).toBe('创建副本') // Duplicate, not 复制 - expect(findByKey(zh, '98a79948b3')).toBe('复制') // Copy keeps the clipboard sense - }) - - it('reads Duplicate Tab as an action, matching the menu on 选项卡', () => { - expect(findByKey(zh, '5d6e89891f')).toBe('复制选项卡') // not 重复选项卡 - }) -}) diff --git a/src/shared/constants.test.ts b/src/shared/constants.test.ts index b69ebda0d2d..941bc6e6c13 100644 --- a/src/shared/constants.test.ts +++ b/src/shared/constants.test.ts @@ -1,6 +1,5 @@ import { describe, expect, it } from 'vitest' -import { DEFAULT_TERMINAL_INACTIVE_PANE_OPACITY, getDefaultSettings } from './constants' -import { getDefaultNotificationSettings } from './notification-settings-defaults' +import { getDefaultSettings } from './constants' import { getDefaultPrimarySelectionMiddleClickPaste, getDefaultTerminalRightClickToPaste @@ -14,137 +13,9 @@ describe('getDefaultSettings', () => { ) }) - it('enables gitignored file decorations by default', () => { - expect(getDefaultSettings('/tmp').showGitIgnoredFiles).toBe(true) - }) - - it('uses list view for Source Control changes by default', () => { - expect(getDefaultSettings('/tmp').sourceControlViewMode).toBe('list') - }) - - it('keeps Source Control changes first by default', () => { - expect(getDefaultSettings('/tmp').sourceControlGroupOrder).toBe('changes-first') - }) - - it('defaults mobile pairing to discovered network addresses', () => { - expect(getDefaultSettings('/tmp').mobilePairingCustomAddress).toBeNull() - expect(getDefaultSettings('/tmp').mobilePairingCustomAddresses).toEqual([]) - }) - - it('keeps first-work branch auto-renaming on by default for new settings', () => { - expect(getDefaultSettings('/tmp').autoRenameBranchFromWork).toBe(true) - expect(getDefaultSettings('/tmp').autoRenameBranchFromWorkDefaultedOn).toBe(true) - }) - - it('uses a block terminal cursor by default for new settings', () => { - expect(getDefaultSettings('/tmp').terminalCursorStyle).toBe('block') - expect(getDefaultSettings('/tmp').terminalCursorStyleDefaultedToBlock).toBe(true) - }) - - it('allows OSC 52 clipboard writes by default for new settings', () => { - expect(getDefaultSettings('/tmp').terminalAllowOsc52Clipboard).toBe(true) - expect(getDefaultSettings('/tmp').terminalAllowOsc52ClipboardDefaultedOnForAllUsers).toBe(true) - }) - - it('enables separate light terminal theme by default', () => { - expect(getDefaultSettings('/tmp').terminalUseSeparateLightTheme).toBe(true) - }) - - it('keeps inactive terminal panes readable by default', () => { - expect(getDefaultSettings('/tmp').terminalInactivePaneOpacity).toBe( - DEFAULT_TERMINAL_INACTIVE_PANE_OPACITY - ) - }) - it('asks before closing terminals with running processes by default', () => { expect(getDefaultSettings('/tmp').skipCloseTerminalWithRunningProcessConfirm).toBe(false) }) - - it('uses system language by default', () => { - expect(getDefaultSettings('/tmp').uiLanguage).toBe('system') - }) - - it('defaults the menu bar icon on so the value round-trips across platforms', () => { - expect(getDefaultSettings('/tmp').showMenuBarIcon).toBe(true) - }) - - it('shows terminal link actions by default', () => { - expect(getDefaultSettings('/tmp').terminalLinkActionPopoverEnabled).toBe(true) - }) - - it('confirms before closing pinned tabs by default', () => { - expect(getDefaultSettings('/tmp').confirmClosePinnedTab).toBe(true) - }) - - it('keeps file-editor word wrapping enabled by default', () => { - expect(getDefaultSettings('/tmp').editorWordWrap).toBe(true) - }) - - it('keeps rich Markdown spellcheck enabled by default', () => { - expect(getDefaultSettings('/tmp').richMarkdownSpellcheckEnabled).toBe(true) - }) - - it('enables Source Control AI by default without pinning a separate agent', () => { - expect(getDefaultSettings('/tmp').commitMessageAi).toMatchObject({ - enabled: true, - agentId: null, - selectedModelByAgent: {} - }) - expect(getDefaultSettings('/tmp').sourceControlAi).toMatchObject({ - enabled: true, - agentId: null, - selectedModelByAgent: {}, - instructionsByOperation: { - commitMessage: '', - pullRequest: '', - branchName: '' - } - }) - }) - - it('keeps compact worktree cards disabled by default', () => { - expect(getDefaultSettings('/tmp').compactWorktreeCards).toBe(false) - }) - - it('keeps per-workspace environments disabled by default', () => { - expect(getDefaultSettings('/tmp').experimentalEphemeralVms).toBe(false) - }) - - it('keeps the agent dashboard popout disabled by default', () => { - expect(getDefaultSettings('/tmp').experimentalAgentDashboardPopout).toBeUndefined() - expect(getDefaultSettings('/tmp').experimentalAgentDashboardShowIdle).toBeUndefined() - }) - - it('defaults local Windows projects to the host runtime', () => { - expect(getDefaultSettings('/tmp').localWindowsRuntimeDefault).toEqual({ - kind: 'windows-host' - }) - }) - - it('suppresses notifications for the focused worktree by default for new users', () => { - expect(getDefaultNotificationSettings().suppressWhenFocused).toBe(true) - expect(getDefaultSettings('/tmp').notifications.suppressWhenFocused).toBe(true) - }) - - it('defaults agent launch args to yolo mode where the CLI supports it', () => { - const settings = getDefaultSettings('/tmp') - - expect(settings.agentDefaultArgs).toMatchObject({ - claude: '--dangerously-skip-permissions', - codex: '--dangerously-bypass-approvals-and-sandbox', - gemini: '--yolo', - cursor: '--yolo', - muse: '--yolo', - copilot: '--yolo', - grok: '--permission-mode bypassPermissions' - }) - expect(settings.agentDefaultArgs).not.toHaveProperty('opencode') - expect(settings.agentDefaultArgs).not.toHaveProperty('kilo') - expect(settings.agentDefaultEnv).toMatchObject({ - goose: { GOOSE_MODE: 'auto' } - }) - expect(settings.agentYoloDefaultsMigrated).toBe(true) - }) }) describe('getDefaultPrimarySelectionMiddleClickPaste', () => { @@ -168,20 +39,3 @@ describe('getDefaultTerminalRightClickToPaste', () => { expect(getDefaultTerminalRightClickToPaste('linux')).toBe(false) }) }) - -describe('MiniMax defaults', () => { - it('starts MiniMax with empty group id and the canonical default model', () => { - const settings = getDefaultSettings('/tmp') - // Why: the fetcher reads these defaults on first launch. An empty - // group id is the signal that the fetcher must pull the value from - // the cookie itself, and "general" matches the model name the - // MiniMax usage endpoint exposes by default. - expect(settings.minimaxGroupId).toBe('') - expect(settings.minimaxUsageModels).toBe('general') - }) - - it('defaults the MiniMax endpoint to overseas', () => { - const settings = getDefaultSettings('/tmp') - expect(settings.minimaxEndpoint).toBe('overseas') - }) -}) diff --git a/src/shared/contextual-tours.test.ts b/src/shared/contextual-tours.test.ts index 9f197f32326..3bde403ae70 100644 --- a/src/shared/contextual-tours.test.ts +++ b/src/shared/contextual-tours.test.ts @@ -1,190 +1,7 @@ import { describe, expect, it } from 'vitest' -import { - CONTEXTUAL_TOURS, - normalizeContextualTourIds, - type ContextualTour, - type ContextualTourId -} from './contextual-tours' - -describe('contextual tour definitions', () => { - it('defines the required tours with concise visible steps', () => { - const expectedIds: ContextualTourId[] = [ - 'workspace-board', - 'workspace-agent-sessions', - 'browser', - 'client-hosted-browser', - 'tasks', - 'automations', - 'floating-workspace', - 'workspace-creation' - ] - - expect(CONTEXTUAL_TOURS.map((tour) => tour.id)).toEqual(expectedIds) - for (const tour of CONTEXTUAL_TOURS) { - expect(tour.steps[0]?.requiredForStart).toBe(true) - const stepCount = (tour.steps as readonly unknown[]).length - if (stepCount === 1) { - // A lone step needs a completion path beyond the default Next: either it - // self-completes on the feature interaction, or it carries an explicit - // complete CTA (the client-hosted browser tip's "Got it"). - const step = tour.steps[0] as ContextualTour['steps'][number] - expect( - Boolean(step.advanceOnFeatureInteraction) || step.primaryAction?.kind === 'complete' - ).toBe(true) - } else { - expect(stepCount).toBeGreaterThanOrEqual(2) - } - expect(stepCount).toBeLessThanOrEqual(tour.id === 'workspace-agent-sessions' ? 5 : 3) - for (const step of tour.steps) { - expect(step.title.length).toBeGreaterThan(0) - expect(step.body.length).toBeGreaterThan(0) - expect(step.body.length).toBeLessThanOrEqual(140) - expect(step.targetSelector).toContain('data-contextual-tour-target') - } - } - }) - - it('defines the workspace agent sessions value tour as split then create-worktree', () => { - const tour = CONTEXTUAL_TOURS.find((entry) => entry.id === 'workspace-agent-sessions') as - | ContextualTour - | undefined - - // Two steps only: tasks and orchestration education lives in their own - // page tours, so the in-app tour ends after the worktree CTA. - expect(tour?.steps.map((step) => step.title)).toEqual([ - 'Split a terminal pane', - 'Start another task in parallel' - ]) - // The opening step teaches the split gesture and offers the convenience button. - expect(tour?.steps[0]).toMatchObject({ - requiredForStart: true, - primaryAction: { kind: 'split-terminal-pane', label: 'Split terminal' }, - advanceOnFeatureInteraction: 'terminal-pane-split' - }) - expect(tour?.steps[0]?.body).toContain('{terminal.splitRight}') - expect(tour?.steps[0]?.targetSelector).toContain('terminal-pane-split-target') - expect(tour?.steps[0]?.targetSelector).not.toContain('terminal-split-control') - expect(tour?.steps[0]?.secondaryAction).toBeUndefined() - // The closing step anchors on the real new-worktree button; the pulse makes - // that button the CTA instead of duplicating it inside the panel. - expect(tour?.steps[1]).toMatchObject({ - targetPulse: true, - hidePrimaryAction: true - }) - expect(tour?.steps[1]?.targetSelector).toContain('workspace-create-control') - expect(tour?.steps[1]?.primaryAction).toBeUndefined() - expect(tour?.steps[1]?.secondaryAction).toBeUndefined() - }) - - it('points the workspace board tour at the board center and done lane', () => { - const tour = CONTEXTUAL_TOURS.find((entry) => entry.id === 'workspace-board') as - | ContextualTour - | undefined - - expect(tour?.steps.map((step) => step.title)).toEqual([ - 'Plan work on the board', - 'Move work through lanes' - ]) - expect(tour?.steps[0]).toMatchObject({ - targetSelector: '[data-contextual-tour-target="workspace-board-center"]', - requiredForStart: true, - preferredPlacement: 'bottom' - }) - expect(tour?.steps[1]).toMatchObject({ - body: 'Drag workspaces between lanes as their status changes.', - targetSelector: - '[data-contextual-tour-target="workspace-board-done-lane"], [data-contextual-tour-target="workspace-board-lanes"]' - }) - }) - - it('orders the browser tour as grab, annotate, then import cookies', () => { - const tour = CONTEXTUAL_TOURS.find((entry) => entry.id === 'browser') as - | ContextualTour - | undefined - - expect(tour?.steps.map((step) => step.title)).toEqual([ - 'Grab page context for agents', - 'Mark design feedback in place', - 'Stay logged in' - ]) - expect(tour?.steps[0]).toMatchObject({ - targetSelector: '[data-contextual-tour-target="browser-grab-control"]', - preferredPlacement: 'bottom' - }) - expect(tour?.steps[1]).toMatchObject({ - targetSelector: '[data-contextual-tour-target="browser-annotation-control"]', - preferredPlacement: 'bottom' - }) - expect(tour?.steps[2]).toMatchObject({ - body: 'Bring your existing logins into Orca to stay signed in immediately.', - // Prefers the always-visible Import button, falling back to the overflow - // menu's Import Cookies row once the hint button is dismissed. - targetSelector: - '[data-contextual-tour-target="browser-import-hint"], [data-contextual-tour-target="browser-import-cookies-control"]', - preferredPlacement: 'bottom' - }) - }) - - it('points the tasks tour at the row workspace action before toolbar fallbacks', () => { - const tour = CONTEXTUAL_TOURS.find((entry) => entry.id === 'tasks') as - | ContextualTour - | undefined - const step = tour?.steps[2] - - expect(step).toMatchObject({ - title: 'Start from work items', - body: 'Use Start or Open on a task, issue, review, or merge request to bring its context into a workspace.' - }) - expect(step?.targetSelector.split(', ')).toEqual([ - '[data-contextual-tour-target="tasks-start-workspace"]', - '[data-contextual-tour-target="tasks-actions"]', - '[data-contextual-tour-target="tasks-search-presets"]' - ]) - }) - - it('orders the automations tour as create, then results', () => { - const tour = CONTEXTUAL_TOURS.find((entry) => entry.id === 'automations') as - | ContextualTour - | undefined - - expect(tour?.steps.map((step) => step.title)).toEqual([ - 'What is an automation?', - 'Find the results' - ]) - expect(tour?.steps[0]).toMatchObject({ - body: 'Automations run agent work on a schedule. Add an automation by clicking this button.', - requiredForStart: true - }) - expect(tour?.steps.map((step) => step.targetSelector)).toEqual([ - '[data-contextual-tour-target="automations-create"]', - '[data-contextual-tour-target="automations-runs"]' - ]) - }) - - it('defines the floating workspace tour on the action list with a surface fallback', () => { - const tour = CONTEXTUAL_TOURS.find((entry) => entry.id === 'floating-workspace') as - | ContextualTour - | undefined - - expect(tour?.steps.map((step) => step.title)).toEqual([ - 'Run an agent across every repo', - 'Or use it as a scratchpad' - ]) - expect(tour?.steps.map((step) => step.body)).toEqual([ - 'Agents here run in any folder you choose. Point one at the directory above your services to work across all your repos at once.', - 'Open agents, scratch terminals, notes, and browser tabs without cluttering the worktree you’re focused on.' - ]) - expect(tour?.steps[0]).toMatchObject({ - requiredForStart: true, - preferredPlacement: 'left' - }) - expect(tour?.steps[1]?.preferredPlacement).toBe('left') - expect(tour?.steps.map((step) => step.targetSelector)).toEqual([ - '[data-contextual-tour-target="floating-workspace-new-terminal"], [data-contextual-tour-target="floating-workspace-surface"]', - '[data-contextual-tour-target="floating-workspace-new-markdown"], [data-contextual-tour-target="floating-workspace-surface"]' - ]) - }) +import { normalizeContextualTourIds } from './contextual-tours' +describe('contextual tour ids', () => { it('normalizes persisted ids by removing unknowns and duplicates', () => { expect( normalizeContextualTourIds([ diff --git a/src/shared/feature-tips.test.ts b/src/shared/feature-tips.test.ts index e74faa8b5e9..ed479db848e 100644 --- a/src/shared/feature-tips.test.ts +++ b/src/shared/feature-tips.test.ts @@ -1,6 +1,5 @@ import { describe, expect, it } from 'vitest' import { - FEATURE_TIPS, getCompletedFeatureTipIds, getOrderedUnseenFeatureTips, normalizeFeatureTipIds, @@ -109,38 +108,4 @@ describe('feature tips', () => { ]) ).toEqual(['orca-cli', 'cmd-j-palette', 'voice-dictation']) }) - - it('describes the command palette tip as a passive acknowledgement', () => { - const paletteTip = FEATURE_TIPS.find((tip) => tip.id === 'cmd-j-palette') - - expect(paletteTip).toMatchObject({ - action: 'learn-cmd-j-palette', - priority: 'new', - eyebrow: 'Tip', - ctaLabel: 'Got it' - }) - expect(paletteTip?.description).toContain('worktrees') - expect(paletteTip?.description).toContain('spin up a new worktree') - }) - - it('describes the CLI tip as an install action with concrete workflows', () => { - const cliTip = FEATURE_TIPS.find((tip) => tip.id === 'orca-cli') - - expect(cliTip).toMatchObject({ - action: 'setup-cli', - title: 'Let agents drive Orca with the Orca CLI', - ctaLabel: 'Install CLI & Skills' - }) - expect(cliTip?.description).toContain('coordinate child worktrees') - expect(cliTip?.description).toContain('communicate between worktrees') - }) - - it('does not label the voice dictation tip as new', () => { - const voiceTip = FEATURE_TIPS.find((tip) => tip.id === 'voice-dictation') - - expect(voiceTip?.eyebrow).toBe('Tip') - expect(voiceTip?.priority).toBe('unseen') - expect(voiceTip?.title).toBe('Dictate into any pane') - expect(voiceTip?.ctaLabel).toBe('Set up voice dictation') - }) }) diff --git a/src/shared/git-binary-compatibility.test.ts b/src/shared/git-binary-compatibility.test.ts index 69fa1ec3e3a..c61efa3a7ee 100644 --- a/src/shared/git-binary-compatibility.test.ts +++ b/src/shared/git-binary-compatibility.test.ts @@ -1,8 +1,6 @@ -import { execFile } from 'node:child_process' -import { mkdir, mkdtemp, readFile, rm, unlink, utimes, writeFile } from 'node:fs/promises' +import { mkdir, mkdtemp, readFile, realpath, rm, unlink, utimes, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' -import { promisify } from 'node:util' import { afterAll, beforeAll, describe, expect, it } from 'vitest' import { isUnsupportedMergeTreeMergeBaseError, @@ -29,11 +27,10 @@ import { parseWorktreeList } from './git-worktree-porcelain-parser' import { fastForwardLocalBaseBranch } from './worktree/local-base-branch-fast-forward' import { gitChangeListArgs, parseGitChangeList } from './git-change-list' import { encodeGitPathspecs } from './git-pathspec-stdin' -import { endSubprocessStdin } from './subprocess-stdin-write' +import { GitBinaryTestFixture } from './git-binary.test-fixture' import { registerGitRepositoryRegistrationBinaryCompatibilityCase } from './git-repository-registration-binary-compatibility.test-cases' import { registerGitResolutionBinaryCompatibilityCases } from './git-resolution-binary-compatibility.test-cases' -const execFileAsync = promisify(execFile) const image = process.env.ORCA_GIT_COMPAT_IMAGE const binary = process.env.ORCA_GIT_COMPAT_BINARY const expectedVersion = process.env.ORCA_GIT_COMPAT_VERSION @@ -43,6 +40,7 @@ type GitResult = { stdout: string; stderr: string } describeBinaryCompatibility('real Git binary compatibility', () => { let repoPath = '' + let fixture: GitBinaryTestFixture let version = { major: 0, minor: 0 } async function runGit( @@ -50,53 +48,7 @@ describeBinaryCompatibility('real Git binary compatibility', () => { env?: NodeJS.ProcessEnv, stdin?: string ): Promise { - if (image) { - const dockerUser = - typeof process.getuid === 'function' && typeof process.getgid === 'function' - ? ['--user', `${process.getuid()}:${process.getgid()}`] - : [] - const pending = execFileAsync( - 'docker', - [ - 'run', - '--rm', - ...(stdin === undefined ? [] : ['-i']), - '--network=none', - ...dockerUser, - ...Object.entries(env ?? {}).flatMap(([key, value]) => - value === undefined ? [] : ['--env', `${key}=${value}`] - ), - '-v', - `${repoPath}:/repo`, - '-w', - '/repo', - image, - '-c', - 'safe.directory=/repo', - ...args - ], - { maxBuffer: 2 * 1024 * 1024 } - ) - if (stdin !== undefined) { - endSubprocessStdin(pending.child.stdin, stdin) - } - return pending - } - const pending = execFileAsync(binary!, args, { - cwd: repoPath, - env: { - ...process.env, - HOME: repoPath, - XDG_CONFIG_HOME: repoPath, - GIT_CONFIG_NOSYSTEM: '1', - ...env - }, - maxBuffer: 2 * 1024 * 1024 - }) - if (stdin !== undefined) { - endSubprocessStdin(pending.child.stdin, stdin) - } - return pending + return fixture.exec(args, fixture.executionRoot, env, stdin) } function supports(major: number, minor: number): boolean { @@ -118,7 +70,9 @@ describeBinaryCompatibility('real Git binary compatibility', () => { } beforeAll(async () => { - repoPath = await mkdtemp(join(tmpdir(), 'orca-git-binary-compat-')) + repoPath = await realpath(await mkdtemp(join(tmpdir(), 'orca-git-binary-compat-'))) + fixture = new GitBinaryTestFixture(repoPath, '/repo') + await fixture.start() const versionOutput = await runGit(['--version']) expect(versionOutput.stdout).toContain(`git version ${expectedVersion}`) const match = versionOutput.stdout.match(/git version (\d+)\.(\d+)/) @@ -135,7 +89,11 @@ describeBinaryCompatibility('real Git binary compatibility', () => { afterAll(async () => { if (repoPath) { - await rm(repoPath, { recursive: true, force: true }) + try { + await fixture.stop() + } finally { + await rm(repoPath, { recursive: true, force: true }) + } } }) diff --git a/src/shared/git-binary.test-fixture.ts b/src/shared/git-binary.test-fixture.ts new file mode 100644 index 00000000000..33d3975c8af --- /dev/null +++ b/src/shared/git-binary.test-fixture.ts @@ -0,0 +1,114 @@ +import { runProcess } from '@orca/process-host' + +type GitResult = { stdout: string; stderr: string } + +export class GitBinaryTestFixture { + private container = '' + private readonly image = process.env.ORCA_GIT_COMPAT_IMAGE + private readonly binary = process.env.ORCA_GIT_COMPAT_BINARY ?? 'git' + readonly executionRoot: string + + constructor( + private readonly root: string, + containerRoot = root + ) { + this.executionRoot = this.image ? containerRoot : root + } + + async start(): Promise { + if (!this.image) { + return + } + const dockerUser = + typeof process.getuid === 'function' && typeof process.getgid === 'function' + ? ['--user', `${process.getuid()}:${process.getgid()}`] + : [] + // Container creation dwarfs Git execution; keep the same isolated image alive for the suite. + const result = await this.run('docker', [ + 'run', + '--detach', + '--rm', + '--network=none', + ...dockerUser, + '-v', + `${this.root}:${this.executionRoot}`, + '--entrypoint', + '/bin/sleep', + this.image, + 'infinity' + ]) + this.container = result.stdout.trim() + } + + exec( + args: string[], + cwd = this.executionRoot, + env: NodeJS.ProcessEnv = {}, + stdin?: string + ): Promise { + const isolatedEnv = { + HOME: this.executionRoot, + XDG_CONFIG_HOME: this.executionRoot, + GIT_CONFIG_NOSYSTEM: '1', + ...env + } + if (!this.image) { + return this.run(this.binary, args, cwd, { ...process.env, ...isolatedEnv }, stdin) + } + if (!this.container) { + throw new Error('Git compatibility container has not started') + } + return this.run( + 'docker', + [ + 'exec', + ...(stdin === undefined ? [] : ['-i']), + ...Object.entries(isolatedEnv).flatMap(([key, value]) => + value === undefined ? [] : ['--env', `${key}=${value}`] + ), + '-w', + cwd, + this.container, + 'git', + '-c', + `safe.directory=${cwd}`, + ...args + ], + undefined, + undefined, + stdin + ) + } + + async stop(): Promise { + if (this.container) { + await this.run('docker', ['rm', '--force', this.container]) + this.container = '' + } + } + + private async run( + program: string, + args: string[], + cwd?: string, + env?: NodeJS.ProcessEnv, + stdin?: string + ): Promise { + const result = await runProcess({ + program, + args, + cwd, + env, + input: stdin, + maxOutputBytes: 2 * 1024 * 1024 + }) + if (result.code !== 0) { + throw Object.assign(new Error(`${program} ${args.join(' ')}: ${result.stderr}`), { + code: result.code, + stdout: result.stdout, + stderr: result.stderr + }) + } + return result + } +} diff --git a/src/shared/git-branch-line-total-soft-deadline.test.ts b/src/shared/git-branch-line-total-soft-deadline.test.ts index 9ef6c6872d9..5c426ea009a 100644 --- a/src/shared/git-branch-line-total-soft-deadline.test.ts +++ b/src/shared/git-branch-line-total-soft-deadline.test.ts @@ -1,4 +1,5 @@ -import { describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { GIT_BRANCH_LINE_TOTAL_SOFT_DEADLINE_MS } from './git-branch-line-total' import { beginGitStatusLineStatsCacheWrite, clearGitStatusLineStatsCache, @@ -33,30 +34,41 @@ async function runPass(diffDelayMs: number): Promise<{ elapsed: number; total: u } describe('branch line total never blocks the status response', () => { - it('returns fast when the ranged diff is slow, then publishes it on the next pass', async () => { + beforeEach(() => { + vi.useFakeTimers() clearGitStatusLineStatsCache() - const slow = await runPass(3000) - expect(slow.total).toBeUndefined() - expect(slow.elapsed).toBeLessThan(1200) + }) - // Why: poll the cache rather than run another pass — a second recompute - // stores, which retires the first pass's still-pending late-arrival token. - await vi.waitFor( - () => { - expect( - readCachedGitBranchLineTotal({ cacheKey: CACHE_KEY, mergeBase: MERGE_BASE }) - ).toEqual({ added: 5, removed: 5, mergeBase: MERGE_BASE }) - }, - { timeout: 15000, interval: 50 } - ) - const next = await runPass(3000) + afterEach(() => { + clearGitStatusLineStatsCache() + vi.useRealTimers() + }) + + it('returns fast when the ranged diff is slow, then publishes it on the next pass', async () => { + const slowPass = runPass(3000) + await vi.advanceTimersByTimeAsync(GIT_BRANCH_LINE_TOTAL_SOFT_DEADLINE_MS) + const slow = await slowPass + expect(slow.total).toBeUndefined() + expect(slow.elapsed).toBe(GIT_BRANCH_LINE_TOTAL_SOFT_DEADLINE_MS) + + // Let the first diff backfill before a second pass retires its write token. + await vi.advanceTimersByTimeAsync(3000 - GIT_BRANCH_LINE_TOTAL_SOFT_DEADLINE_MS) + expect(readCachedGitBranchLineTotal({ cacheKey: CACHE_KEY, mergeBase: MERGE_BASE })).toEqual({ + added: 5, + removed: 5, + mergeBase: MERGE_BASE + }) + const nextPass = runPass(3000) + await vi.advanceTimersByTimeAsync(GIT_BRANCH_LINE_TOTAL_SOFT_DEADLINE_MS) + const next = await nextPass expect(next.total).toEqual({ added: 5, removed: 5, mergeBase: MERGE_BASE }) - }, 20000) + }) it('still returns the total inline when the diff is fast', async () => { - clearGitStatusLineStatsCache() - const fast = await runPass(10) + const fastPass = runPass(10) + await vi.advanceTimersByTimeAsync(10) + const fast = await fastPass expect(fast.total).toEqual({ added: 5, removed: 5, mergeBase: MERGE_BASE }) - expect(fast.elapsed).toBeLessThan(400) + expect(fast.elapsed).toBe(10) }) }) diff --git a/src/shared/native-chat-types.test.ts b/src/shared/native-chat-types.test.ts deleted file mode 100644 index ce799378bbc..00000000000 --- a/src/shared/native-chat-types.test.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { describe, it, expect } from 'vitest' -import { - isTextBlock, - isToolCallBlock, - isToolResultBlock, - isImageRefBlock, - NATIVE_CHAT_SOURCE_PRIORITY, - type NativeChatBlock -} from './native-chat-types' - -const textBlock: NativeChatBlock = { type: 'text', text: 'hello' } -const toolCallBlock: NativeChatBlock = { type: 'tool-call', name: 'Edit', input: { path: 'a' } } -const toolResultBlock: NativeChatBlock = { type: 'tool-result', output: 'done', isError: false } -const imageRefBlock: NativeChatBlock = { type: 'image-ref', path: '/tmp/a.png', alt: 'a' } - -describe('native chat block guards', () => { - it('isTextBlock narrows only text blocks', () => { - expect(isTextBlock(textBlock)).toBe(true) - expect(isTextBlock(toolCallBlock)).toBe(false) - expect(isTextBlock(toolResultBlock)).toBe(false) - expect(isTextBlock(imageRefBlock)).toBe(false) - }) - - it('isToolCallBlock narrows only tool-call blocks', () => { - expect(isToolCallBlock(toolCallBlock)).toBe(true) - expect(isToolCallBlock(textBlock)).toBe(false) - }) - - it('isToolResultBlock narrows only tool-result blocks', () => { - expect(isToolResultBlock(toolResultBlock)).toBe(true) - expect(isToolResultBlock(toolCallBlock)).toBe(false) - }) - - it('isImageRefBlock narrows only image-ref blocks', () => { - expect(isImageRefBlock(imageRefBlock)).toBe(true) - expect(isImageRefBlock(textBlock)).toBe(false) - }) -}) - -describe('source priority', () => { - it('ranks transcript > hook > scrape', () => { - expect(NATIVE_CHAT_SOURCE_PRIORITY.transcript).toBeGreaterThan(NATIVE_CHAT_SOURCE_PRIORITY.hook) - expect(NATIVE_CHAT_SOURCE_PRIORITY.hook).toBeGreaterThan(NATIVE_CHAT_SOURCE_PRIORITY.scrape) - }) -}) diff --git a/tests/e2e/browser-tab.spec.ts b/tests/e2e/browser-tab.spec.ts index 9ddc7d7f14c..48da3235a40 100644 --- a/tests/e2e/browser-tab.spec.ts +++ b/tests/e2e/browser-tab.spec.ts @@ -14,9 +14,6 @@ import { getActiveWorktreeId, getActiveTabType, getBrowserTabs, - getAllWorktreeIds, - switchToOtherWorktree, - switchToWorktree, ensureTerminalVisible } from './helpers/store' import { startBrowserLinkServer } from './helpers/browser-link-server' @@ -54,25 +51,6 @@ async function createBrowserTab( ) } -async function switchToTerminalTab( - page: Parameters[0], - worktreeId: string -): Promise { - await page.evaluate((targetWorktreeId) => { - const store = window.__store - if (!store) { - return - } - - const state = store.getState() - const terminalTab = (state.tabsByWorktree[targetWorktreeId] ?? [])[0] - if (terminalTab) { - state.setActiveTab(terminalTab.id) - } - state.setActiveTabType('terminal', store.getState().activeWorktreeId) - }, worktreeId) -} - async function switchToBrowserTab( page: Parameters[0], worktreeId: string, @@ -370,77 +348,6 @@ test.describe('Browser Tab', () => { await ensureTerminalVisible(orcaPage) }) - /** - * User Prompt: - * - Browser works and also retains state when switching tabs etc. - */ - test('creating a browser tab adds it and activates browser view', async ({ orcaPage }) => { - const worktreeId = (await getActiveWorktreeId(orcaPage))! - const browserTabsBefore = await getBrowserTabs(orcaPage, worktreeId) - - await createBrowserTab(orcaPage, worktreeId) - - // Wait for the browser tab to appear in the store - await expect - .poll(async () => (await getBrowserTabs(orcaPage, worktreeId)).length, { timeout: 5_000 }) - .toBe(browserTabsBefore.length + 1) - - // The active tab type should switch to 'browser' - await expect.poll(async () => getActiveTabType(orcaPage), { timeout: 3_000 }).toBe('browser') - }) - - /** - * User Prompt: - * - Browser works and also retains state when switching tabs etc. - */ - test('browser tab is created and active in the store', async ({ orcaPage }) => { - const worktreeId = (await getActiveWorktreeId(orcaPage))! - - await createBrowserTab(orcaPage, worktreeId) - await expect.poll(async () => getActiveTabType(orcaPage), { timeout: 5_000 }).toBe('browser') - - // Verify the browser tab exists in the store - const browserTabs = await getBrowserTabs(orcaPage, worktreeId) - expect(browserTabs.length).toBeGreaterThan(0) - - // The active browser tab should have a URL (even if it's about:blank or the default) - const activeBrowserTabId = await orcaPage.evaluate(() => { - const store = window.__store - return store?.getState().activeBrowserTabId ?? null - }) - expect(activeBrowserTabId).not.toBeNull() - }) - - /** - * User Prompt: - * - Browser works and also retains state when switching tabs etc. - */ - test('browser tab retains state when switching to terminal and back', async ({ orcaPage }) => { - const worktreeId = (await getActiveWorktreeId(orcaPage))! - - await createBrowserTab(orcaPage, worktreeId) - await expect.poll(async () => getActiveTabType(orcaPage), { timeout: 5_000 }).toBe('browser') - - // Record the browser tab info - const browserTabsBefore = await getBrowserTabs(orcaPage, worktreeId) - expect(browserTabsBefore.length).toBeGreaterThan(0) - const browserTabId = browserTabsBefore.at(-1)?.id - expect(browserTabId).toBeTruthy() - - // Switch to the terminal view - await switchToTerminalTab(orcaPage, worktreeId) - await expect.poll(async () => getActiveTabType(orcaPage), { timeout: 3_000 }).toBe('terminal') - - // Switch back to browser tab - await switchToBrowserTab(orcaPage, worktreeId, browserTabId!) - await expect.poll(async () => getActiveTabType(orcaPage), { timeout: 3_000 }).toBe('browser') - - // The browser tab should still exist with the same ID - const browserTabsAfter = await getBrowserTabs(orcaPage, worktreeId) - const tabStillExists = browserTabsAfter.some((tab) => tab.id === browserTabId) - expect(tabStillExists).toBe(true) - }) - test('browser webview form state survives switching between browser tabs', async ({ orcaPage }) => { @@ -805,38 +712,4 @@ test.describe('Browser Tab', () => { await closeServer.close() } }) - - /** - * User Prompt: - * - Browser works and also retains state when switching tabs etc. - */ - test('browser tab retains state when switching worktrees and back', async ({ orcaPage }) => { - const allWorktreeIds = await getAllWorktreeIds(orcaPage) - if (allWorktreeIds.length < 2) { - test.skip(true, 'Need at least 2 worktrees to test worktree switching') - } - - const worktreeId = (await getActiveWorktreeId(orcaPage))! - - await createBrowserTab(orcaPage, worktreeId) - await expect.poll(async () => getActiveTabType(orcaPage), { timeout: 5_000 }).toBe('browser') - - const browserTabsBefore = await getBrowserTabs(orcaPage, worktreeId) - expect(browserTabsBefore.length).toBeGreaterThan(0) - - // Switch to a different worktree via the store - const otherId = await switchToOtherWorktree(orcaPage, worktreeId) - expect(otherId).not.toBeNull() - await expect.poll(async () => getActiveWorktreeId(orcaPage), { timeout: 5_000 }).toBe(otherId) - - // Switch back to the original worktree - await switchToWorktree(orcaPage, worktreeId) - await expect - .poll(async () => getActiveWorktreeId(orcaPage), { timeout: 5_000 }) - .toBe(worktreeId) - - // Browser tabs should still be preserved - const browserTabsAfter = await getBrowserTabs(orcaPage, worktreeId) - expect(browserTabsAfter.length).toBe(browserTabsBefore.length) - }) }) diff --git a/tests/e2e/helpers/e2e-completed-onboarding-profile.ts b/tests/e2e/helpers/e2e-completed-onboarding-profile.ts index 82f79564dd3..f4393fb33d2 100644 --- a/tests/e2e/helpers/e2e-completed-onboarding-profile.ts +++ b/tests/e2e/helpers/e2e-completed-onboarding-profile.ts @@ -47,7 +47,8 @@ export function getE2ECompletedOnboardingProfile() { browserImportHintHidden: true, // Why: E2E profiles model completed existing users and should not be // interrupted by the usage-display change toast covering the UI under test. - usagePercentageDisplayChangeNoticeDismissed: true + usagePercentageDisplayChangeNoticeDismissed: true, + statusBarCompactChangeNoticeDismissed: true } } } diff --git a/tests/e2e/onboarding.spec.ts b/tests/e2e/onboarding.spec.ts index a5a6557b4b9..9cdcf17e5d0 100644 --- a/tests/e2e/onboarding.spec.ts +++ b/tests/e2e/onboarding.spec.ts @@ -160,10 +160,13 @@ test.describe('Onboarding flow', () => { await waitForSessionReady(orcaPage) }) - test('renders on first launch with the agent step active', async ({ orcaPage }) => { + test('Continue advances steps, persists progress, and applies user-visible settings', async ({ + orcaPage + }) => { await expect(orcaPage.getByRole('heading', { name: /Pick your default agent/i })).toBeVisible({ timeout: 15_000 }) + await expectOnboardingProgress(orcaPage, /^1 of [345]$/) await expect(onboardingFooterButton(orcaPage, /^Continue\b/)).toBeVisible() await expect(onboardingFooterButton(orcaPage, SKIP_TO_PROJECT_SETUP_BUTTON)).toBeVisible() @@ -180,14 +183,6 @@ test.describe('Onboarding flow', () => { .filter({ hasText: /⌘|Ctrl/ }) .first() ).toBeVisible() - }) - - test('Continue advances steps, persists progress, and applies user-visible settings', async ({ - orcaPage - }) => { - await expect(orcaPage.getByRole('heading', { name: /Pick your default agent/i })).toBeVisible({ - timeout: 15_000 - }) // --- Step 1: agent --- // Force a deterministic, non-default selection so the assertion below @@ -209,6 +204,7 @@ test.describe('Onboarding flow', () => { await orcaPage.getByText(/Show \d+ more agents/).click() } await codexButton.click() + await expect(codexButton).toHaveAttribute('aria-pressed', 'true') await continueOnboarding(orcaPage) await expect(orcaPage.getByRole('heading', { name: /Make it feel like home/i })).toBeVisible() @@ -548,26 +544,6 @@ test.describe('Onboarding flow', () => { expect((await getOnboardingState(orcaPage)).closedAt).not.toBeNull() }) - test('selected agent button reports aria-pressed=true', async ({ orcaPage }) => { - await expect(orcaPage.getByRole('heading', { name: /Pick your default agent/i })).toBeVisible({ - timeout: 15_000 - }) - - const codexButton = orcaPage.getByRole('button', { name: 'Codex', exact: true }) - const codexVisible = await codexButton - .first() - .waitFor({ state: 'visible', timeout: 1_000 }) - .then(() => true) - .catch(() => false) - if (!codexVisible) { - await orcaPage.getByText(/Show \d+ more agents/).click() - } - await codexButton.click() - // Why: AgentButton now sets aria-pressed so screen readers and assistive - // tech can announce the selection. Verify the attribute reflects state. - await expect(codexButton).toHaveAttribute('aria-pressed', 'true') - }) - test('notification sound choice persists on Continue', async ({ orcaPage }) => { await expect(orcaPage.getByRole('heading', { name: /Pick your default agent/i })).toBeVisible({ timeout: 15_000 diff --git a/tests/e2e/tabs.spec.ts b/tests/e2e/tabs.spec.ts index 50d02e507a4..063809f32ad 100644 --- a/tests/e2e/tabs.spec.ts +++ b/tests/e2e/tabs.spec.ts @@ -236,129 +236,6 @@ test.describe('Tabs', () => { await expect.poll(() => getDomActiveTabId(orcaPage), { timeout: 3_000 }).toBe(storeActiveId) }) - /** - * User Prompt: - * - New tab works - * - * Why: we still use the store's `setActiveTab` for the switch itself (the - * hotkey path that used to be here turned out to target bracket-chord next/ - * prev tab cycling, not arbitrary tab selection), but the final assertion - * checks DOM `data-active` to prove the selection actually paints onto the - * right tab element. - */ - test('Cmd/Ctrl+Shift+] and Cmd/Ctrl+Shift+[ switch between tabs', async ({ orcaPage }) => { - const worktreeId = (await getActiveWorktreeId(orcaPage))! - - // Ensure we have at least 2 tabs — use the real "+" flow so a render - // regression would fail setup before we even start the cycle check. - if ((await countRenderedTabs(orcaPage)) < 2) { - await orcaPage.getByRole('button', { name: 'New tab' }).click() - await orcaPage - .getByRole('menuitem', { name: /New Terminal/i }) - .first() - .click() - await expect - .poll(() => countRenderedTabs(orcaPage), { timeout: 5_000 }) - .toBeGreaterThanOrEqual(2) - } - - const firstTabId = await getActiveTabId(orcaPage) - const orderedTabs = await getWorktreeTabs(orcaPage, worktreeId) - const secondTabId = orderedTabs.find((tab) => tab.id !== firstTabId)?.id - expect(secondTabId).toBeTruthy() - - await orcaPage.evaluate((tabId) => { - window.__store?.getState().setActiveTab(tabId) - }, secondTabId) - - // DOM assertion — the second tab must actually show the active indicator. - await expect.poll(() => getDomActiveTabId(orcaPage), { timeout: 3_000 }).toBe(secondTabId) - - // Switch back. - await orcaPage.evaluate((tabId) => { - window.__store?.getState().setActiveTab(tabId) - }, firstTabId) - await expect.poll(() => getDomActiveTabId(orcaPage), { timeout: 3_000 }).toBe(firstTabId) - }) - - /** - * User Prompt: - * - dragging tabs around to reorder them - * - * Why the reorder is still store-driven: real dnd-kit pointer events are - * unreliable in the hidden-window Electron mode we run E2E in (pointer - * capture + collision detection interact poorly with `window.show()` being - * suppressed). We seed the post-drag state via `reorderUnifiedTabs` — the - * same action dnd-kit calls on drop — and then assert the tab bar's DOM - * order matches the new sequence. That final DOM check is what makes this - * a real test: a pure store round-trip would not catch a regression where - * the tab strip stopped re-rendering in the store's new order. - */ - test('dragging a tab to a new position reorders it', async ({ orcaPage }) => { - const worktreeId = (await getActiveWorktreeId(orcaPage))! - - if ((await countRenderedTabs(orcaPage)) < 2) { - await orcaPage.getByRole('button', { name: 'New tab' }).click() - await orcaPage - .getByRole('menuitem', { name: /New Terminal/i }) - .first() - .click() - await expect - .poll(() => countRenderedTabs(orcaPage), { timeout: 5_000 }) - .toBeGreaterThanOrEqual(2) - } - - const domOrderBefore = await orcaPage.$$eval(SORTABLE_TAB, (nodes) => - nodes.map((n) => (n as HTMLElement).dataset.tabId ?? '') - ) - expect(domOrderBefore.length).toBeGreaterThanOrEqual(2) - - await orcaPage.evaluate((targetWorktreeId) => { - const store = window.__store - if (!store) { - return - } - - const state = store.getState() - const groups = state.groupsByWorktree[targetWorktreeId] ?? [] - const activeGroupId = state.activeGroupIdByWorktree[targetWorktreeId] - const activeGroup = activeGroupId - ? groups.find((group) => group.id === activeGroupId) - : groups[0] - - if (activeGroup?.tabOrder?.length >= 2) { - const nextOrder = [ - activeGroup.tabOrder[1], - activeGroup.tabOrder[0], - ...activeGroup.tabOrder.slice(2) - ] - state.reorderUnifiedTabs(activeGroup.id, nextOrder) - return - } - - const terminalOrder = (state.tabsByWorktree[targetWorktreeId] ?? []).map((tab) => tab.id) - if (terminalOrder.length >= 2) { - state.setTabBarOrder(targetWorktreeId, [ - terminalOrder[1], - terminalOrder[0], - ...terminalOrder.slice(2) - ]) - } - }, worktreeId) - - // Final assertion: the tab strip must re-render with the swapped order. - // Keying off `data-tab-id` makes this independent of title formatting. - await expect - .poll( - async () => - orcaPage.$$eval(SORTABLE_TAB, (nodes) => - nodes.map((n) => (n as HTMLElement).dataset.tabId ?? '') - ), - { timeout: 3_000, message: 'Tab bar DOM order did not reflect the reorder' } - ) - .toEqual([domOrderBefore[1], domOrderBefore[0], ...domOrderBefore.slice(2)]) - }) - test('clicking tabs still switches after dragging a terminal tab to reorder', async ({ orcaPage }) => { @@ -516,7 +393,7 @@ test.describe('Tabs', () => { * so the test fails if the store cleared the tab but the DOM didn't * re-render. */ - test('closing a tab removes it from the tab bar', async ({ orcaPage }) => { + test('closing the active tab removes it and activates a neighbor', async ({ orcaPage }) => { const worktreeId = (await getActiveWorktreeId(orcaPage))! // Need a second tab so we can close one without deactivating the worktree. @@ -545,45 +422,7 @@ test.describe('Tabs', () => { message: 'Clicking close did not remove the tab element from the DOM' }) .toBe(tabsBefore - 1) - }) - - /** - * User Prompt: - * - closing tabs works - * - * The DOM check (`data-active="true"` lands on a different element) proves - * the tab bar re-paints the active indicator after a close — a store-only - * check would pass even if the indicator failed to shift. - */ - test('closing the active tab activates a neighbor tab', async ({ orcaPage }) => { - const worktreeId = (await getActiveWorktreeId(orcaPage))! - - await orcaPage.evaluate((targetWorktreeId) => { - const store = window.__store - if (!store) { - return - } - const state = store.getState() - if ((state.tabsByWorktree[targetWorktreeId] ?? []).length < 2) { - state.createTab(targetWorktreeId) - } - }, worktreeId) - await expect - .poll(() => countRenderedTabs(orcaPage), { timeout: 5_000 }) - .toBeGreaterThanOrEqual(2) - - const activeTabBefore = await getActiveTabId(orcaPage) - expect(activeTabBefore).not.toBeNull() - - await closeTabFromTabBar(orcaPage, activeTabBefore!) - - // Final DOM assertion: some *other* tab element now carries data-active. - await expect - .poll(() => getDomActiveTabId(orcaPage), { - timeout: 5_000, - message: 'After closing the active tab, no neighbor tab took over the active indicator' - }) - .not.toBe(activeTabBefore) + await expect.poll(() => getDomActiveTabId(orcaPage), { timeout: 5_000 }).not.toBe(activeId) await expect.poll(() => getDomActiveTabId(orcaPage), { timeout: 5_000 }).not.toBeNull() }) }) diff --git a/tests/e2e/terminal-tui-wheel-drain.spec.ts b/tests/e2e/terminal-tui-wheel-drain.spec.ts index b2e2cd9167c..cee81a02ec1 100644 --- a/tests/e2e/terminal-tui-wheel-drain.spec.ts +++ b/tests/e2e/terminal-tui-wheel-drain.spec.ts @@ -3,6 +3,7 @@ import os from 'node:os' import path from 'node:path' import type { Page } from '@stablyai/playwright-test' import { test, expect } from './helpers/orca-app' +import { startHiddenRendererFrameCapture } from './helpers/hidden-renderer-frame-capture' import { ensureTerminalVisible, waitForActiveWorktree, waitForSessionReady } from './helpers/store' import { execInTerminal, @@ -159,25 +160,32 @@ test.describe('terminal TUI wheel report drain', () => { // Why: the dense CDP wheel stream is throughput-bound on loaded CI runners. test.slow() const logPath = path.join(os.tmpdir(), `tui-wheel-drain-${Date.now()}.log`) - await startHeavyTuiFixture(orcaPage, logPath) + const stopFrameCapture = await startHiddenRendererFrameCapture(orcaPage) + try { + await startHeavyTuiFixture(orcaPage, logPath) - const target = await terminalWheelTarget(orcaPage) - const input = await dispatchTrackpadWheelStream(orcaPage, { - alternate: false, - events: WHEEL_EVENTS, - deltaY: Math.min(49, target.cellHeight) - }) - // Give a laggy drain ample time to expose itself before reading the log. - await orcaPage.waitForTimeout(8000) + const target = await terminalWheelTarget(orcaPage) + const input = await dispatchTrackpadWheelStream(orcaPage, { + alternate: false, + events: WHEEL_EVENTS, + deltaY: Math.min(49, target.cellHeight) + }) + // Give a laggy drain ample time to expose itself before reading the log. + await orcaPage.waitForTimeout(8000) - const summary = summarizeArrivals(readReportArrivalLog(logPath), input) - fs.rmSync(logPath, { force: true }) - console.log(`[tui-wheel-drain] dense: ${JSON.stringify(summary)}`) + const summary = summarizeArrivals(readReportArrivalLog(logPath), input) + console.log(`[tui-wheel-drain] dense: ${JSON.stringify(summary)}`) - // The full gesture distance must reach the TUI (no dead/eaten scrolls)... - expect(summary.totalReports, JSON.stringify(summary)).toBeGreaterThanOrEqual(WHEEL_EVENTS - 10) - // ...while the gesture happens, not replayed 1-by-1 afterwards. - expect(summary.arrivalLagMs, JSON.stringify(summary)).toBeLessThanOrEqual(MAX_ARRIVAL_LAG_MS) + // The full gesture distance must reach the TUI (no dead/eaten scrolls)... + expect(summary.totalReports, JSON.stringify(summary)).toBeGreaterThanOrEqual( + WHEEL_EVENTS - 10 + ) + // ...while the gesture happens, not replayed 1-by-1 afterwards. + expect(summary.arrivalLagMs, JSON.stringify(summary)).toBeLessThanOrEqual(MAX_ARRIVAL_LAG_MS) + } finally { + await stopFrameCapture() + fs.rmSync(logPath, { force: true }) + } }) test('aggressive alternating trackpad-like gesture does not replay after input ends', async ({ @@ -186,20 +194,25 @@ test.describe('terminal TUI wheel report drain', () => { // Why: the dense CDP wheel stream is throughput-bound on loaded CI runners. test.slow() const logPath = path.join(os.tmpdir(), `tui-wheel-drain-alt-${Date.now()}.log`) - await startHeavyTuiFixture(orcaPage, logPath) + const stopFrameCapture = await startHiddenRendererFrameCapture(orcaPage) + try { + await startHeavyTuiFixture(orcaPage, logPath) - const target = await terminalWheelTarget(orcaPage) - const input = await dispatchTrackpadWheelStream(orcaPage, { - alternate: true, - events: WHEEL_EVENTS, - deltaY: Math.min(49, target.cellHeight) - }) - await orcaPage.waitForTimeout(8000) + const target = await terminalWheelTarget(orcaPage) + const input = await dispatchTrackpadWheelStream(orcaPage, { + alternate: true, + events: WHEEL_EVENTS, + deltaY: Math.min(49, target.cellHeight) + }) + await orcaPage.waitForTimeout(8000) - const summary = summarizeArrivals(readReportArrivalLog(logPath), input) - fs.rmSync(logPath, { force: true }) - console.log(`[tui-wheel-drain] alternate: ${JSON.stringify(summary)}`) + const summary = summarizeArrivals(readReportArrivalLog(logPath), input) + console.log(`[tui-wheel-drain] alternate: ${JSON.stringify(summary)}`) - expect(summary.arrivalLagMs, JSON.stringify(summary)).toBeLessThanOrEqual(MAX_ARRIVAL_LAG_MS) + expect(summary.arrivalLagMs, JSON.stringify(summary)).toBeLessThanOrEqual(MAX_ARRIVAL_LAG_MS) + } finally { + await stopFrameCapture() + fs.rmSync(logPath, { force: true }) + } }) }) diff --git a/tests/e2e/worktree-reveal-host-sections.spec.ts b/tests/e2e/worktree-reveal-host-sections.spec.ts index 1ad5bc0e6be..f3396af7426 100644 --- a/tests/e2e/worktree-reveal-host-sections.spec.ts +++ b/tests/e2e/worktree-reveal-host-sections.spec.ts @@ -18,143 +18,162 @@ const sections = [ ] as const for (const section of sections) { - for (const hostId of ['ssh:reveal-builder', 'runtime:reveal-builder'] as const) { - for (const collapsedHost of [false, true]) { - test(`reveals ${hostId} ${section.key} workspace without expanding local section (host collapsed: ${collapsedHost})`, async ({ - orcaPage - }, testInfo) => { - await orcaPage.emulateMedia({ reducedMotion: 'reduce' }) - await waitForSessionReady(orcaPage) - await waitForActiveWorktree(orcaPage) - await waitForStartupWorktreeRefresh(orcaPage) - const ids = await orcaPage.evaluate( - async ({ hostId, collapsedHost, section }) => { - const store = window.__store! - const state = store.getState() - const sourceRepo = state.repos[0] - const sourceWorktree = state.worktreesByRepo[sourceRepo.id][0] - await state.updateWorktreeMeta(sourceWorktree.id, { - isPinned: section.pinned, - displayName: 'Local workspace' - }) - const local = { - ...sourceWorktree, - hostId: 'local' as const, - isPinned: section.pinned, - displayName: 'Local workspace' - } - const remoteRepo = { - ...sourceRepo, - id: 'reveal-remote-repo', - executionHostId: hostId, - connectionId: hostId.startsWith('ssh:') ? 'reveal-builder' : null, - displayName: 'Remote project' - } - const remote = { - ...sourceWorktree, - id: 'reveal-remote-workspace', - repoId: remoteRepo.id, - hostId, - isPinned: section.pinned, - isMainWorktree: false, - displayName: 'Remote workspace' - } - state.setActiveView('terminal') - state.setSidebarOpen(true) - await window.api.ui.set({ groupBy: section.groupBy }) - state.setShowSleepingWorkspaces(true) - state.setHideDefaultBranchWorkspace(false) - state.setFilterRepoIds([]) - const collapsedGroups = [ - section.key, - `${section.key}:host:${hostId}`, - ...(collapsedHost ? [`host:${hostId}`] : []) - ] - await window.api.ui.set({ collapsedGroups }) - store.setState({ - groupBy: section.groupBy, - repos: [sourceRepo, remoteRepo], - worktreesByRepo: { [sourceRepo.id]: [local], [remoteRepo.id]: [remote] }, - sshTargetLabels: new Map([['reveal-builder', 'Remote builder']]), - sshConnectionStates: new Map([ - [ - 'reveal-builder', - { - targetId: 'reveal-builder', - status: 'connected', - error: null, - reconnectAttempt: 0 - } - ] - ]), - visibleWorkspaceHostIds: ['local', hostId], - workspaceHostScope: 'all', - activeRepoId: remoteRepo.id, - activeWorktreeId: remote.id, - activeWorkspaceKey: `worktree:${remote.id}`, - activeWorkspaceExecutionHostId: hostId, - pendingRevealWorktree: null, - collapsedGroups: new Set(collapsedGroups) - }) - return { local: local.id, remote: remote.id } - }, - { hostId, collapsedHost, section } - ) - const headers = orcaPage.getByRole('button', { name: new RegExp(`^${section.label}`) }) - const localRow = orcaPage.locator( - `[data-worktree-sidebar] [role="option"][data-worktree-id=${JSON.stringify(ids.local)}]` - ) - const remoteRow = orcaPage.locator( - `[data-worktree-sidebar] [role="option"][data-worktree-id=${JSON.stringify(ids.remote)}]` - ) - if (section.pinned || section.groupBy === 'workspace-status') { - await expect(headers.first()).toHaveAttribute('aria-expanded', 'false') - } - await expect(localRow).toHaveCount(0) - await expect(remoteRow).toHaveCount(0) - await orcaPage.screenshot({ - path: testInfo.outputPath('before-reveal.png'), - clip: { x: 0, y: 170, width: 280, height: 500 } - }) - await orcaPage.getByRole('button', { name: 'Reveal active workspace' }).click() - await expect(remoteRow).toBeVisible() - await expect(remoteRow).toHaveAttribute('data-scroll-reveal-highlight', 'true') - await orcaPage.screenshot({ - path: testInfo.outputPath('after-reveal.png'), - clip: { x: 0, y: 170, width: 280, height: 500 } - }) - await expect(headers).toHaveCount(2) - if (section.pinned || section.groupBy === 'workspace-status') { - await expect(headers.first()).toHaveAttribute('aria-expanded', 'false') - } - if (section.pinned || section.groupBy === 'workspace-status') { - await expect(headers.last()).toHaveAttribute('aria-expanded', 'true') - } - await expect(localRow).toHaveCount(0) - - await headers.last().click() - await expect(remoteRow).toHaveCount(0) - await orcaPage.evaluate((localId) => { - const store = window.__store! - const local = Object.values(store.getState().worktreesByRepo) - .flat() - .find((worktree) => worktree.id === localId)! - store.setState({ - activeRepoId: local.repoId, - activeWorktreeId: localId, - activeWorkspaceKey: `worktree:${localId}`, - activeWorkspaceExecutionHostId: 'local', - pendingRevealWorktree: null + test(`reveals ${section.key} workspaces without expanding another host's section`, async ({ + orcaPage + }, testInfo) => { + await orcaPage.emulateMedia({ reducedMotion: 'reduce' }) + await waitForSessionReady(orcaPage) + await waitForActiveWorktree(orcaPage) + await waitForStartupWorktreeRefresh(orcaPage) + for (const hostId of ['ssh:reveal-builder', 'runtime:reveal-builder'] as const) { + for (const collapsedHost of [false, true]) { + const scenario = `${hostId.split(':')[0]}-collapsed-${collapsedHost}` + await test.step(scenario, async () => { + const ids = await orcaPage.evaluate( + async ({ hostId, collapsedHost, section }) => { + const store = window.__store! + const state = store.getState() + const sourceRepo = state.repos[0] + const sourceWorktree = state.worktreesByRepo[sourceRepo.id][0] + await state.updateWorktreeMeta(sourceWorktree.id, { + isPinned: section.pinned, + displayName: 'Local workspace' + }) + const local = { + ...sourceWorktree, + hostId: 'local' as const, + isPinned: section.pinned, + displayName: 'Local workspace' + } + const remoteRepo = { + ...sourceRepo, + id: 'reveal-remote-repo', + executionHostId: hostId, + connectionId: hostId.startsWith('ssh:') ? 'reveal-builder' : null, + displayName: 'Remote project' + } + const remote = { + ...sourceWorktree, + id: 'reveal-remote-workspace', + repoId: remoteRepo.id, + hostId, + isPinned: section.pinned, + isMainWorktree: false, + displayName: 'Remote workspace' + } + state.setActiveView('terminal') + state.setSidebarOpen(true) + await window.api.ui.set({ groupBy: section.groupBy }) + state.setShowSleepingWorkspaces(true) + state.setHideDefaultBranchWorkspace(false) + state.setFilterRepoIds([]) + const collapsedGroups = [ + section.key, + `${section.key}:host:${hostId}`, + ...(collapsedHost ? [`host:${hostId}`] : []) + ] + await window.api.ui.set({ collapsedGroups }) + store.setState({ + groupBy: section.groupBy, + repos: [sourceRepo, remoteRepo], + worktreesByRepo: { [sourceRepo.id]: [local], [remoteRepo.id]: [remote] }, + sshTargetLabels: new Map([['reveal-builder', 'Remote builder']]), + sshConnectionStates: new Map([ + [ + 'reveal-builder', + { + targetId: 'reveal-builder', + status: 'connected', + error: null, + reconnectAttempt: 0 + } + ] + ]), + visibleWorkspaceHostIds: ['local', hostId], + workspaceHostScope: 'all', + activeRepoId: remoteRepo.id, + activeWorktreeId: remote.id, + activeWorkspaceKey: `worktree:${remote.id}`, + activeWorkspaceExecutionHostId: hostId, + pendingRevealWorktree: null, + collapsedGroups: new Set(collapsedGroups) + }) + return { local: local.id, remote: remote.id } + }, + { hostId, collapsedHost, section } + ) + const headers = orcaPage.getByRole('button', { name: new RegExp(`^${section.label}`) }) + const localRow = orcaPage.locator( + `[data-worktree-sidebar] [role="option"][data-worktree-id=${JSON.stringify(ids.local)}]` + ) + const remoteRow = orcaPage.locator( + `[data-worktree-sidebar] [role="option"][data-worktree-id=${JSON.stringify(ids.remote)}]` + ) + if (section.pinned || section.groupBy === 'workspace-status') { + await expect(headers.first()).toHaveAttribute('aria-expanded', 'false') + } + await expect(localRow).toHaveCount(0) + await expect(remoteRow).toHaveCount(0) + await orcaPage.screenshot({ + path: testInfo.outputPath(`${scenario}-before-reveal.png`), + clip: { x: 0, y: 170, width: 280, height: 500 } }) - }, ids.local) - await orcaPage.getByRole('button', { name: 'Reveal active workspace' }).click() - await expect(localRow).toBeVisible() - if (section.pinned || section.groupBy === 'workspace-status') { - await expect(headers.first()).toHaveAttribute('aria-expanded', 'true') - await expect(headers.last()).toHaveAttribute('aria-expanded', 'false') - } - await expect(remoteRow).toHaveCount(0) - }) + await orcaPage.getByRole('button', { name: 'Reveal active workspace' }).click() + await expect(remoteRow).toBeVisible() + await expect(remoteRow).toHaveAttribute('data-scroll-reveal-highlight', 'true') + await orcaPage.screenshot({ + path: testInfo.outputPath(`${scenario}-after-reveal.png`), + clip: { x: 0, y: 170, width: 280, height: 500 } + }) + await expect(headers).toHaveCount(2) + if (section.pinned || section.groupBy === 'workspace-status') { + await expect(headers.first()).toHaveAttribute('aria-expanded', 'false') + } + if (section.pinned || section.groupBy === 'workspace-status') { + await expect(headers.last()).toHaveAttribute('aria-expanded', 'true') + } + await expect(localRow).toHaveCount(0) + + await headers.last().click() + await expect(remoteRow).toHaveCount(0) + await orcaPage.evaluate((localId) => { + const store = window.__store! + const local = Object.values(store.getState().worktreesByRepo) + .flat() + .find((worktree) => worktree.id === localId)! + store.setState({ + activeRepoId: local.repoId, + activeWorktreeId: localId, + activeWorkspaceKey: `worktree:${localId}`, + activeWorkspaceExecutionHostId: 'local', + pendingRevealWorktree: null + }) + }, ids.local) + await orcaPage.getByRole('button', { name: 'Reveal active workspace' }).click() + await expect(localRow).toBeVisible() + if (section.pinned || section.groupBy === 'workspace-status') { + await expect(headers.first()).toHaveAttribute('aria-expanded', 'true') + await expect(headers.last()).toHaveAttribute('aria-expanded', 'false') + } + await expect(remoteRow).toHaveCount(0) + // Finish the reveal and its persistence before resetting the next scenario. + await expect + .poll(() => + orcaPage.evaluate(async () => ({ + pendingRevealWorktree: window.__store!.getState().pendingRevealWorktree, + collapsedGroups: [...window.__store!.getState().collapsedGroups].sort(), + persistedCollapsedGroups: (await window.api.ui.get()).collapsedGroups?.sort() + })) + ) + .toEqual({ + pendingRevealWorktree: null, + collapsedGroups: [`${section.key}:host:${hostId}`], + persistedCollapsedGroups: [`${section.key}:host:${hostId}`] + }) + await expect(localRow).toBeVisible() + await expect(remoteRow).toHaveCount(0) + }) + } } - } + }) }