From 872cfc220828010e100d33ec195d7fe51a4eab94 Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Sun, 25 Oct 2026 20:14:42 -0700 Subject: [PATCH 1/5] perf(test): focus PR verification and remove redundant coverage --- .claude/rules/testing.md | 78 +- .claude/rules/verification.md | 104 +- .github/actions/setup-ci/action.yml | 30 + .github/workflows/docs.yml | 21 +- .github/workflows/lint.yml | 152 +-- .github/workflows/release.yml | 25 +- .github/workflows/test.yml | 1170 +++-------------- .github/workflows/website.yml | 21 +- AGENTS.md | 32 +- .../desktop/ui/src/__tests__/app.test.tsx | 15 - .../ui/src/__tests__/no-inline-styles.test.ts | 9 +- .../ui/src/__tests__/service-screen.test.tsx | 13 - .../ui/src/__tests__/status-screen.test.tsx | 79 -- .../ui/src/__tests__/update-screen.test.tsx | 50 - .../src/lib/__tests__/breakpoints.test.ts | 4 - .../mobile/src/lib/__tests__/key-bar.test.ts | 5 - .../src/lib/__tests__/poll-policy.test.ts | 5 - .../src/lib/__tests__/subshell-socket.test.ts | 6 +- apps/node/agent/src/__tests__/backoff.test.ts | 8 - .../agent/src/__tests__/commands-ssh.test.ts | 12 +- apps/server/api/AGENTS.md | 6 + .../api/__tests__/admin-status-route.test.ts | 5 - .../api/src/api/__tests__/files-route.test.ts | 8 - .../__tests__/harness-schema-route.test.ts | 5 - .../api/__tests__/identities-route.test.ts | 5 - .../api/src/api/__tests__/meta-route.test.ts | 5 - .../api/__tests__/notifications-route.test.ts | 10 - .../src/api/__tests__/plugins-route.test.ts | 8 - .../api/__tests__/prompt-stacks.route.test.ts | 4 - .../src/api/__tests__/prompts.route.test.ts | 4 - .../__tests__/settings-instance-route.test.ts | 7 - .../src/api/__tests__/settings-route.test.ts | 8 - .../__tests__/subshells-notify-route.test.ts | 5 - .../src/api/__tests__/ws-token-route.test.ts | 5 - .../channels/__tests__/channels-route.test.ts | 5 - .../nodes/__tests__/nodes-crud-route.test.ts | 5 - .../nodes/__tests__/setup-keys-route.test.ts | 4 - .../__tests__/subshells-extend-token.test.ts | 7 - .../__tests__/subshells-log-route.test.ts | 5 - .../__tests__/subshells-rename-route.test.ts | 5 - .../subshells-workspace-file.test.ts | 11 - .../__tests__/workspaces-route.test.ts | 5 - .../api/src/db/__tests__/dialect.test.ts | 133 -- .../src/db/__tests__/open-database.test.ts | 17 + .../api/src/lib/__tests__/context.test.ts | 26 +- .../services/__tests__/notify-idle.test.ts | 7 - .../__tests__/ssh-relay.service.test.ts | 9 - .../__tests__/subshell-manager-mcp.test.ts | 5 +- .../subshells-resolve-launch-node.test.ts | 6 - .../nodes/__tests__/link-session.test.ts | 6 - .../nodes/__tests__/node-registry.test.ts | 4 - .../nodes/__tests__/preview-cache.test.ts | 4 - .../api/src/utils/__tests__/log-file.test.ts | 3 - .../api/src/ws/__tests__/ws-token.test.ts | 5 - apps/server/api/src/ws/capture-text.ts | 2 +- .../ui/src/__tests__/config-form.test.ts | 25 - .../desktop/ui/src/__tests__/host.test.tsx | 18 - .../ui/src/__tests__/settings-screen.test.ts | 63 +- .../ui/src/__tests__/update-act.test.ts | 30 +- .../ui/src/__tests__/wizard-state.test.ts | 129 +- .../screens/__tests__/status-details.test.tsx | 26 - .../__tests__/actions-menu.test.tsx | 6 - .../__tests__/app-sidebar-group.test.tsx | 29 - .../components/__tests__/app-sidebar.test.ts | 39 - .../components/__tests__/combobox.test.tsx | 7 - .../__tests__/desktop-app-update-row.test.tsx | 56 - .../__tests__/detail-back-header.test.tsx | 55 - .../__tests__/existing-subshell-list.test.tsx | 5 - .../__tests__/network-plugin-card.test.tsx | 173 --- .../__tests__/new-subshell-form.test.tsx | 15 - .../__tests__/not-found-page.test.tsx | 15 - .../components/__tests__/segmented.test.tsx | 32 - .../components/__tests__/step-dots.test.tsx | 4 - .../__tests__/subshell-actions-menu.test.tsx | 19 - .../__tests__/subshell-dot.test.tsx | 57 - .../__tests__/terminal-key-bar.test.tsx | 28 - .../__tests__/terminal-overlay-stack.test.tsx | 37 +- .../__tests__/trust-notice-banner.test.tsx | 9 +- .../__tests__/new-account-fields.test.tsx | 27 - .../__tests__/installed-plugins-card.test.tsx | 34 +- .../__tests__/preset-table-row.test.tsx | 24 +- .../__tests__/ssh-preset-library.test.tsx | 42 +- .../sidebar/__tests__/rail-subshells.test.tsx | 11 - .../__tests__/subshell-cell-grid.test.tsx | 378 +----- .../__tests__/tooltip-labelled-lines.test.tsx | 10 - .../__tests__/workspace-drop-create.test.tsx | 14 - .../__tests__/new-subshell-form.test.tsx | 23 - .../ui/__tests__/dropdown-menu.test.tsx | 44 - .../components/ui/__tests__/tooltip.test.tsx | 14 - .../users/__tests__/user-row-actions.test.tsx | 5 - .../__tests__/use-harness-subagents.test.tsx | 122 +- .../web/src/lib/__tests__/build-id.test.ts | 6 - .../src/lib/__tests__/create-subshell.test.ts | 5 - .../lib/__tests__/replay-alignment.test.ts | 32 +- .../__tests__/sidebar-rail-view-pref.test.ts | 4 - .../__tests__/root-frame-guards.test.ts | 25 - .../__tests__/install-column.test.tsx | 11 - apps/website/lib/__tests__/ui.test.tsx | 12 - brand/package.json | 3 +- bun.lock | 1 + docs/README.md | 2 + docs/release-and-ci.md | 94 +- docs/testing.md | 261 ++++ e2e/AGENTS.md | 7 +- e2e/package.json | 4 +- lefthook.yml | 6 +- package.json | 5 +- .../assistant/src/__tests__/frame.test.tsx | 37 - .../assistant/src/__tests__/rail.test.tsx | 35 - .../__tests__/built-in-api-versions.test.ts | 12 +- .../src/__tests__/built-in-icons.test.ts | 26 - .../src/__tests__/embedded-plugins.test.ts | 12 - .../src/__tests__/legal.test.ts | 36 +- .../src/__tests__/node-results.test.ts | 7 - .../src/__tests__/relay-frames.test.ts | 18 +- .../src/__tests__/shared-geometry.test.ts | 39 +- scripts/__tests__/ci-test-plan.test.ts | 372 +++--- .../__tests__/release-verification.test.ts | 47 + .../__tests__/rust-check-selection.test.ts | 91 ++ scripts/__tests__/test-plan-execution.test.ts | 48 + scripts/__tests__/website-workflow.test.ts | 6 +- scripts/check-changed.ts | 18 + scripts/ci-test-plan.ts | 497 +++---- scripts/par-test.sh | 15 +- scripts/run-test-plan.ts | 80 ++ scripts/rust-check.sh | 30 +- scripts/test-changed.ts | 27 + scripts/test-compiled-smoke.sh | 20 + turbo.json | 5 +- 129 files changed, 1563 insertions(+), 4216 deletions(-) create mode 100644 .github/actions/setup-ci/action.yml delete mode 100644 apps/server/api/src/db/__tests__/dialect.test.ts create mode 100644 apps/server/api/src/db/__tests__/open-database.test.ts delete mode 100644 apps/server/web/src/components/__tests__/app-sidebar.test.ts delete mode 100644 apps/server/web/src/components/ui/__tests__/dropdown-menu.test.tsx create mode 100644 docs/testing.md create mode 100644 scripts/__tests__/release-verification.test.ts create mode 100644 scripts/__tests__/rust-check-selection.test.ts create mode 100644 scripts/__tests__/test-plan-execution.test.ts create mode 100644 scripts/check-changed.ts create mode 100644 scripts/run-test-plan.ts create mode 100644 scripts/test-changed.ts create mode 100644 scripts/test-compiled-smoke.sh diff --git a/.claude/rules/testing.md b/.claude/rules/testing.md index 9363265b0..43b35f8e2 100644 --- a/.claude/rules/testing.md +++ b/.claude/rules/testing.md @@ -1,56 +1,58 @@ # Testing -## Commands +Read [docs/testing.md](../../docs/testing.md) for the shared policy and commands. -```bash -turbo test # Run tests across all packages -``` +## Test value -Each package's `test` script passes `--parallel=N` (issue #261): test files -run in worker processes and **every file gets a fresh module registry** - a -fresh `db` singleton with its own database file. Two consequences for suite -authors: a suite that touches the DB must migrate it itself (server/api: -`@/__tests__/helpers/test-database.js`) and may assume neither an empty DB -nor a sibling file's boot; and runner-wide cleanup cannot live in a preload -`afterAll` (those fire per file under `--parallel`), it belongs in the -package's `test` script. A bare `bun test` still runs serially - bun ignores -a `parallel` key in `bunfig.toml [test]` (measured on 1.4.0 and 1.4.2), so -the flag lives in the scripts only. `SUBSHELL_TEST_PARALLEL` overrides a -package's width. CI instead sets `SUBSHELL_TEST_SERIAL=1`, which drops the -flag entirely and adds `--retry=1`: the hosted container runs a fraction of -its labelled cores and starves time-budgeted tests at every worker width, so -CI gets the serial regime plus a shard matrix for speed (`--parallel=1` is -NOT serial - it still isolates per file; omit the flag for serial). +Add a test when it can catch a plausible regression in behavior we own. Name +that failure before writing the test. Prefer an existing suite at the narrowest +boundary that observes the result. Authorization, privacy, destructive actions, +migrations, protocol compatibility, and asynchronous ordering deserve focused +coverage, including the relevant refusal path. -## Guidelines +Do not require a test per function, component, hook, or file. Do not add tests +for types already checked by TypeScript, copied implementation logic, fixed +copy, CSS classes, trivial prop forwarding, or third-party behavior. Review +layout in a browser. Keep accessibility and interaction tests where they verify +our behavior. When one test already catches the regression, another layer needs +a distinct failure to justify its setup and maintenance cost. -**Always write tests for new features.** Every new service, repository, route, or significant function should have corresponding tests. Tests should cover: +For a bug fix, reproduce the failure at the appropriate boundary when practical. +Prefer adding a case to an existing suite over creating a new file and preload. +Do not increase test count or coverage percentages as an end in itself. -- Happy path (expected behavior) -- Edge cases (empty inputs, null values, boundaries) -- Error conditions (invalid inputs, not found cases) +Reuse existing boundary coverage. The API's composed-route authorization suite +already rejects anonymous and node-key requests on every guarded route; don't +repeat a bare 401 case in each route file. Keep separate tests for permission +scopes, ownership, conditional authentication, body-dependent behavior, and +refused writes leaving state unchanged. -Test files live in `__tests__/` directories alongside the code they test: -``` -src/services/ -├── logs.service.ts -└── __tests__/ - └── logs.service.test.ts +Drive polling through a controlled scheduler or clock when elapsed wall time +isn't the behavior under test. Make successful fakes supply the response the +real operation waits for, rather than silently exercising its timeout fallback. +Keep real deadline/process tests when they verify cancellation or termination. -src/db/repositories/ -├── logs.repository.ts -└── __tests__/ - └── logs.repository.test.ts -``` +## Running tests -When fixing bugs, add a test that reproduces the bug before fixing it to prevent regressions. +While editing, run the covering files from their package directory. Before +handoff, inspect `bun run test:changed -- --plan`, then run the affected checks +once. Use package `test` scripts for suite runs; they own parallelism and cleanup. +Use `env -u SHELLOPTS -u BASHOPTS` in automation shells, capture the output once, +and inspect failures from that capture. Confirm the expected file count ran. + +Bun's `--parallel` isolates each file. DB suites migrate their own database; +runner-wide cleanup belongs in the package runner, not preload `afterAll`. +`SUBSHELL_TEST_PARALLEL` overrides local width. CI keeps serial package execution +while routing packages across at most three jobs. Tests in release verification +run without retries; a flaky failure needs fixing, not a green retry. ## Server-backed search coverage Test the HTTP boundary, not just the matching helper: maximum and over-limit lengths (including Unicode), invalid enum/format values, literal SQL/regex-like text, and private records or nested members that must remain undiscoverable. -Retain anonymous and scoped-bearer authorization checks when adding filters. +Use the composed-route suite for anonymous rejection. Retain filter-specific +scoped-bearer and visibility checks, including private results staying hidden. UI tests should verify debounced requests, immediate select/clear actions, stale-response isolation, and a failed search recovering through Retry without @@ -61,4 +63,4 @@ handler so they assert the API's actual error status and body. ## After Making Code Changes -After any code change, check if tests need updating due to changed behavior (e.g., different error types, response formats, new error conditions). Then run full verification - see `verification.md`. +After any code change, check if tests need updating due to changed behavior (e.g., different error types, response formats, new error conditions). Run the checks affected by that behavior; see `verification.md`. diff --git a/.claude/rules/verification.md b/.claude/rules/verification.md index 37d113836..8afc1ddd7 100644 --- a/.claude/rules/verification.md +++ b/.claude/rules/verification.md @@ -1,84 +1,40 @@ # Verification -**Run verification focused while iterating, and full at task boundaries** (operator ruling 2026-09-24: the full trio on every edit stopped adding signal and started costing minutes). +**Verify the affected behavior, including at task boundaries.** A push, PR, +merge, or handoff does not justify running unrelated packages. The shared +policy is [docs/testing.md](../../docs/testing.md). -**While iterating**, only what your change can break: +While iterating, run covering test files, the package's type check, and Biome +on changed code. Before handoff, inspect and execute the affected plan: ```bash -bun test # check the FILE COUNT — bun silently skips paths that don't exist -bunx turbo verify-types --filter= # the package you changed, not all 52 -bunx biome check # read-only; `bun run lint` to fix +bun run test:changed -- --plan +bun run verify:changed ``` -**At a task boundary**, before any push, PR, merge, or handoff, run the full set once: - -```bash -bun run verify-types -bun run lint:check -bun run lint:prose -bun run test -``` - -`lint:prose` enforces the voice rule (no U+2014 in authored prose, operator -ruling 2026-09-25) in ~20 ms; it joined this set after PR #269 shipped two em -dashes that only CI's lint workflow caught, because neither the trio above (as -it then read) nor pre-push covered it. - -If any of these fail, fix the issues before considering the task complete. Do not proceed to commits or other work until the checks relevant to that step pass. Subagent gate contracts should name the focused files for the work and reserve the full suite for the boundary. - -`pre-push` runs the fast static checks (types, `lint:check`, licenses, design -tokens, prose) but NOT the test suite: CI owns that. Run `bun run test` -yourself before pushing anything you expect to be green on the first try. - -## Changes under `packages/` need a build - -```bash -bunx turbo build -``` - -`verify-types`, `lint:check` and `test` all run against SOURCE and will pass -while the workspace build is broken. The trap that catches people is -`@internal/subshell-protocol`: **`apps/client/mobile` imports its barrel through -Metro, which cannot resolve `node:*`**, so adding a node-builtin import to -`src/index.ts` (directly or by re-exporting a module that has one) breaks the -mobile build and NOTHING ELSE. All three local commands stay green and CI fails -on a package you never opened. Node-only modules are subpath exports instead -(`release-artifacts`, `service-test-safety`); `src/index.ts` says so at the -export site. Measured 2026-09-15, twice in one run. - -## Rust changes need a fourth command - -Those three commands do not touch Rust at all. If you changed anything under -`crates/desktop-core` or either `apps/*/desktop/src-tauri`, also run: - -```bash -bun run rust:check -``` - -It runs exactly what CI's Rust jobs run (`cargo fmt --check`, then -`cargo clippy --all-targets -- -D warnings`, then `cargo test`) in all three -crates. - -**`cargo check` on its own is not enough, and the two app crates cannot even be -compiled without help.** `tauri-build` refuses to build when an `externalBin` -path is missing, and the staged sidecar is a gitignored ~110 MB build input, so -a plain `cargo clippy` there dies inside the build script instead of reporting a -lint. `rust:check` stages a stub for the host triple first (the same trick -test.yml uses) and removes only what it created, so a real sidecar staged by a -release in progress is left alone. - -This was added after a hand-written import block failed `cargo fmt --check` on -both desktop shards in CI, caught by neither `cargo check` nor clippy (a -full CI round trip to discover something a local command finds in seconds). - -**On macOS it refuses early when `cc` cannot link** (`scripts/macos-toolchain-preflight.sh`, -shared with the `tauri dev` launchers). An unaccepted Xcode licence stops every -cargo build, and cargo reports that as a `note:` about a hundred lines above an -error naming a SOURCE FILE, so a machine that cannot link anything reads as one -broken doctest in a file you just edited. Measured on 2026-09-15. The remedy is -`sudo xcodebuild -license`, or `export DEVELOPER_DIR=/Library/Developer/CommandLineTools`, -whose toolchain the licence gate does not cover. A link failure the probe does -not recognise is reported verbatim rather than blamed on the licence. +The planner includes committed changes since `origin/main`, staged changes, +unstaged changes, and untracked files. `--base ` selects another base. +Unknown inputs or missing history select full verification and explain why. +Do not silently narrow that fallback; fetch the correct base if it is missing. +For a documentation-only change, run prose/content validation instead of product +suites. Do not repeat checks that already passed unless new changes affect them. + +`verify:changed` builds and type-checks changed packages and their transitive +consumers. This catches failures source-only tests miss, including a shared +protocol import that Metro cannot bundle. It also runs the selected tests, +compiled/browser checks, and Rust crates. A Rust app edit checks that app; +a shared desktop-core change checks both apps too. `rust:check` still stages +missing sidecar stubs and removes only the stubs it created. + +Fix relevant failures before handoff. Report the checks actually run and any +blocked tier. Never describe a partial run as the full suite. `bun run test` +is workspace unit tests only; `bun run test:full` includes the other tiers. +Full verification belongs to release cuts and explicit broad verification, +not every task boundary. Release CI requires a fresh full run on the release +revision before tags or artifacts are published, on Linux and macOS. + +The pre-push hook runs affected static checks plus the fast repository contract +checks. CI owns the affected test tiers; the hook does not duplicate them. ## The CLI end-to-end suite diff --git a/.github/actions/setup-ci/action.yml b/.github/actions/setup-ci/action.yml new file mode 100644 index 000000000..c0a25c115 --- /dev/null +++ b/.github/actions/setup-ci/action.yml @@ -0,0 +1,30 @@ +name: Set up verification +description: Install the pinned JS toolchain and restore dependency/build caches. +inputs: + cache-suffix: + description: Keep independently populated Turbo caches from racing to save the same key. + default: checks +runs: + using: composite + steps: + - name: Trust the workspace + shell: bash + run: git config --global --add safe.directory "$GITHUB_WORKSPACE" + - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + with: + bun-version: 1.4.4 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: 24 + - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: ~/.bun/install/cache + key: ${{ runner.os }}-bun-${{ hashFiles('bun.lock') }} + restore-keys: ${{ runner.os }}-bun- + - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: .turbo/cache + key: ${{ runner.os }}-turbo-${{ github.sha }}-${{ inputs.cache-suffix }} + restore-keys: ${{ runner.os }}-turbo- + - shell: bash + run: bun install --frozen-lockfile diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 7ce65ab66..f287474f1 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -8,13 +8,8 @@ # release — and the retry semantics differ: a docs tag already on this commit # means "redeploy" (below), where for release.yml it would mean "skip". # -# No CI gate, deliberately. There was one: a `ci-gate` job waited for this -# commit's push-triggered Test + Lint runs and refused anything but success. -# The operator removed it on 2026-09-25, the day a flaky job in an unrelated -# package's Test run blocked BOTH site deploys until a human re-ran it. What -# proves a static site is `lint.yml` building this export on every push (and -# PR CI before that); the gate only coupled deploy health to server/node test -# luck, which for a docs site is borrowing someone else's red light. +# Every release cut requires a fresh full verification of this revision, +# including site releases. A failure blocks tag creation and deployment. # # Flow: # plan — refuse any run not on refs/heads/main, resolve the version @@ -49,7 +44,17 @@ concurrency: cancel-in-progress: false jobs: + verify: + name: Full release verification + uses: ./.github/workflows/test.yml + with: + full: true + permissions: + contents: read + packages: read + plan: + needs: verify name: Plan the deploy runs-on: ubuntu-24.04 timeout-minutes: 10 @@ -124,7 +129,7 @@ jobs: bun-version: 1.4.4 # next's bin carries a `#!/usr/bin/env node` shebang — same reason - # lint.yml pins node: a runner image floats its own node, and the + # test.yml pins node: a runner image floats its own node, and the # pinned version (the root package.json's `engines.node`) is the # contract, not the image's mood. - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index e09cafb57..d83c62d5b 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -1,163 +1,17 @@ -name: Lint +name: Release manifest on: - pull_request: push: - branches: - - main + branches: [main] permissions: contents: read -# A superseded push must not keep CI busy: pushing three times to a PR used to -# queue three full sets of jobs, all but the last of them already irrelevant. -# release.yml deliberately does the OPPOSITE (`cancel-in-progress: false`) — -# cancelling a release mid-flight leaves a tag without a release — but CI has -# nothing to protect. concurrency: - group: ${{ github.workflow }}-${{ github.ref }} + group: manifest-${{ github.ref }} cancel-in-progress: true jobs: - lint: - name: Linting - # Bare on the runner rather than in the builder image: this job needs only - # bun (via setup-bun below) and node, and a container would buy nothing — - # nothing root-owns the workspace, so there is nothing to un-root. - runs-on: ubuntu-24.04 - timeout-minutes: 20 - steps: - - name: Checkout Repository - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - - name: Use Bun - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 - with: - bun-version: 1.4.4 - - # tsdown/rolldown run under NODE, not bun — their bin shebang is - # `#!/usr/bin/env node` — so the repo has always had a Node requirement - # it never declared, and CI silently inherited whatever the host had. - # the fleet once shipped node 18.19 while ubuntu-latest shipped 20+; rolldown needs - # `styleText` from `node:util` (20.12+), so the build broke on the move. - # - # Pinned to the `engines.node` the root package.json now declares, NOT - # to rolldown's floor. A hosted runner image ships a node and floats it - # with each image refresh; the pinned version is the contract, so it - # comes from the workflow rather than from the image. - - name: Use Node - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 - with: - node-version: 24 - - - name: Cache Bun install - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 - with: - path: ~/.bun/install/cache - key: ${{ runner.os }}-bun-${{ hashFiles('bun.lock') }} - restore-keys: ${{ runner.os }}-bun- - - - name: Cache Turbo - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 - with: - # Turbo v2's local cache is .turbo/cache at the repo root - # (measured 2026-09-22 against turbo 2.10.11); the v1 path this - # previously named is never written, so the cache restored nothing. - path: .turbo - key: ${{ runner.os }}-turbo-${{ github.sha }} - restore-keys: ${{ runner.os }}-turbo- - - - name: Install Dependencies - run: bun install --frozen-lockfile - - - name: Build workspace packages - run: bun run build - - - name: Run package checking - run: bun run lint:packages - - # Bun records a version for each workspace in bun.lock and never resyncs - # it, and `--frozen-lockfile` exits 0 when it is stale — so the release - # flow silently drifted the lockfile a whole version behind before - # anyone noticed. This is the only check that sees it. - - name: Check the lockfile's workspace versions - run: bun run lint:lockfile - - # Subshell is dual-licensed by path (apps/server/** is AGPL, the rest is - # Apache). A new or moved package declaring the wrong `license` — or none - # at all — is not a type error, a lint error or a test failure. This is - # the only thing that sees it. - - name: Check the per-path licence declarations - run: bun run lint:licenses - - # The design system (docs/design-system.md): one type/colour vocabulary - # across four surfaces, asserted from the token files. A stray - # `text-[13px]`, `font-size: 14.5px` or `fontSize: 17` is not a type, - # lint or test failure — this is the only thing that sees it. - - name: Check the design tokens - run: bun run lint:design - - # The voice rule (operator ruling 2026-09-25): no em dashes in authored - # prose. The docs site enforces this through its content test; this is - # the twin for the rest of the repository's prose (AGENTS.md files, - # docs/, rules, READMEs, pending changesets). A dash is not a type, lint - # or test failure, so this is the only thing that sees it. - - name: Check the prose (no em dashes) - run: bun run lint:prose - - - name: Run type checking - run: bun run verify-types - - # Read-only: `lint` writes fixes and would pass on unformatted code. - - name: Run linting - run: bun run lint:check - - - name: "shellcheck (Proxmox helpers, image entrypoint, docker scenario)" - run: | - sudo apt-get update -q - sudo apt-get install -y -q shellcheck - shellcheck -S warning proxmox-server.sh proxmox-node.sh docker-entrypoint.sh scripts/cli-e2e/docker-image.sh - - # Dependency advisories, gated. `bun audit` on its own is informational: it - # prints advisories and would have left this file green through every one of - # them. dep-audit requires the live advisory set to be a subset of - # scripts/dep-audit.ignore.json, so a NEW advisory fails the job naming its - # GHSA id, and an allowlisted entry that has since been fixed warns, which is - # what keeps the file recording reasons rather than history. A separate job - # rather than a step: the audit is a statement about the lockfile, and it - # fails for reasons no amount of linting fixes. - dep-audit: - name: Dependency audit - # Bare like the lint job above: bun and the lockfile are the whole input. - runs-on: ubuntu-24.04 - timeout-minutes: 10 - steps: - - name: Checkout Repository - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - - name: Use Bun - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 - with: - bun-version: 1.4.4 - - - name: Cache Bun install - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 - with: - path: ~/.bun/install/cache - key: ${{ runner.os }}-bun-${{ hashFiles('bun.lock') }} - restore-keys: ${{ runner.os }}-bun- - - - name: Install Dependencies - run: bun install --frozen-lockfile - - - name: Audit dependencies - run: bun run audit:deps - - # The website's install column reads the root releases.json, and release.yml - # regenerates it after every cut. If that job's commit step ever breaks, - # main moves on with a stale manifest: drift is a CI failure, not a - # visitor's problem (spec 2026-09-23 §5). Bare on the runner like the Lint - # job above for the same reason: bun and the checkout are the whole need. site-manifest-fresh: name: releases.json matches the tags # Push-only: tags land at cut START, PRs must not go red for a cut in flight; drift is caught "on the next push". diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 47defa22c..8d8f33ac7 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -253,6 +253,22 @@ jobs: create-github-releases: false + # The same workflow as PR verification, forced to run every suite on this SHA. + # Version-PR maintenance and a manifest refresh don't publish artifacts. + verify: + name: Full release verification + needs: changesets + if: >- + (github.event_name == 'workflow_dispatch' && inputs.app != 'refresh') || + (github.event_name == 'push' && vars.NPM_PUBLISH_ENABLED == 'true' && + needs.changesets.outputs.needs_publish == 'true') + uses: ./.github/workflows/test.yml + with: + full: true + permissions: + contents: read + packages: read + # Hosted by necessity rather than by policy: npm's OIDC trusted publishing # does not support self-hosted runners ("not currently supported but are # planned for future releases", docs.npmjs.com/trusted-publishers, checked @@ -267,7 +283,7 @@ jobs: # silently preferred over OIDC. npm-publish: name: Publish @subshell-ai/* to npm (OIDC) - needs: changesets + needs: [changesets, verify] if: >- github.event_name == 'push' && vars.NPM_PUBLISH_ENABLED == 'true' && @@ -335,7 +351,12 @@ jobs: plan: name: Plan the cut - needs: changesets + needs: [changesets, verify] + if: >- + !cancelled() && needs.changesets.result == 'success' && + (needs.verify.result == 'success' || + (needs.verify.result == 'skipped' && + (github.event_name == 'push' || inputs.app == 'refresh'))) runs-on: ubuntu-24.04 timeout-minutes: 10 permissions: diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index ec8721e75..18feb8060 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -1,763 +1,187 @@ name: Test -# EVERY job here runs on GitHub-hosted ubuntu-24.04 runners (pinned, not -# `ubuntu-latest`: the desktop artifacts' minimum glibc is 2.39, which is what -# 24.04 IS, and an alias may one day move under it) inside the repo's own -# builder image. Moved off the self-hosted fleet on 2026-09-11 — hosted -# minutes are still metered on the free plan even after the repo went public, -# and that cost remains the accepted trade; the fleet's runners are -# org-registered and the repo page shows none of them, which is how a CI -# outage starts looking like a missing fleet. -# -# The image (docker/desktop-builder.Dockerfile) already carries bun 1.4.4, -# rustup stable and Tauri's system dependencies, which is why the jobs below -# install none of that: `setup-bun`, `dtolnay/rust-toolchain` and every -# `sudo apt-get` stay gone. The image is a PRIVATE package; the pull -# authenticates with the job token via the `packages: read` permission below. -# Inside a container we simply ARE root, which is also what makes Playwright's -# `install-deps` possible. -# -# The un-root steps closing each job are fleet-era artifacts: hosted runners -# are ephemeral, so they no-op here. They stay because the org fleet can still -# pick this workflow up unchanged — there, skipping them poisons the next -# checkout with `EACCES`, the failure `reset-linux-runner-workspace.yml` -# exists to repair. -# -# `timeout-minutes` on every job is still not boilerplate: minutes are now -# metered, so a hung job costs money instead of just fleet time — and the -# 360-minute GitHub default is an outage either way. -# -# The suites are split into ten routed jobs (web / mobile / desktop / -# server + node, in two --shard legs / packages / scripts / smoke / e2e, plus the two Rust ones: -# the desktop-core crate and the desktop apps' src-tauri legs) rather than -# one big run. -# The single `Testing` job had become the place failures were hard to LOCATE — -# a red check meant eleven workspaces of scrollback. Per-slice jobs make the -# job name the diagnosis, and run in parallel, so wall-clock drops even as -# total metered minutes rise: each job pays checkout + `bun install` (+ build -# where it needs one). That repeat is near-free on a turbo cache hit (the -# shared `-turbo-` restore-key prefix is what buys this), which is the trade -# as made. -# -# The `Plan` job decides WHICH slices run, via scripts/ci-test-plan.ts: -# turbo's own change-impact (`...[base]` — changed packages plus dependents, -# from the real build graph) routed against each slice, never hand-written -# directory globs. `Test: scripts` also gates on the FILES its suites read -# (package manifests and the like), and every degraded path — unreadable -# base ref, turbo failure, an unregistered workspace — returns run-everything. -# Slices skipped here show grey; there is deliberately no required-check rule -# to satisfy (no branch protection on this repo as of 2026-09-17). -# -# The two Rust jobs are routed too (2026-09-26, at the operator's request). -# Their inputs are not in the turbo graph — that fact stands, and is why they -# route on FILE predicates (`rustTouched` in the router) rather than package -# routing. The rot objection to gating them is answered by WHERE the rules -# live: registered and unit-tested beside `scriptsTouched`, not scattered -# into YAML conditionals. Every degraded path still runs them (allFlags -# covers the Rust pair), and a change to THIS file runs every slice — the -# router cannot route its own change, so a CI PR never greys its own proof. - on: pull_request: push: - branches: - - main - -# CI runs every suite SERIALLY inside its package (issue #261's scripts -# still parallelize local runs). The hosted container gets a fraction of its -# 4 labels (0.4 CPU measured in apps/server/web's budgets, twice), and bun's -# --parallel at ANY width - even 1, which is not serial - starved -# time-budgeted suites into per-test timeouts across three CI attempts on -# PR #277. scripts/par-test.sh omits the flag on this variable; the shard -# matrix is CI's speed, not worker threads. -env: - SUBSHELL_TEST_SERIAL: "1" + branches: [main] + workflow_dispatch: + workflow_call: + inputs: + full: + description: Run every check on the caller's exact revision before a release. + type: boolean + default: false permissions: contents: read - # Container jobs authenticate pulls of ghcr.io/subshell-ai/desktop-builder. - # Package visibility is separate from this public repository; retain read - # access for registry-authenticated CI pulls. packages: read -# A superseded push must not keep CI busy: pushing three times to a PR used to -# queue three full sets of jobs, all but the last of them already irrelevant. -# release.yml deliberately does the OPPOSITE (`cancel-in-progress: false`) — -# cancelling a release mid-flight leaves a tag without a release — but CI has -# nothing to protect. +# A release caller must not cancel itself or a PR run of the same revision. concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true + group: tests-${{ github.workflow }}-${{ github.ref }}-${{ inputs.full || false }} + cancel-in-progress: ${{ !inputs.full }} + +env: + # Keep the measured serial regime; package/job selection removes unrelated work. + SUBSHELL_TEST_SERIAL: "1" jobs: plan: name: Plan runs-on: ubuntu-24.04 - container: ghcr.io/subshell-ai/desktop-builder:ubuntu24.04 - timeout-minutes: 10 - defaults: - run: - shell: bash + timeout-minutes: 5 outputs: - web: ${{ steps.route.outputs.web }} - mobile: ${{ steps.route.outputs.mobile }} - desktop: ${{ steps.route.outputs.desktop }} - server-node: ${{ steps.route.outputs.serverNode }} - packages: ${{ steps.route.outputs.packages }} - scripts: ${{ steps.route.outputs.scripts }} - smoke: ${{ steps.route.outputs.smoke }} - e2e: ${{ steps.route.outputs.e2e }} - rustCore: ${{ steps.route.outputs.rustCore }} - rustApps: ${{ steps.route.outputs.rustApps }} + plan: ${{ steps.route.outputs.plan }} + units: ${{ steps.route.outputs.units }} + integration: ${{ steps.route.outputs.integration }} + rust: ${{ steps.route.outputs.rust }} steps: - # Full history: the router diffs against the PR base (or the push's - # before-sha), and a shallow clone cannot answer that question — the - # script's fail-wide path would then run everything, which is SAFE but - # makes the router a placebo. fetch-depth 0 is cheap here and the only - # job that needs it. - - name: Checkout Repository - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 - - # Same safe.directory step every container job in this workflow carries; - # see test-web for why. - - name: Trust the bind-mounted workspace - run: git config --global --add safe.directory "$GITHUB_WORKSPACE" - - # `bunx turbo` must be the repo's turbo, not a fresh download: the - # change-impact filter's semantics are pinned by the same - # toolchain-from-the-workflow rule the rest of this file follows. - - name: Use Node - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 - with: - node-version: 24 - - - name: Cache Bun install - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 with: - path: ~/.bun/install/cache - key: ${{ runner.os }}-bun-${{ hashFiles('bun.lock') }} - restore-keys: ${{ runner.os }}-bun- - - - name: Install Dependencies - run: bun install --frozen-lockfile - - - name: Route the slices + bun-version: 1.4.4 + - name: Select checks without installing dependencies id: route env: - # PRs diff against the base SHA from the event payload; pushes against - # the before-SHA (zero/absent on a branch's first push — the script - # fails wide there, which is the point). BASE: ${{ github.event_name == 'pull_request' && github.event.pull_request.base.sha || github.event.before }} - run: bun scripts/ci-test-plan.ts "$BASE" - - - name: Un-root the workspace - if: always() - run: | - set -euo pipefail - own="$(stat -c '%u:%g' "$RUNNER_WORKSPACE")" - echo "restoring workspace ownership to $own" - chown -R "$own" "$GITHUB_WORKSPACE" - - # ─── The suites, one job per slice ─────────────────────────────────────── - # - # The preamble (checkout, safe.directory, Node, caches, install, build) is - # identical across the jobs that need one and repeated on purpose — this - # workflow already repeats it across jobs, and the alternative (a composite - # action) would hide the container/safe.directory traps that the comments - # above each step exist to keep visible. `bun run build` is full-repo in - # every job that runs one (which is all of them — see the Test: scripts - # job for why even the manifest-reading suites need the dists built): - # turbo's cache makes the repeat nearly free, and a filtered - # `turbo run test` would rebuild the same dependency graph anyway. - # - # Filters are pinned against the unfiltered task set by - # `turbo run test --dry` — every workspace with a `test` script lands in - # exactly one slice. The packages filter is `./packages/**` RECURSIVE: the - # harness and network plugins live at `packages/plugins/` and a single - # star silently misses ten suites. - - test-web: - name: "Test: web" - needs: plan - if: needs.plan.outputs.web == 'true' - runs-on: ubuntu-24.04 - container: ghcr.io/subshell-ai/desktop-builder:ubuntu24.04 - timeout-minutes: 15 - defaults: - run: - # A container job defaults to `sh`, not bash, and these steps use - # `set -o pipefail`. Same reason release.yml sets it. - shell: bash - steps: - - name: Checkout Repository - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - # The workspace is bind-mounted and owned by the runner's user while the - # container runs as root, so git refuses it as "dubious ownership". That - # breaks `bun install` ITSELF, whose root `prepare` script runs - # `lefthook install`. The image sets safe.directory too, but a container - # job overrides HOME to /github/home, where that config is not — so the - # image's /root/.gitconfig is never read. Same step release.yml carries. - - name: Trust the bind-mounted workspace - run: git config --global --add safe.directory "$GITHUB_WORKSPACE" - - # tsdown/rolldown run under NODE, not bun — their bin shebang is - # `#!/usr/bin/env node` — so the repo has always had a Node requirement - # it never declared, and CI silently inherited whatever the host had. - # the fleet once shipped node 18.19 while ubuntu-latest shipped 20+; rolldown needs - # `styleText` from `node:util` (20.12+), so the build broke on the move. - # - # Pinned to the `engines.node` the root package.json now declares, NOT - # to rolldown's floor — Ubuntu 24.04's apt node is 18, so the image - # cannot supply this and setup-node is the only route. - - name: Use Node - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 - with: - node-version: 24 - - - name: Cache Bun install - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 - with: - path: ~/.bun/install/cache - key: ${{ runner.os }}-bun-${{ hashFiles('bun.lock') }} - restore-keys: ${{ runner.os }}-bun- - - - name: Cache Turbo - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 - with: - # Turbo v2's local cache is .turbo/cache at the repo root - # (measured 2026-09-22 against turbo 2.10.11: `bunx turbo run build` - # writes .turbo/cache). This previously named the turbo 1.x path, - # which v2 never writes — every job restored nothing and rebuilt the - # graph from scratch, the defect the shared restore-key prefix was - # supposed to prevent. - path: .turbo - key: ${{ runner.os }}-turbo-${{ github.sha }} - restore-keys: ${{ runner.os }}-turbo- - - - name: Install Dependencies - run: bun install --frozen-lockfile - - - name: Build workspace packages - run: bun run build - - # Browser signer tests generate real OpenSSH key fixtures. - - name: Ensure OpenSSH key generation - run: | - command -v ssh-keygen >/dev/null || { apt-get update && apt-get install -y --no-install-recommends openssh-client; } - - - name: Run web tests - run: bunx turbo run test --continue --filter=@internal/server-web - - - name: Un-root the workspace - if: always() - run: | - set -euo pipefail - own="$(stat -c '%u:%g' "$RUNNER_WORKSPACE")" - echo "restoring workspace ownership to $own" - chown -R "$own" "$GITHUB_WORKSPACE" - - test-mobile: - name: "Test: mobile" - needs: plan - if: needs.plan.outputs.mobile == 'true' - runs-on: ubuntu-24.04 - container: ghcr.io/subshell-ai/desktop-builder:ubuntu24.04 - timeout-minutes: 15 - defaults: - run: - shell: bash - steps: - - name: Checkout Repository - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - # The workspace is bind-mounted and owned by the runner's user while the - # container runs as root, so git refuses it as "dubious ownership". That - # breaks `bun install` ITSELF, whose root `prepare` script runs - # `lefthook install`. The image sets safe.directory too, but a container - # job overrides HOME to /github/home, where that config is not — so the - # image's /root/.gitconfig is never read. Same step release.yml carries. - - name: Trust the bind-mounted workspace - run: git config --global --add safe.directory "$GITHUB_WORKSPACE" - - # tsdown/rolldown run under NODE, not bun — see the test-web job. - - name: Use Node - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 - with: - node-version: 24 - - - name: Cache Bun install - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 - with: - path: ~/.bun/install/cache - key: ${{ runner.os }}-bun-${{ hashFiles('bun.lock') }} - restore-keys: ${{ runner.os }}-bun- - - - name: Cache Turbo - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 - with: - # Turbo v2's local cache is .turbo/cache at the repo root - # (measured 2026-09-22 against turbo 2.10.11: `bunx turbo run build` - # writes .turbo/cache). This previously named the turbo 1.x path, - # which v2 never writes — every job restored nothing and rebuilt the - # graph from scratch, the defect the shared restore-key prefix was - # supposed to prevent. - path: .turbo - key: ${{ runner.os }}-turbo-${{ github.sha }} - restore-keys: ${{ runner.os }}-turbo- - - - name: Install Dependencies - run: bun install --frozen-lockfile - - - name: Build workspace packages - run: bun run build - - - name: Run mobile tests - run: bunx turbo run test --continue --filter=@internal/mobile - - - name: Un-root the workspace - if: always() + FULL: ${{ inputs.full || github.event_name == 'workflow_dispatch' }} run: | - set -euo pipefail - own="$(stat -c '%u:%g' "$RUNNER_WORKSPACE")" - echo "restoring workspace ownership to $own" - chown -R "$own" "$GITHUB_WORKSPACE" + if [ "$FULL" = true ]; then + bun scripts/ci-test-plan.ts --full + else + bun scripts/ci-test-plan.ts "$BASE" + fi - test-desktop: - name: "Test: desktop apps" + checks: + name: Build, types, and lint needs: plan - if: needs.plan.outputs.desktop == 'true' runs-on: ubuntu-24.04 - container: ghcr.io/subshell-ai/desktop-builder:ubuntu24.04 timeout-minutes: 20 - defaults: - run: - shell: bash + env: + TEST_PLAN: ${{ needs.plan.outputs.plan }} steps: - - name: Checkout Repository - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - # The workspace is bind-mounted and owned by the runner's user while the - # container runs as root, so git refuses it as "dubious ownership". That - # breaks `bun install` ITSELF, whose root `prepare` script runs - # `lefthook install`. The image sets safe.directory too, but a container - # job overrides HOME to /github/home, where that config is not — so the - # image's /root/.gitconfig is never read. Same step release.yml carries. - - name: Trust the bind-mounted workspace - run: git config --global --add safe.directory "$GITHUB_WORKSPACE" - - # tsdown/rolldown run under NODE, not bun — see the test-web job. - - name: Use Node - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 - with: - node-version: 24 - - - name: Cache Bun install - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 - with: - path: ~/.bun/install/cache - key: ${{ runner.os }}-bun-${{ hashFiles('bun.lock') }} - restore-keys: ${{ runner.os }}-bun- - - - name: Cache Turbo - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 - with: - # Turbo v2's local cache is .turbo/cache at the repo root - # (measured 2026-09-22 against turbo 2.10.11: `bunx turbo run build` - # writes .turbo/cache). This previously named the turbo 1.x path, - # which v2 never writes — every job restored nothing and rebuilt the - # graph from scratch, the defect the shared restore-key prefix was - # supposed to prevent. - path: .turbo - key: ${{ runner.os }}-turbo-${{ github.sha }} - restore-keys: ${{ runner.os }}-turbo- - - - name: Install Dependencies - run: bun install --frozen-lockfile - - - name: Build workspace packages - run: bun run build - - # The TS half of both Tauri apps (release scripts, ui tests); their - # Rust halves are the desktop-rust matrix job below. Neither suite - # execs tmux — the desktop tests stub every spawn. - - name: Run desktop-app tests - run: bunx turbo run test --continue --filter=@internal/desktop-server --filter=@internal/desktop-client - - - name: Un-root the workspace - if: always() + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: ./.github/actions/setup-ci + - run: bun scripts/run-test-plan.ts checks + - name: Check shell scripts run: | - set -euo pipefail - own="$(stat -c '%u:%g' "$RUNNER_WORKSPACE")" - echo "restoring workspace ownership to $own" - chown -R "$own" "$GITHUB_WORKSPACE" + if ! command -v shellcheck >/dev/null; then + sudo apt-get update + sudo apt-get install -y shellcheck + fi + shellcheck -S warning proxmox-server.sh proxmox-node.sh docker-entrypoint.sh scripts/cli-e2e/docker-image.sh scripts/test-compiled-smoke.sh scripts/par-test.sh scripts/rust-check.sh - # "backend" was the old name and it lied twice: the vocabulary table carries - # no such word, and the two halves it bundled (the control plane and the - # agent) are exactly what a red job needs telling apart. The name now says - # which workspaces run, in the words the repo uses for them. - test-server-node: - name: "Test: server + node (shard ${{ matrix.shard }})" + unit: + name: Unit tests (${{ matrix.name }}) needs: plan - if: needs.plan.outputs.server-node == 'true' + if: needs.plan.outputs.units != '[]' + runs-on: ubuntu-24.04 + timeout-minutes: 25 strategy: fail-fast: false matrix: - # Issue #261: two legs run complementary `bun test --shard` halves of - # every filtered package. The committed .bun-test-timings.json makes - # the split by measured per-file duration rather than file count; - # regenerate it with `bun run test:timings`. - shard: ["1/2", "2/2"] - runs-on: ubuntu-24.04 - container: ghcr.io/subshell-ai/desktop-builder:ubuntu24.04 - timeout-minutes: 20 - defaults: - run: - shell: bash + include: ${{ fromJSON(needs.plan.outputs.units) }} + env: + TEST_PLAN: ${{ needs.plan.outputs.plan }} + TEST_LANE: ${{ toJSON(matrix) }} steps: - - name: Checkout Repository - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - # The workspace is bind-mounted and owned by the runner's user while the - # container runs as root, so git refuses it as "dubious ownership". That - # breaks `bun install` ITSELF, whose root `prepare` script runs - # `lefthook install`. The image sets safe.directory too, but a container - # job overrides HOME to /github/home, where that config is not — so the - # image's /root/.gitconfig is never read. Same step release.yml carries. - - name: Trust the bind-mounted workspace - run: git config --global --add safe.directory "$GITHUB_WORKSPACE" - - # The TmuxRunner suite drives a real tmux server — the ONLY consumer of - # one in the split (the web/desktop suites name tmux in strings only, - # and the compiled smoke never execs it). Idempotent so it costs nothing - # now that the image bakes tmux in, but a PR still pulls whatever tag - # `main` last published. - - name: Ensure tmux and jq - run: | - set -euo pipefail - missing=() - for tool in tmux jq; do command -v "$tool" >/dev/null || missing+=("$tool"); done - if [ ${#missing[@]} -gt 0 ]; then - apt-get update && apt-get install -y --no-install-recommends "${missing[@]}" - fi - tmux -V && jq --version - - # tsdown/rolldown run under NODE, not bun — see the test-web job. - - name: Use Node - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 - with: - node-version: 24 - - - name: Cache Bun install - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 - with: - path: ~/.bun/install/cache - key: ${{ runner.os }}-bun-${{ hashFiles('bun.lock') }} - restore-keys: ${{ runner.os }}-bun- - - - name: Cache Turbo - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: ./.github/actions/setup-ci with: - # Turbo v2's local cache is .turbo/cache at the repo root - # (measured 2026-09-22 against turbo 2.10.11: `bunx turbo run build` - # writes .turbo/cache). This previously named the turbo 1.x path, - # which v2 never writes — every job restored nothing and rebuilt the - # graph from scratch, the defect the shared restore-key prefix was - # supposed to prevent. - path: .turbo - key: ${{ runner.os }}-turbo-${{ github.sha }} - restore-keys: ${{ runner.os }}-turbo- - - - name: Install Dependencies - run: bun install --frozen-lockfile - - - name: Build workspace packages - run: bun run build - - # Turbo's passthrough appends the args to each filtered package's test - # command, so BOTH legs together cover every file once - # (`bun test ... src --shard=1/2` here, `--shard=2/2` on the sibling). - - name: Run server and node tests - run: bunx turbo run test --continue --filter=@internal/server --filter=@internal/node -- --shard=${{ matrix.shard }} --timings="$GITHUB_WORKSPACE/.bun-test-timings.json" - - - name: Un-root the workspace - if: always() + cache-suffix: ${{ matrix.name }} + - name: Ensure test tools run: | - set -euo pipefail - own="$(stat -c '%u:%g' "$RUNNER_WORKSPACE")" - echo "restoring workspace ownership to $own" - chown -R "$own" "$GITHUB_WORKSPACE" + if ! command -v tmux || ! command -v jq || ! command -v ssh-keygen; then + sudo apt-get update + sudo apt-get install -y tmux jq openssh-client + fi + - run: bun scripts/run-test-plan.ts unit - test-packages: - name: "Test: packages" + integration: + name: Browser and compiled CLI tests needs: plan - if: needs.plan.outputs.packages == 'true' + if: needs.plan.outputs.integration == 'true' + # Bare runner: Testcontainers needs the host Docker daemon and reachable mapped ports. runs-on: ubuntu-24.04 - container: ghcr.io/subshell-ai/desktop-builder:ubuntu24.04 - timeout-minutes: 15 - defaults: - run: - shell: bash + timeout-minutes: 45 + env: + TEST_PLAN: ${{ needs.plan.outputs.plan }} steps: - - name: Checkout Repository - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - # The workspace is bind-mounted and owned by the runner's user while the - # container runs as root, so git refuses it as "dubious ownership". That - # breaks `bun install` ITSELF, whose root `prepare` script runs - # `lefthook install`. The image sets safe.directory too, but a container - # job overrides HOME to /github/home, where that config is not — so the - # image's /root/.gitconfig is never read. Same step release.yml carries. - - name: Trust the bind-mounted workspace - run: git config --global --add safe.directory "$GITHUB_WORKSPACE" - - # tsdown/rolldown run under NODE, not bun — see the test-web job. - - name: Use Node - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 - with: - node-version: 24 - - - name: Cache Bun install - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 - with: - path: ~/.bun/install/cache - key: ${{ runner.os }}-bun-${{ hashFiles('bun.lock') }} - restore-keys: ${{ runner.os }}-bun- - - - name: Cache Turbo - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: ./.github/actions/setup-ci with: - # Turbo v2's local cache is .turbo/cache at the repo root - # (measured 2026-09-22 against turbo 2.10.11: `bunx turbo run build` - # writes .turbo/cache). This previously named the turbo 1.x path, - # which v2 never writes — every job restored nothing and rebuilt the - # graph from scratch, the defect the shared restore-key prefix was - # supposed to prevent. - path: .turbo - key: ${{ runner.os }}-turbo-${{ github.sha }} - restore-keys: ${{ runner.os }}-turbo- - - - name: Install Dependencies - run: bun install --frozen-lockfile - - - name: Build workspace packages - run: bun run build - - # `./packages/**`, recursive: the harness and network plugins live at - # `packages/plugins/*` and carry real suites; a single `*` matches one - # path segment and would have dropped all ten silently. - - name: Run package tests - run: bunx turbo run test --continue --filter='./packages/**' - - - name: Un-root the workspace - if: always() + cache-suffix: integration + - name: Ensure integration tools run: | - set -euo pipefail - own="$(stat -c '%u:%g' "$RUNNER_WORKSPACE")" - echo "restoring workspace ownership to $own" - chown -R "$own" "$GITHUB_WORKSPACE" - - # `bun run test` is `turbo run test`, which only reaches WORKSPACES — and - # `scripts/` is not one, so its suites ran nowhere until the split named - # them (and sat red for three pushes unnoticed for the same reason: no - # local or pre-push command runs `test:scripts`). These tests READ the repo - # — manifests, CHANGELOGs, lockfile, token files — which is why the Plan - # job routes them by changed-FILE predicates, not just by package. - # - # It DOES need the build, and that is load-bearing, not tidiness: - # desktop-dev.test.ts imports the launcher, which imports the desktop app's - # REAL `stageSidecar` — one function for launcher and release, the launcher - # running it in its declared source mode — which imports - # `@internal/subshell-protocol` through its dist. - # The first CI run of this job died on exactly that; a warm local tree - # cannot see the gap, which is the trap verification.md names. - test-scripts: - name: "Test: scripts" - needs: plan - if: needs.plan.outputs.scripts == 'true' - runs-on: ubuntu-24.04 - container: ghcr.io/subshell-ai/desktop-builder:ubuntu24.04 - timeout-minutes: 10 - defaults: - run: - shell: bash - steps: - - name: Checkout Repository - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - # The workspace is bind-mounted and owned by the runner's user while the - # container runs as root, so git refuses it as "dubious ownership". That - # breaks `bun install` ITSELF, whose root `prepare` script runs - # `lefthook install`. The image sets safe.directory too, but a container - # job overrides HOME to /github/home, where that config is not — so the - # image's /root/.gitconfig is never read. Same step release.yml carries. - - name: Trust the bind-mounted workspace - run: git config --global --add safe.directory "$GITHUB_WORKSPACE" - - # tsdown runs under NODE — see test-web for why CI pins it. - - name: Use Node - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 - with: - node-version: 24 - - - name: Cache Bun install - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + sudo apt-get update + sudo apt-get install -y tmux jq openssh-client openssh-server + docker info >/dev/null + - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + if: fromJSON(needs.plan.outputs.plan).e2e with: - path: ~/.bun/install/cache - key: ${{ runner.os }}-bun-${{ hashFiles('bun.lock') }} - restore-keys: ${{ runner.os }}-bun- - - - name: Cache Turbo - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + path: ~/.cache/ms-playwright + key: ${{ runner.os }}-playwright-${{ hashFiles('e2e/package.json') }} + - name: Install browser + if: fromJSON(needs.plan.outputs.plan).e2e + working-directory: e2e + run: bunx playwright install --with-deps chromium + - run: bun scripts/run-test-plan.ts integration + - name: Upload browser failures + if: failure() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: - # Turbo v2's local cache is .turbo/cache at the repo root - # (measured 2026-09-22 against turbo 2.10.11: `bunx turbo run build` - # writes .turbo/cache). This previously named the turbo 1.x path, - # which v2 never writes — every job restored nothing and rebuilt the - # graph from scratch, the defect the shared restore-key prefix was - # supposed to prevent. - path: .turbo - key: ${{ runner.os }}-turbo-${{ github.sha }} - restore-keys: ${{ runner.os }}-turbo- - - - name: Install Dependencies - run: bun install --frozen-lockfile - - - name: Build workspace packages - run: bun run build - - - name: Run repo script tests - run: bun run test:scripts - - # The website's suites run here: scripts/ci-test-plan.ts registers - # @internal/website to this slice (they are plain `bun test`, no - # browser, and the build step above already produced the - # subshell-protocol dist their imports resolve through). - - name: Run website tests - run: bunx turbo run test --continue --filter=@internal/website - - - name: Un-root the workspace - if: always() - run: | - set -euo pipefail - own="$(stat -c '%u:%g' "$RUNNER_WORKSPACE")" - echo "restoring workspace ownership to $own" - chown -R "$own" "$GITHUB_WORKSPACE" + name: playwright-report + path: | + e2e/playwright-report/ + e2e/test-results/ + retention-days: 7 - # The smoke compiles the CONTROL PLANE binary and exercises it — the old - # "(backend)" suffix was wrong twice over: the node agent has no smoke, - # and a red job here can only mean the server's bundle or its refusal - # contract. Plan lights it when @internal/server is affected (that IS its - # import closure — the defect class this guards) or scripts/** changes - # (the refusal script lives there). - test-smoke: - name: "Test: smoke (server)" + rust: + name: Rust (Linux) needs: plan - if: needs.plan.outputs.smoke == 'true' + if: needs.plan.outputs.rust == 'true' runs-on: ubuntu-24.04 container: ghcr.io/subshell-ai/desktop-builder:ubuntu24.04 - timeout-minutes: 20 + timeout-minutes: 40 + env: + TEST_PLAN: ${{ needs.plan.outputs.plan }} defaults: run: shell: bash steps: - - name: Checkout Repository - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - # The workspace is bind-mounted and owned by the runner's user while the - # container runs as root, so git refuses it as "dubious ownership". That - # breaks `bun install` ITSELF, whose root `prepare` script runs - # `lefthook install`. The image sets safe.directory too, but a container - # job overrides HOME to /github/home, where that config is not — so the - # image's /root/.gitconfig is never read. Same step release.yml carries. - - name: Trust the bind-mounted workspace - run: git config --global --add safe.directory "$GITHUB_WORKSPACE" - - # The smoke reads the expected version out of package.json with jq. - - name: Ensure jq - run: | - set -euo pipefail - command -v jq >/dev/null || { apt-get update && apt-get install -y --no-install-recommends jq; } - jq --version - - # tsdown/rolldown run under NODE, not bun — see the test-web job. - - name: Use Node - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 - with: - node-version: 24 - - - name: Cache Bun install - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 - with: - path: ~/.bun/install/cache - key: ${{ runner.os }}-bun-${{ hashFiles('bun.lock') }} - restore-keys: ${{ runner.os }}-bun- - - - name: Cache Turbo - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - run: | + git config --global --add safe.directory "$GITHUB_WORKSPACE" + apt-get update + apt-get install -y --no-install-recommends cmake clang libclang-dev + rustup component add rustfmt clippy + - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: - # Turbo v2's local cache is .turbo/cache at the repo root - # (measured 2026-09-22 against turbo 2.10.11: `bunx turbo run build` - # writes .turbo/cache). This previously named the turbo 1.x path, - # which v2 never writes — every job restored nothing and rebuilt the - # graph from scratch, the defect the shared restore-key prefix was - # supposed to prevent. - path: .turbo - key: ${{ runner.os }}-turbo-${{ github.sha }} - restore-keys: ${{ runner.os }}-turbo- - - - name: Install Dependencies - run: bun install --frozen-lockfile - - - name: Build workspace packages - run: bun run build - - # Compiled-binary smoke guards against bundler import-graph defects - # (e.g. oven-sh/bun#31586 — zod subpath duplication) that source-level - # `bun test` cannot see. - - name: Compiled binary smoke - run: | - set -euo pipefail - cd apps/server/api - bun run compile - bin="$PWD/dist/subshell-server" - expected="subshell-server $(jq -r .version package.json)" - actual="$("$bin" version)" - [ "$actual" = "$expected" ] || { echo "version mismatch: got '$actual', want '$expected'"; exit 1; } - # The `mcp` refusal contract (clean-env refusal + no CWD litter) - # lives in ONE script, shared with the release shards. - bash ../../../scripts/smoke-mcp-refusal.sh "$bin" - # Real-bytes pin for the renamed-artifact guard (7d5d6f4): the unit - # tests fake the compiled argv[1]; this asserts against a REAL - # compiled copy under a foreign name that the self rung never - # resolves from baked/virtual build paths (`via self` would bake an - # entry that cannot exist on the target host). - tmp="$(mktemp -d)" - trap 'rm -rf "$tmp"' EXIT - cp "$bin" "$tmp/renamed-server" - status_out="$("$tmp/renamed-server" status 2>&1 || true)" - echo "$status_out" - if printf '%s' "$status_out" | grep -q "via self"; then echo "renamed compiled binary self-resolved its mcp entrypoint"; exit 1; fi - echo "compiled-binary smoke OK: version + clean mcp refusal + renamed-artifact fall-through" - - - name: Un-root the workspace - if: always() - run: | - set -euo pipefail - own="$(stat -c '%u:%g' "$RUNNER_WORKSPACE")" - echo "restoring workspace ownership to $own" - chown -R "$own" "$GITHUB_WORKSPACE" + path: | + /usr/local/cargo/registry + /usr/local/cargo/git + crates/desktop-core/target + apps/server/desktop/src-tauri/target + apps/client/desktop/src-tauri/target + key: linux-rust-${{ hashFiles('**/Cargo.lock', 'native/display-helper/**') }} + restore-keys: linux-rust- + - run: bun scripts/run-test-plan.ts rust - macos-display-helper: - name: macOS display helper and desktop integration + macos: + name: Rust and native display helper (macOS) needs: plan - if: needs.plan.outputs.rustApps == 'true' + if: needs.plan.outputs.rust == 'true' runs-on: macos-14 - timeout-minutes: 30 + timeout-minutes: 40 + env: + TEST_PLAN: ${{ needs.plan.outputs.plan }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + with: + bun-version: 1.4.4 - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: | @@ -766,327 +190,33 @@ jobs: crates/desktop-core/target apps/client/desktop/src-tauri/target apps/server/desktop/src-tauri/target - key: macos-helper-cargo-${{ hashFiles('**/Cargo.lock', 'native/display-helper/**') }} - restore-keys: macos-helper-cargo- - - name: Check native consent and both desktop apps without opening a GUI - run: | - rustup component add rustfmt clippy - env -u SHELLOPTS -u BASHOPTS bash scripts/rust-check.sh - - # The shared Rust library both desktop apps depend on. Its own job because it - # has NO tauri dependency and therefore none of Tauri's system deps — it is a - # ~30-second job, and keeping it separate means the apps' matrix legs do not - # each re-run it. - desktop-core-rust: - name: Desktop core (Rust) - needs: plan - if: needs.plan.outputs.rustCore == 'true' + key: macos-rust-${{ hashFiles('**/Cargo.lock', 'native/display-helper/**') }} + restore-keys: macos-rust- + - run: rustup component add rustfmt clippy + - run: bun scripts/run-test-plan.ts rust + + result: + name: Tests passed + if: always() + needs: [plan, checks, unit, integration, rust, macos] runs-on: ubuntu-24.04 - container: ghcr.io/subshell-ai/desktop-builder:ubuntu24.04 - timeout-minutes: 20 - defaults: - run: - shell: bash + timeout-minutes: 5 steps: - - name: Checkout Repository - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - # The workspace is bind-mounted and owned by the runner's user while the - # container runs as root, so git refuses it as "dubious ownership". That - # breaks `bun install` ITSELF, whose root `prepare` script runs - # `lefthook install`. The image sets safe.directory too, but a container - # job overrides HOME to /github/home, where that config is not — so the - # image's /root/.gitconfig is never read. Same step release.yml carries. - - name: Trust the bind-mounted workspace - run: git config --global --add safe.directory "$GITHUB_WORKSPACE" - - # The image installs rustup with `--profile minimal`, which does NOT - # carry rustfmt or clippy — and this job is nothing but those two plus - # tests. Idempotent, so it is a no-op once the image bakes them in. - - name: Ensure native crypto build dependencies - run: | - apt-get update - apt-get install -y --no-install-recommends cmake clang libclang-dev - - - name: Ensure rustfmt and clippy - run: | - set -euo pipefail - rustup component add rustfmt clippy - cargo fmt --version && cargo clippy --version - - - name: Cache cargo - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 - with: - # CARGO_HOME is /usr/local/cargo in the image, not ~/.cargo. - path: | - /usr/local/cargo/registry - /usr/local/cargo/git - crates/desktop-core/target - key: ${{ runner.os }}-cargo-core-${{ hashFiles('crates/desktop-core/Cargo.lock') }} - restore-keys: ${{ runner.os }}-cargo-core- - - - name: Format, lint and test - working-directory: crates/desktop-core - run: | - cargo fmt --check - cargo clippy --all-targets -- -D warnings - cargo test - - - name: Un-root the workspace - if: always() - run: | - set -euo pipefail - own="$(stat -c '%u:%g' "$RUNNER_WORKSPACE")" - echo "restoring workspace ownership to $own" - chown -R "$own" "$GITHUB_WORKSPACE" - - # The two Tauri apps' Rust halves. Deliberately their OWN job rather than part - # of `bun run test`: cargo is kept out of the turbo build graph, and these - # need Tauri's system deps — which is exactly what the builder image carries, - # so the old `sudo apt-get install libwebkit2gtk…` step is gone. - desktop-rust: - name: Desktop (Rust) — ${{ matrix.app }} - needs: plan - if: needs.plan.outputs.rustApps == 'true' - runs-on: ubuntu-24.04 - container: ghcr.io/subshell-ai/desktop-builder:ubuntu24.04 - timeout-minutes: 30 - defaults: - run: - shell: bash - strategy: - # Both legs run side by side, and neither finds a warm `target/` under - # it — hosted workspaces are ephemeral — so the cargo cache step above is - # the only persistence either leg gets. - fail-fast: false - matrix: - # `app` is the component id (also the release tag prefix); `dir` is - # where it lives under apps/. The two diverged when the apps became - # nested — see release.yml's plan job for the same table. - include: - - app: desktop-server - dir: server/desktop - sidecar: subshell-server-bundled - - app: desktop-client - dir: client/desktop - sidecar: subshell-node-bundled - steps: - - name: Checkout Repository - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - # The workspace is bind-mounted and owned by the runner's user while the - # container runs as root, so git refuses it as "dubious ownership". That - # breaks `bun install` ITSELF, whose root `prepare` script runs - # `lefthook install`. The image sets safe.directory too, but a container - # job overrides HOME to /github/home, where that config is not — so the - # image's /root/.gitconfig is never read. Same step release.yml carries. - - name: Trust the bind-mounted workspace - run: git config --global --add safe.directory "$GITHUB_WORKSPACE" - - - name: Ensure native crypto build dependencies - run: | - apt-get update - apt-get install -y --no-install-recommends cmake clang libclang-dev - - - name: Ensure rustfmt and clippy - run: | - set -euo pipefail - rustup component add rustfmt clippy - - - name: Cache cargo - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 - with: - path: | - /usr/local/cargo/registry - /usr/local/cargo/git - apps/${{ matrix.dir }}/src-tauri/target - key: ${{ runner.os }}-cargo-${{ matrix.app }}-${{ hashFiles(format('apps/{0}/src-tauri/Cargo.lock', matrix.dir), 'crates/desktop-core/**/*.rs') }} - restore-keys: ${{ runner.os }}-cargo-${{ matrix.app }}- - - # `tauri-build` REFUSES TO BUILD when an `externalBin` file is missing, - # and the staged sidecar is a gitignored ~110 MB build input — so a clean - # checkout cannot compile this crate at all without one. `bun run - # rust:check` does the same thing locally. - # - # A stub is the right answer here rather than a real build. This job tests - # the Rust logic — the resolution ladder, the ExecStart parser, the - # install policy, the process runner — none of which executes the sidecar; - # and `tauri-build` only checks that the path EXISTS. The real bundle, - # with a real binary inside it, is proven by - # `scripts/smoke-desktop-bundle.sh` on the release shards. - - name: Stage a stub sidecar - working-directory: apps/${{ matrix.dir }}/src-tauri + - name: Require every selected check to pass env: - SIDECAR: ${{ matrix.sidecar }} - run: | - set -euo pipefail - triple="$(rustc --print host-tuple)" - install -m 755 /dev/null "binaries/$SIDECAR-$triple" - echo "staged a STUB sidecar for $triple (the release pipeline stages a real one)" - - - name: Format, lint and test - working-directory: apps/${{ matrix.dir }}/src-tauri - run: | - cargo fmt --check - cargo clippy --all-targets -- -D warnings - cargo test - - - name: Un-root the workspace - if: always() - run: | - set -euo pipefail - own="$(stat -c '%u:%g' "$RUNNER_WORKSPACE")" - echo "restoring workspace ownership to $own" - chown -R "$own" "$GITHUB_WORKSPACE" - - # Gated by the same router: `@internal/e2e`'s devDeps on server, server-web - # and mcp-core mean turbo's dependent-expansion lights this — the priciest - # job in the file is the last one to run for a change that cannot affect it. - e2e: - name: End-to-end - needs: plan - if: needs.plan.outputs.e2e == 'true' - runs-on: ubuntu-24.04 - container: ghcr.io/subshell-ai/desktop-builder:ubuntu24.04 - timeout-minutes: 30 - defaults: - run: - shell: bash - steps: - - name: Checkout Repository - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - # The workspace is bind-mounted and owned by the runner's user while the - # container runs as root, so git refuses it as "dubious ownership". That - # breaks `bun install` ITSELF, whose root `prepare` script runs - # `lefthook install`. The image sets safe.directory too, but a container - # job overrides HOME to /github/home, where that config is not — so the - # image's /root/.gitconfig is never read. Same step release.yml carries. - - name: Trust the bind-mounted workspace - run: git config --global --add safe.directory "$GITHUB_WORKSPACE" - - # Spec 12 (session lifecycle) spawns real tmux sessions (stub harness); - # spec 22 (ssh terminal) drives a REAL sshd plus an ssh client. Neither - # OpenSSH binary is in the builder image; installing here rather than - # in the image keeps the shared image lean. The container job runs as - # root, so apt-get needs no sudo. Tools map to PACKAGES, not names: - # "ssh" is not an apt package (openssh-client ships ssh + ssh-keygen; - # openssh-server ships sshd), so the missing binaries are translated - # before the deduped set is installed. - - name: Ensure tmux, jq, and OpenSSH - run: | - set -euo pipefail - pkg_for() { - case "$1" in - tmux) echo tmux ;; - jq) echo jq ;; - ssh|ssh-keygen) echo openssh-client ;; - sshd) echo openssh-server ;; - esac - } - missing_pkgs=() - for tool in tmux jq ssh ssh-keygen sshd; do - command -v "$tool" >/dev/null && continue - pkg="$(pkg_for "$tool")" - for p in "${missing_pkgs[@]+"${missing_pkgs[@]}"}"; do - [ "$p" = "$pkg" ] && continue 2 - done - missing_pkgs+=("$pkg") - done - if [ ${#missing_pkgs[@]} -gt 0 ]; then - apt-get update && apt-get install -y --no-install-recommends "${missing_pkgs[@]}" - fi - tmux -V && jq --version && ssh -V - # sshd -V prints usage and exits nonzero on some builds, so the - # presence proof is the binary: on PATH, else the package's - # documented location (the fixture probes /usr/sbin too). - command -v sshd || ls -l /usr/sbin/sshd - # Loud post-install assertion: an apt package rename would - # otherwise surface only as spec 22 skipping, i.e. the proof - # silently not running. - if ! command -v sshd >/dev/null && [ ! -x /usr/sbin/sshd ]; then - echo "::error::openssh-server present in no sshd location (PATH or /usr/sbin/sshd)" - exit 1 - fi - - # tsdown/rolldown run under NODE, not bun — their bin shebang is - # `#!/usr/bin/env node` — so the repo has always had a Node requirement - # it never declared, and CI silently inherited whatever the host had. - # the fleet once shipped node 18.19 while ubuntu-latest shipped 20+; rolldown needs - # `styleText` from `node:util` (20.12+), so the build broke on the move. - # - # Pinned to the `engines.node` the root package.json now declares, NOT - # to rolldown's floor — Ubuntu 24.04's apt node is 18, so the image - # cannot supply this and setup-node is the only route. - - name: Use Node - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 - with: - node-version: 24 - - - name: Cache Bun install - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 - with: - path: ~/.bun/install/cache - key: ${{ runner.os }}-bun-${{ hashFiles('bun.lock') }} - restore-keys: ${{ runner.os }}-bun- - - - name: Cache Turbo - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 - with: - # Turbo v2's local cache is .turbo/cache at the repo root - # (measured 2026-09-22 against turbo 2.10.11: `bunx turbo run build` - # writes .turbo/cache). This previously named the turbo 1.x path, - # which v2 never writes — every job restored nothing and rebuilt the - # graph from scratch, the defect the shared restore-key prefix was - # supposed to prevent. - path: .turbo - key: ${{ runner.os }}-turbo-${{ github.sha }} - restore-keys: ${{ runner.os }}-turbo- - - - name: Install Dependencies - run: bun install --frozen-lockfile - - - name: Build workspace packages - run: bun run build - - # Keyed on the Playwright version so a bump downloads a matching browser. - - name: Cache Playwright browsers - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 - with: - path: ~/.cache/ms-playwright - key: ${{ runner.os }}-playwright-${{ hashFiles('e2e/package.json') }} - restore-keys: ${{ runner.os }}-playwright- - - # `install-browsers` is `playwright install chromium` — it fetches the - # browser but NOT the shared libraries it dlopens, which ubuntu-latest - # happened to already have and a bare ubuntu:24.04 image does not. A - # chromium that downloads fine and then cannot launch is the failure this - # avoids. Kept out of the package script deliberately: `install-deps` - # needs root, which a developer's machine should not be asked for. - - name: Install Chromium system libraries - working-directory: e2e - run: bunx playwright install-deps chromium - - - name: Install Chromium - run: bun run test:e2e:install - - - name: Run end-to-end tests - run: bun run test:e2e - - - name: Upload report on failure - if: failure() - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 - with: - name: playwright-report - path: | - e2e/playwright-report/ - e2e/test-results/ - retention-days: 7 - - - name: Un-root the workspace - if: always() - run: | - set -euo pipefail - own="$(stat -c '%u:%g' "$RUNNER_WORKSPACE")" - echo "restoring workspace ownership to $own" - chown -R "$own" "$GITHUB_WORKSPACE" + RESULTS: ${{ toJSON(needs) }} + run: | + python3 - <<'PY' + import json, os + jobs = json.loads(os.environ['RESULTS']) + assert jobs['plan']['result'] == 'success', 'Planning failed' + outputs = jobs['plan']['outputs'] + required = ['checks'] + if json.loads(outputs['units']): required.append('unit') + if outputs['integration'] == 'true': required.append('integration') + if outputs['rust'] == 'true': required += ['rust', 'macos'] + failed = [name for name in required if jobs[name]['result'] != 'success'] + assert not failed, f'Required checks did not pass: {failed}' + with open(os.environ['GITHUB_STEP_SUMMARY'], 'a') as summary: + summary.write('All selected checks passed.\n\n```json\n' + outputs['plan'] + '\n```\n') + PY diff --git a/.github/workflows/website.yml b/.github/workflows/website.yml index 23bb94b21..7ea2d9e46 100644 --- a/.github/workflows/website.yml +++ b/.github/workflows/website.yml @@ -9,13 +9,8 @@ # commit means "redeploy" (below), where for release.yml it would mean "skip". # Spec 2026-09-23. # -# No CI gate, deliberately. There was one: a `ci-gate` job waited for this -# commit's push-triggered Test + Lint runs and refused anything but success. -# The operator removed it on 2026-09-25, the day a flaky job in an unrelated -# package's Test run blocked BOTH site deploys until a human re-ran it. What -# proves a static site is `lint.yml` building this export on every push (and -# PR CI before that); the gate only coupled deploy health to server/node test -# luck, which for a one-page site is borrowing someone else's red light. +# Every release cut requires a fresh full verification of this revision, +# including site releases. A failure blocks tag creation and deployment. # # Flow: # plan: refuse any run not on refs/heads/main, resolve the version @@ -50,7 +45,17 @@ concurrency: cancel-in-progress: false jobs: + verify: + name: Full release verification + uses: ./.github/workflows/test.yml + with: + full: true + permissions: + contents: read + packages: read + plan: + needs: verify name: Plan the deploy runs-on: ubuntu-24.04 timeout-minutes: 10 @@ -125,7 +130,7 @@ jobs: bun-version: 1.4.4 # next's bin carries a `#!/usr/bin/env node` shebang. Same reason - # lint.yml pins node: a runner image floats its own node, and the + # test.yml pins node: a runner image floats its own node, and the # pinned version (the root package.json's `engines.node`) is the # contract, not the image's mood. - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 diff --git a/AGENTS.md b/AGENTS.md index 550e1fde1..a252054c9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -499,16 +499,33 @@ turbo build # Build all packages ### Testing -Testing is `bun test` (vitest was removed: its node worker cannot import -`bun:sqlite`). +Choose checks by the behavior the change can break. **Do not run the full +suite just because a task is ending, a PR is opening, or an agent is handing +off.** Start with existing covering files; run affected packages once before +handoff. Pure prose needs the relevant prose/content checks, not product tests. Copy +and styling changes without behavioral changes need focused content/visual +review and lint; don't add or run broad behavioral suites for them. +Read [the testing policy](docs/testing.md) for selection, test value, and the +Bun/Vitest assessment. ```bash -bun run test # Run tests across all packages -bun run test:e2e # Playwright end-to-end suite (boots its own backend on :3199) +bun run test:changed -- --plan # Inspect affected work, including uncommitted files +bun run test:changed -- --base origin/main # Affected unit, integration, and Rust tests +bun run verify:changed # Also build, type-check, and lint affected packages +bun run test # All workspace unit tests (explicit opt-in) +bun run test:full # Every check and test tier; required before release ``` +`test:changed -- --unit` runs only the selected unit suites during iteration. +It does not replace integration checks when the change reaches those boundaries. +Full release verification includes script and brand tests, Playwright, compiled +CLI scenarios, Rust on Linux and macOS, and native display-helper tests. The +release workflow runs it on the exact revision before creating tags or publishing +npm packages; failed, skipped-required, or cancelled checks block publication. +A local `test:full` verifies the current host; CI supplies both desktop platforms. + **Locally, always run the `test` scripts (`bun run test`, -`bun run test --filter=`, a package's own `bun run test`), never a bare +`bunx turbo run test --filter=`, a package's own `bun run test`), never a bare `bun test` for a suite run.** The `--parallel=N` flag that gives the suite its worker processes lives IN the scripts (bun silently ignores a `parallel` key in `bunfig.toml`, measured on 1.4.0/1.4.2), so a hand-typed `bun test` runs @@ -597,7 +614,7 @@ wires the hooks up. To resync by hand: `bunx lefthook install`. `pre-commit` formats/lints staged files; `pre-push` runs `verify-types` and `lint:check` only: the test suite belongs to CI (`.github/workflows/test.yml`) so pushes stay fast. -Run `bun run test` yourself before pushing work you want green on the first try. +Run `bun run verify:changed` before pushing; do not repeat a successful full suite for unrelated work. ### Cleaning @@ -614,7 +631,8 @@ user-visible changes to the four releasable apps (or to `@internal/docs`, whose bump drives the docs deploy instead of a release cut) → a version PR ("chore: release package(s)") maintained on every push to main; merging it bumps the app's `package.json` + CHANGELOG and publishes the seven -`@subshell-ai/*` npm packages. Merging does NOT cut a GitHub Release: cuts +`@subshell-ai/*` npm packages. Merging does NOT cut a GitHub Release. Publishing npm packages and cutting +app releases both require fresh full verification of that revision. Cuts are an explicit `gh workflow run release.yml -f app=all` (or a single component). **Tagging and releasing is owned by the workflow; never cut tags by hand.** diff --git a/apps/client/desktop/ui/src/__tests__/app.test.tsx b/apps/client/desktop/ui/src/__tests__/app.test.tsx index 6bcaf7ac5..a14b422e5 100644 --- a/apps/client/desktop/ui/src/__tests__/app.test.tsx +++ b/apps/client/desktop/ui/src/__tests__/app.test.tsx @@ -639,13 +639,6 @@ describe("the register form asks the three answers once", () => { expect(fake.callsTo("node_enroll").length).toBe(0); }); - it("says where the key comes from, in one line", async () => { - // The register screen carries the ONE sentence (the old re-enroll - // screen's notes column retired with its door): where to obtain a key. - await openRegisterForm(); - expect(screen.getByText(/open Nodes → Add node to create a setup key/)).toBeTruthy(); - }); - // A warning, never a block. it("warns about a loopback URL without disabling anything", async () => { const fake = await openRegisterForm(enrolledOk); @@ -2099,14 +2092,6 @@ describe("tmux is a hard stop, not a hint", () => { await screen.findByRole("heading", { name: "Install tmux" }); expect(screen.queryByText("Setup key")).toBeNull(); }); - - it("the install button says it also starts, because the CLI's install does", async () => { - await boot({ - probe: makeProbe({ ...STOPPED, step: "no-service", service: { installed: false } }), - }); - await openSection("Service"); - expect(button("Install and Start")).toBeTruthy(); - }); }); describe("the plane list and its doors", () => { diff --git a/apps/client/desktop/ui/src/__tests__/no-inline-styles.test.ts b/apps/client/desktop/ui/src/__tests__/no-inline-styles.test.ts index 37129fc30..9e12fdb53 100644 --- a/apps/client/desktop/ui/src/__tests__/no-inline-styles.test.ts +++ b/apps/client/desktop/ui/src/__tests__/no-inline-styles.test.ts @@ -39,11 +39,8 @@ function sourceFiles(dir: string): string[] { describe("nothing under ui/src can be styled inline", () => { const files = sourceFiles(UI_SRC).filter((f) => basename(f) !== SELF); - it("finds files to check", () => { - expect(files.length).toBeGreaterThan(10); - }); - it("uses no `style` prop or attribute", () => { + expect(files.length).toBeGreaterThan(0); for (const file of files) { const body = readFileSync(file, "utf8"); // `style={...}` (a JSX prop) and `style="..."` (an HTML attribute). @@ -68,8 +65,4 @@ describe("the HTML shell", () => { expect(html).not.toMatch(/]/i); expect(html).not.toMatch(/]*\bsrc=)/i); }); - - it("is dark from the first paint, matching the dark-only palette", () => { - expect(html).toContain('class="dark"'); - }); }); diff --git a/apps/client/desktop/ui/src/__tests__/service-screen.test.tsx b/apps/client/desktop/ui/src/__tests__/service-screen.test.tsx index f110101f7..f5a8dc254 100644 --- a/apps/client/desktop/ui/src/__tests__/service-screen.test.tsx +++ b/apps/client/desktop/ui/src/__tests__/service-screen.test.tsx @@ -396,19 +396,6 @@ describe("Un-enroll", () => { mount({ output: { ok: false, stdout: "", stderr: "Failed to restart: Unit is masked." } }); expect(screen.getByText(/Unit is masked/)).toBeTruthy(); }); - - it("stands the two binding acts on one row with no divider", () => { - mount(); - const re = button("Re-enroll…"); - const un = button("Un-enroll…"); - expect(re.parentElement).toBe(un.parentElement); - expect(re.parentElement?.className).toContain("flex"); - // No rule inside the card (the frame's own bar may keep its border; - // this asserts the card, not the page). - const card = re.closest('[class*="rounded-md"]'); - expect(card).not.toBeNull(); - expect(card?.querySelectorAll('[class*="border-t"]').length).toBe(0); - }); }); /** diff --git a/apps/client/desktop/ui/src/__tests__/status-screen.test.tsx b/apps/client/desktop/ui/src/__tests__/status-screen.test.tsx index d55444191..a9d4f6050 100644 --- a/apps/client/desktop/ui/src/__tests__/status-screen.test.tsx +++ b/apps/client/desktop/ui/src/__tests__/status-screen.test.tsx @@ -91,23 +91,6 @@ describe("an enrolled, online machine", () => { expect(maybeButton(/^(start|restart|install and start)$/i)).toBeNull(); }); - /** - * The state chip lives in the header (operator ruling 2026-09-22): title, - * badge, subtitle — not a sandwich cut between the subtitle and the - * sentence under it. Order of the three is the pin. - */ - it("reads the badge between the title and the subtitle", () => { - mount({ enrolledNode: { nodeId: makeProbe().status?.nodeId ?? "", name: "mac mini" } }); - const title = screen.getByRole("heading", { name: "This Machine" }); - const badge = screen.getByText("Online"); - const subtitle = screen.getByText("What this machine is doing."); - const sentence = screen.getByText(/Enrolled as/); - const before = (a: Element, b: Element) => !!(a.compareDocumentPosition(b) & Node.DOCUMENT_POSITION_FOLLOWING); - expect(before(title, badge)).toBe(true); - expect(before(badge, subtitle)).toBe(true); - expect(before(subtitle, sentence)).toBe(true); - }); - // The ONE-Entry ruling stated as its own pin (operator ruling 2026-09-22, // fix wave): the rail's Reset section is Unregister's only entry, and // "only" is testable here. The rails final addendum (2026-09-22) narrowed @@ -184,22 +167,6 @@ describe("the status subtitle", () => { }); describe("a client that is not a node", () => { - /** - * Re-enrolling is what you do to a machine that IS one; on this machine the - * act with that meaning is Register, and two labels for one thing is two - * things to a reader. - */ - it("does not offer Re-enroll", () => { - mount({ probe: watcherProbe() }); - expect(maybeButton(/re-enroll/i)).toBeNull(); - }); - - /** There is no node address to repoint — the node block is the node's. */ - it("does not offer to repoint anything", () => { - mount({ probe: watcherProbe() }); - expect(maybeButton(/repoint this node/i)).toBeNull(); - }); - it("says what the machine is, not which server it opens", () => { // The status screen keeps machine state (operator ruling 2026-09-22); // the plane address is the Control Plane section's, shown labeled there, @@ -214,49 +181,3 @@ describe("a client that is not a node", () => { // The plane addresses moved to the Control Plane section (operator ruling // 2026-09-22); their screen-level pins live in plane-screen.test.tsx now. - -describe("what the connected screen offered is still offered", () => { - it("offers neither the plane doors nor the update door — the rail and the Control Plane section carry them", () => { - // The plane address's home is the Control Plane section and the update - // door is the rail's Update section (operator rulings, 2026-09-22); the - // status screen keeps machine state and the two machine acts. - mount(); - expect(maybeButton(/change server/i)).toBeNull(); - expect(maybeButton(/open the control plane/i)).toBeNull(); - // No doors at all after the second addendum: the Control Plane section's - // Dashboard card is the only place that opens the plane. - expect(maybeButton(/open in browser/i)).toBeNull(); - expect(maybeButton(/open in app/i)).toBeNull(); - expect(maybeButton(/open dashboard/i)).toBeNull(); - expect(maybeButton(/check for updates/i)).toBeNull(); - expect(maybeButton(/update the node to/i)).toBeNull(); - }); - - it("offers no Re-enroll even on a machine that is one — the act is the Control Plane section's", () => { - // Operator ruling 2026-09-22: re-enrolling is an act on this machine's - // RELATIONSHIP to the plane, so it moved out of the machine-state screen. - // The Control Plane side of the move is pinned in plane-screen.test.tsx. - mount(); - expect(maybeButton(/re-enroll/i)).toBeNull(); - }); - - it("renders the facts INLINE, and they are this screen's alone", () => { - // Operator ruling 2026-09-22 (the server wave's ruling carried over): a - // section that hides its own facts behind a second control is two - // navigations for one answer. And since screenshot 60 the list is THIS - // screen's ALONE — every other screen lost it — so the full list - // including `bundled` and `tmux` lives here again (superseding the - // screenshot-52 scoping). - mount(); - expect(screen.queryByText("Show Details")).toBeNull(); - expect(screen.getByText("/usr/bin/tmux")).toBeTruthy(); - expect(screen.getByText("/home/u/.config/subshell/config.json")).toBeTruthy(); - }); - - it("offers no refresh — the probe's own interval re-reads the machine", () => { - // Operator ruling 2026-09-22: no Refresh button; the poll is the refresh. - // The interval wiring itself is pinned in `use-node-state.test.tsx`. - mount({ probe: makeProbe({ step: "stopped" }) }); - expect(maybeButton(/^refresh$/i)).toBeNull(); - }); -}); diff --git a/apps/client/desktop/ui/src/__tests__/update-screen.test.tsx b/apps/client/desktop/ui/src/__tests__/update-screen.test.tsx index dadfc96db..8bccd33cb 100644 --- a/apps/client/desktop/ui/src/__tests__/update-screen.test.tsx +++ b/apps/client/desktop/ui/src/__tests__/update-screen.test.tsx @@ -105,56 +105,6 @@ describe("the rail's Update section is the door (§ 7.4)", () => { // is the one screen that asks nothing. await waitFor(() => expect(screen.getByRole("heading", { level: 1 }).textContent).toBe("Subshell Client")); }); - - /** - * The frame's contract is "primary right and ghost left", and every screen - * that asks something ends on a filled button at the bottom right. This - * screen's act is the press in the CONTENT, so the bar carries only the way - * out — which sat in the ghost-left seat, leaving the filled one empty and - * making the one footer button the faintest thing in the frame (operator's - * call, 2026-09-18, on the sibling app's copy of the same screen). - * - * The WORD stays Back, and the case above is why: leaving returns to the - * status screen rather than closing anything. The server app's says Close - * because `host.close()` really does end that window. - */ - it("ends on a filled leave at the bottom right, with Check Again beside it", async () => { - // Rail-less (the machine is not settled): the bar still carries the way - // out, and its shape is pinned here. - // The same tray-raised, rail-less boot as the Back test above: the bar - // still carries the way out, and its shape is pinned here. - await boot({ - // No plane address and no node config: `configured` reads both rungs, - // and the machine is genuinely unsettled. - settings: makeSettings({ planes: [] }), - probe: makeProbe({ status: null }), - handlers: { - node_pending_screen: () => "update", - node_check_app_update: () => APP_CURRENT, - }, - }); - await waitFor(() => expect(screen.getByText("Update Subshell Client")).toBeTruthy()); - - const leave = button("Back"); - const check = button("Check Again"); - // Same bar cell, and Check Again comes FIRST — the seat Restart takes - // beside Save on the sibling app's Server Addresses. - expect(leave.parentElement).toBe(check.parentElement); - expect(check.compareDocumentPosition(leave) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); - // Nothing is left in the ghost-left cell. - const bar = leave.parentElement?.parentElement; - expect( - within(bar as HTMLElement) - .getAllByRole("button") - .map((b) => b.textContent), - ).toEqual(["Check Again", "Back"]); - // The leave is the PRIMARY — the filled gradient the `default` variant - // paints — and Check Again is the `outline` one beside it. Keyed on the - // variant's own token rather than on a word like "outline", which every - // button carries through `focus-visible:outline-none`. - expect(leave.className).toContain("--button-primary-from"); - expect(check.className).not.toContain("--button-primary-from"); - }); }); describe("the second phase finishes an act this build did not start (§ 4.2)", () => { diff --git a/apps/client/mobile/src/lib/__tests__/breakpoints.test.ts b/apps/client/mobile/src/lib/__tests__/breakpoints.test.ts index 2fdb49511..293b9030a 100644 --- a/apps/client/mobile/src/lib/__tests__/breakpoints.test.ts +++ b/apps/client/mobile/src/lib/__tests__/breakpoints.test.ts @@ -2,10 +2,6 @@ import { describe, expect, it } from "bun:test"; import { isWide, TARGET_WIDTHS, WIDE_MIN_WIDTH } from "@/lib/breakpoints"; describe("isWide", () => { - it("uses the web app's 1024px rule", () => { - expect(WIDE_MIN_WIDTH).toBe(1024); - }); - it("flips at the threshold, inclusive", () => { expect(isWide(WIDE_MIN_WIDTH - 1)).toBe(false); expect(isWide(WIDE_MIN_WIDTH)).toBe(true); diff --git a/apps/client/mobile/src/lib/__tests__/key-bar.test.ts b/apps/client/mobile/src/lib/__tests__/key-bar.test.ts index bc6863188..4ecc793a6 100644 --- a/apps/client/mobile/src/lib/__tests__/key-bar.test.ts +++ b/apps/client/mobile/src/lib/__tests__/key-bar.test.ts @@ -21,11 +21,6 @@ describe("KEY_BAR_BUTTONS", () => { it("matches the web key bar byte-for-byte", () => { expect(KEY_BAR_BUTTONS.map((b) => [b.label, b.bytes])).toEqual(EXPECTED_MAIN); }); - - it("has unique labels", () => { - const labels = KEY_BAR_BUTTONS.map((b) => b.label); - expect(new Set(labels).size).toBe(labels.length); - }); }); describe("KEY_BAR_EXTENDED", () => { diff --git a/apps/client/mobile/src/lib/__tests__/poll-policy.test.ts b/apps/client/mobile/src/lib/__tests__/poll-policy.test.ts index 26dbc1db3..8edba169f 100644 --- a/apps/client/mobile/src/lib/__tests__/poll-policy.test.ts +++ b/apps/client/mobile/src/lib/__tests__/poll-policy.test.ts @@ -8,11 +8,6 @@ describe("pollIntervalMs", () => { expect(pollIntervalMs({ foreground: false, hasActivity: true })).toBeNull(); expect(pollIntervalMs({ foreground: false, hasActivity: false })).toBeNull(); }); - - it("keeps the spec's exact numbers", () => { - expect(POLL_ACTIVE_MS).toBe(3000); - expect(POLL_IDLE_MS).toBe(15000); - }); }); describe("hasActivity", () => { diff --git a/apps/client/mobile/src/lib/__tests__/subshell-socket.test.ts b/apps/client/mobile/src/lib/__tests__/subshell-socket.test.ts index 72aa7e661..81849eca6 100644 --- a/apps/client/mobile/src/lib/__tests__/subshell-socket.test.ts +++ b/apps/client/mobile/src/lib/__tests__/subshell-socket.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "bun:test"; import { parseClientFrame } from "@internal/subshell-protocol"; -import { RECONNECT_DELAY_MS, shouldReconnectAfterClose } from "@/lib/subshell-socket"; +import { shouldReconnectAfterClose } from "@/lib/subshell-socket"; describe("close-code policy (mirrors use-subshell-ws.ts:63-64,128)", () => { it("retries everything below 4000 and nothing server-rejected", () => { @@ -12,10 +12,6 @@ describe("close-code policy (mirrors use-subshell-ws.ts:63-64,128)", () => { expect(shouldReconnectAfterClose(4004)).toBe(false); // not found / not running expect(shouldReconnectAfterClose(4999)).toBe(false); }); - - it("keeps the documented fixed delay", () => { - expect(RECONNECT_DELAY_MS).toBe(1500); - }); }); describe("visibility frames (the shared-pane rule this app has to obey)", () => { diff --git a/apps/node/agent/src/__tests__/backoff.test.ts b/apps/node/agent/src/__tests__/backoff.test.ts index 566291812..f4f5563c1 100644 --- a/apps/node/agent/src/__tests__/backoff.test.ts +++ b/apps/node/agent/src/__tests__/backoff.test.ts @@ -37,11 +37,3 @@ test("bounds: for every attempt and every draw, 0 <= delay <= min(cap, base * 2* test("negative attempts floor to 0 (never below base delay)", () => { expect(backoffDelay(-3, () => 1)).toBe(BACKOFF_BASE_MS); // 2**max(0,-3) = 1 }); - -test("default rand (Math.random) stays within bounds across many draws", () => { - for (let i = 0; i < 200; i++) { - const d = backoffDelay(i % 12); - expect(d).toBeGreaterThanOrEqual(0); - expect(d).toBeLessThanOrEqual(BACKOFF_CAP_MS); - } -}); diff --git a/apps/node/agent/src/__tests__/commands-ssh.test.ts b/apps/node/agent/src/__tests__/commands-ssh.test.ts index 05479b3f0..90c486505 100644 --- a/apps/node/agent/src/__tests__/commands-ssh.test.ts +++ b/apps/node/agent/src/__tests__/commands-ssh.test.ts @@ -3,12 +3,7 @@ import { existsSync, mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync import { tmpdir } from "node:os"; import { join } from "node:path"; import { buildAgentSocketPath } from "@internal/pane-runtime"; -import { - type JsonValue, - NODE_PROTOCOL_VERSION, - NODE_RESULT_MAINTENANCE, - type NodeCommandBody, -} from "@internal/subshell-protocol"; +import { type JsonValue, NODE_RESULT_MAINTENANCE, type NodeCommandBody } from "@internal/subshell-protocol"; import type { CommandContext } from "../commands/context.js"; import { dispatchCommand } from "../commands/index.js"; import { writeMaintenance } from "../maintenance.js"; @@ -237,8 +232,3 @@ describe("ssh_key_bridge: a closed gate or maintenance destroys live signing, no } }); }); - -/** Follows each protocol bump: the exact-match gate refuses stale binaries outright. */ -it("NODE_PROTOCOL_VERSION is 24 (current bump: saved HTTP(S) services)", () => { - expect(NODE_PROTOCOL_VERSION).toBe(24); -}); diff --git a/apps/server/api/AGENTS.md b/apps/server/api/AGENTS.md index 6e9c1c7df..95b998ba5 100644 --- a/apps/server/api/AGENTS.md +++ b/apps/server/api/AGENTS.md @@ -883,6 +883,12 @@ Route tests live in `__tests__/` next to the route and share - `signIn(email, password)` / `authedRequest(...)`: real session cookies - `deleteUserByEmailOrId(...)`: per-test cleanup +The composed `api-surface-auth.test.ts` suite owns ordinary anonymous and node-key +rejection for every guarded route. Don't duplicate bare 401 assertions in each +resource suite. Keep resource-specific permissions, scoped keys, ownership, +multipart or conditional authentication, and checks that refused writes leave +state unchanged; the shared inventory doesn't replace those. + Migrations must be applied by the code under test or the helper, never assumed from a developer's local `data/subshell.db`. diff --git a/apps/server/api/src/api/__tests__/admin-status-route.test.ts b/apps/server/api/src/api/__tests__/admin-status-route.test.ts index 5182adc32..e3cd13671 100644 --- a/apps/server/api/src/api/__tests__/admin-status-route.test.ts +++ b/apps/server/api/src/api/__tests__/admin-status-route.test.ts @@ -318,9 +318,4 @@ describe("GET /api/admin/status", () => { const res = await app.fetch(bearerRequest("/api/admin/status", adminSubshellKey)); expect(res.status).toBe(403); }); - - it("refuses an anonymous caller with 401, not 403", async () => { - const res = await app.fetch(new Request("http://localhost:3080/api/admin/status")); - expect(res.status).toBe(401); - }); }); diff --git a/apps/server/api/src/api/__tests__/files-route.test.ts b/apps/server/api/src/api/__tests__/files-route.test.ts index bd5b8d47b..9007f1fcb 100644 --- a/apps/server/api/src/api/__tests__/files-route.test.ts +++ b/apps/server/api/src/api/__tests__/files-route.test.ts @@ -94,10 +94,6 @@ describe("files route (folder explorer)", () => { expect(Array.isArray(body.entries)).toBe(true); }); - it("anonymous -> 401", async () => { - expect((await explore({})).status).toBe(401); - }); - it("subshell key bearer -> 403 (no filesystem walking from a harness)", async () => { const key = await mintSubshellKey(); expect((await explore({ bearer: key, path: "/tmp" })).status).toBe(403); @@ -596,10 +592,6 @@ describe("files route (folder explorer)", () => { expect(body.home).toBe(homedir()); }); - it("unauthenticated -> 401", async () => { - expect((await recent()).status).toBe(401); - }); - it("machine bearer -> 403 (browser affordance, like /explore)", async () => { const key = await mintSubshellKey(); expect((await recent({ bearer: key })).status).toBe(403); diff --git a/apps/server/api/src/api/__tests__/harness-schema-route.test.ts b/apps/server/api/src/api/__tests__/harness-schema-route.test.ts index 3e3165d5c..44e1c57c3 100644 --- a/apps/server/api/src/api/__tests__/harness-schema-route.test.ts +++ b/apps/server/api/src/api/__tests__/harness-schema-route.test.ts @@ -31,11 +31,6 @@ describe("harness schema route", () => { await deleteUserByEmailOrId(email); }); - it("anonymous -> 401", async () => { - const res = await presetRoutes.fetch(new Request("http://localhost/api/presets/harnesses/claude-code/schema")); - expect(res.status).toBe(401); - }); - it("known harness -> full schema payload", async () => { const res = await presetRoutes.fetch(authedRequest("/api/presets/harnesses/claude-code/schema", token)); expect(res.status).toBe(200); diff --git a/apps/server/api/src/api/__tests__/identities-route.test.ts b/apps/server/api/src/api/__tests__/identities-route.test.ts index 8fce0a61b..a440b3b4f 100644 --- a/apps/server/api/src/api/__tests__/identities-route.test.ts +++ b/apps/server/api/src/api/__tests__/identities-route.test.ts @@ -92,9 +92,4 @@ describe("identities route", () => { const res = await identityRoutes.fetch(authedRequest("/api/identities/user:ghost", token)); expect(res.status).toBe(404); }); - - it("anonymous -> 401", async () => { - const res = await identityRoutes.fetch(new Request("http://localhost:3080/api/identities/user:anyone")); - expect(res.status).toBe(401); - }); }); diff --git a/apps/server/api/src/api/__tests__/meta-route.test.ts b/apps/server/api/src/api/__tests__/meta-route.test.ts index 3efc81c11..13ab66cdd 100644 --- a/apps/server/api/src/api/__tests__/meta-route.test.ts +++ b/apps/server/api/src/api/__tests__/meta-route.test.ts @@ -55,9 +55,4 @@ describe("GET /api/meta/status", () => { const body = (await res.json()) as { serverTime: string }; expect(Number.isNaN(Date.parse(body.serverTime))).toBe(false); }); - - it("stays behind authGuard — anonymous callers get 401", async () => { - const res = await app.fetch(new Request("http://localhost:3080/api/meta/status")); - expect(res.status).toBe(401); - }); }); diff --git a/apps/server/api/src/api/__tests__/notifications-route.test.ts b/apps/server/api/src/api/__tests__/notifications-route.test.ts index a393bd680..b9fe6b829 100644 --- a/apps/server/api/src/api/__tests__/notifications-route.test.ts +++ b/apps/server/api/src/api/__tests__/notifications-route.test.ts @@ -108,16 +108,6 @@ describe("notifications route", () => { expect(res.status).toBe(400); }); - it("unauthenticated → 401", async () => { - // A request builder that never attaches the cookie — no mutating shared - // state between tests. /subscribe must be POSTed: a GET there is a router - // 404 (method mismatch) and never reaches the auth guard. - const anon = (path: string, init?: RequestInit) => - app.fetch(new Request(`http://localhost:3080/api/notifications${path}`, init)); - expect((await anon("/config")).status).toBe(401); - expect((await anon("/subscribe", { method: "POST" })).status).toBe(401); - }); - it("system key bearer → 403 on /config (machine credentials are not browsers)", async () => { const key = await mintSystemKey(); const res = await app.fetch( diff --git a/apps/server/api/src/api/__tests__/plugins-route.test.ts b/apps/server/api/src/api/__tests__/plugins-route.test.ts index 36e232482..6d1c267f5 100644 --- a/apps/server/api/src/api/__tests__/plugins-route.test.ts +++ b/apps/server/api/src/api/__tests__/plugins-route.test.ts @@ -253,18 +253,10 @@ describe("/api/plugins", () => { expect((await get("/api/plugins/..%2f..%2fetc/icon", aliceCookie)).status).toBe(404); }); - it("anonymous icon read -> 401", async () => { - expect((await app.fetch(new Request("http://localhost/api/plugins/claude-code/icon"))).status).toBe(401); - }); - it("a system bearer key may LIST", async () => { expect((await bearerSend("GET", "/api/plugins", systemKey)).status).toBe(200); }); - it("anonymous LIST -> 401", async () => { - expect((await app.fetch(new Request("http://localhost:3080/api/plugins"))).status).toBe(401); - }); - // ── writes: cookie-admin only ───────────────────────────────────────────── it("install/disable/uninstall/impact all 403 for a non-admin cookie — and nothing moved", async () => { diff --git a/apps/server/api/src/api/__tests__/prompt-stacks.route.test.ts b/apps/server/api/src/api/__tests__/prompt-stacks.route.test.ts index f8c5893ac..8781a57d7 100644 --- a/apps/server/api/src/api/__tests__/prompt-stacks.route.test.ts +++ b/apps/server/api/src/api/__tests__/prompt-stacks.route.test.ts @@ -471,8 +471,4 @@ describe("/api/prompts/stacks (spec 2026-09-29)", () => { 403, ); }); - - it("anonymous is 401", async () => { - expect((await call("/stacks")).status).toBe(401); - }); }); diff --git a/apps/server/api/src/api/__tests__/prompts.route.test.ts b/apps/server/api/src/api/__tests__/prompts.route.test.ts index 525ab4442..fdc97d778 100644 --- a/apps/server/api/src/api/__tests__/prompts.route.test.ts +++ b/apps/server/api/src/api/__tests__/prompts.route.test.ts @@ -300,8 +300,4 @@ describe("/api/prompts (spec 2026-09-28)", () => { expect((await call("", { bearer: readOnly })).status).toBe(200); expect((await call("/", { bearer: readOnly, body: { description: "d", body: "b" } })).status).toBe(403); }); - - it("anonymous is 401", async () => { - expect((await call("")).status).toBe(401); - }); }); diff --git a/apps/server/api/src/api/__tests__/settings-instance-route.test.ts b/apps/server/api/src/api/__tests__/settings-instance-route.test.ts index 2b13f1225..27f7c8de5 100644 --- a/apps/server/api/src/api/__tests__/settings-instance-route.test.ts +++ b/apps/server/api/src/api/__tests__/settings-instance-route.test.ts @@ -130,11 +130,4 @@ describe("GET /api/settings/instance (anonymous)", () => { .execute(); } }); - - it("does not make the guarded settings routes anonymous", async () => { - // The point of keeping this in its own module: mounting it must not lift - // authGuard off its neighbour. - expect((await app.fetch(anon("/api/settings"))).status).toBe(401); - expect((await app.fetch(anon("/api/settings/public"))).status).toBe(401); - }); }); diff --git a/apps/server/api/src/api/__tests__/settings-route.test.ts b/apps/server/api/src/api/__tests__/settings-route.test.ts index a74cbcf42..3a3b4ef6e 100644 --- a/apps/server/api/src/api/__tests__/settings-route.test.ts +++ b/apps/server/api/src/api/__tests__/settings-route.test.ts @@ -119,10 +119,6 @@ describe("settings routes (admin cookie only)", () => { await deleteUserByEmailOrId(nonAdminEmail); }); - it("anonymous -> 401", async () => { - expect((await app.fetch(new Request("http://localhost:3080/api/settings"))).status).toBe(401); - }); - it("admin cookie reads and writes settings (200)", async () => { const get = await app.fetch(authedRequest("/api/settings", adminCookie)); expect(get.status).toBe(200); @@ -1042,8 +1038,4 @@ describe("/api/settings/terminal-history (per-user, cookie only)", () => { ); expect(res.status).toBe(403); }); - - it("anonymous -> 401", async () => { - expect((await app.fetch(new Request("http://localhost:3080/api/settings/terminal-history"))).status).toBe(401); - }); }); diff --git a/apps/server/api/src/api/__tests__/subshells-notify-route.test.ts b/apps/server/api/src/api/__tests__/subshells-notify-route.test.ts index 2944d5148..9c10bbda2 100644 --- a/apps/server/api/src/api/__tests__/subshells-notify-route.test.ts +++ b/apps/server/api/src/api/__tests__/subshells-notify-route.test.ts @@ -280,9 +280,4 @@ describe("subshells attention + notify routes", () => { expect(other.status).toBe(403); expect((await row(idB)).notify).toBe(1); }); - - it("unauthenticated → 401 on both endpoints", async () => { - expect((await req(`/${idA}/attention`, { method: "POST", body: { kind: "turn_complete" } })).status).toBe(401); - expect((await req(`/${idA}/notify`, { method: "PATCH", body: { notify: true } })).status).toBe(401); - }); }); diff --git a/apps/server/api/src/api/__tests__/ws-token-route.test.ts b/apps/server/api/src/api/__tests__/ws-token-route.test.ts index 0fd8be4fe..a3e4448f7 100644 --- a/apps/server/api/src/api/__tests__/ws-token-route.test.ts +++ b/apps/server/api/src/api/__tests__/ws-token-route.test.ts @@ -140,11 +140,6 @@ describe("ws-token route (unscoped cookie mints, scoped bearer mints)", () => { } }); - it("anonymous -> 401", async () => { - const res = await app.fetch(new Request("http://localhost:3080/api/auth/ws-token", { method: "POST" })); - expect(res.status).toBe(401); - }); - it("cookie session -> 200 with an UNBOUND attach token", async () => { const res = await app.fetch(authedRequest("/api/auth/ws-token", cookie, { method: "POST" })); const token = await mintedToken(res); diff --git a/apps/server/api/src/api/channels/__tests__/channels-route.test.ts b/apps/server/api/src/api/channels/__tests__/channels-route.test.ts index 5a109b055..beef24500 100644 --- a/apps/server/api/src/api/channels/__tests__/channels-route.test.ts +++ b/apps/server/api/src/api/channels/__tests__/channels-route.test.ts @@ -98,11 +98,6 @@ describe("channels route", () => { if (r.status !== 200) throw new Error(`identity register failed: ${r.status} ${r.text}`); } - it("anonymous -> 401 on list", async () => { - const res = await channelRoutes.fetch(new Request("http://localhost:3080/api/channels")); - expect(res.status).toBe(401); - }); - it("creating a channel without an identity -> 409 identity_required", async () => { const r = await call(channelRoutes, "/api/channels", aliceToken, postJson({ name: "build" })); expect(r.status).toBe(409); diff --git a/apps/server/api/src/api/nodes/__tests__/nodes-crud-route.test.ts b/apps/server/api/src/api/nodes/__tests__/nodes-crud-route.test.ts index 318e2d0c5..d6d744cda 100644 --- a/apps/server/api/src/api/nodes/__tests__/nodes-crud-route.test.ts +++ b/apps/server/api/src/api/nodes/__tests__/nodes-crud-route.test.ts @@ -539,11 +539,6 @@ describe("/api/nodes registry CRUD", () => { expect((await req("POST", "/local/rotate-key", { bearer: subshellKey })).status).toBe(403); }); - it("unauthenticated → 401", async () => { - expect((await req("GET", "")).status).toBe(401); - expect((await req("GET", "/local")).status).toBe(401); - }); - // ── rename ──────────────────────────────────────────────────────────────── it("rename: owner ok; per-owner collision → 409 NODE_NAME_TAKEN; view-grantee and admin 403", async () => { diff --git a/apps/server/api/src/api/nodes/__tests__/setup-keys-route.test.ts b/apps/server/api/src/api/nodes/__tests__/setup-keys-route.test.ts index 1fc3636b4..314e05efe 100644 --- a/apps/server/api/src/api/nodes/__tests__/setup-keys-route.test.ts +++ b/apps/server/api/src/api/nodes/__tests__/setup-keys-route.test.ts @@ -191,10 +191,6 @@ describe("/api/nodes/setup-keys", () => { expect((await req("DELETE", "/setup-keys/whatever", { bearer: subshellKey })).status).toBe(403); }); - it("unauthenticated → 401", async () => { - expect((await req("GET", "/setup-keys")).status).toBe(401); - }); - describe("the allow_node_enrollment setting", () => { const settings = new SettingsRepository(db); async function setAllowed(value: boolean | null): Promise { diff --git a/apps/server/api/src/api/subshells/__tests__/subshells-extend-token.test.ts b/apps/server/api/src/api/subshells/__tests__/subshells-extend-token.test.ts index 156ad62e3..7092a36b0 100644 --- a/apps/server/api/src/api/subshells/__tests__/subshells-extend-token.test.ts +++ b/apps/server/api/src/api/subshells/__tests__/subshells-extend-token.test.ts @@ -105,13 +105,6 @@ describe("POST /api/subshells/:id/extend-token", () => { expect(((await res.json()) as { extended: boolean }).extended).toBe(false); }); - it("anonymous -> 401", async () => { - const res = await subshellRoutes.fetch( - new Request("http://localhost:3080/api/subshells/any/extend-token", { method: "POST" }), - ); - expect(res.status).toBe(401); - }); - // A subshell token that is linked (passes the guard's apiKeyId check) but // carries a reduced grant set — proves the routes enforce requirePerm, not // just that subshell tokens reach them. issueSubshellToken always mints full diff --git a/apps/server/api/src/api/subshells/__tests__/subshells-log-route.test.ts b/apps/server/api/src/api/subshells/__tests__/subshells-log-route.test.ts index b0f131776..bd0382ce5 100644 --- a/apps/server/api/src/api/subshells/__tests__/subshells-log-route.test.ts +++ b/apps/server/api/src/api/subshells/__tests__/subshells-log-route.test.ts @@ -115,11 +115,6 @@ describe("GET /api/subshells/:id/log", () => { expect(res.status).toBe(404); }); - it("anonymous -> 401", async () => { - const res = await subshellRoutes.fetch(new Request(`http://localhost:3080/api/subshells/any/log`)); - expect(res.status).toBe(401); - }); - it("SUBSHELL_SERVER_DATA_DIR is absolute — the log path must survive any cwd", () => { // The pane log's directory travels to tmux and harness processes; a // relative path silently reads from the wrong cwd. diff --git a/apps/server/api/src/api/subshells/__tests__/subshells-rename-route.test.ts b/apps/server/api/src/api/subshells/__tests__/subshells-rename-route.test.ts index 28a8eecb7..7bd7b660a 100644 --- a/apps/server/api/src/api/subshells/__tests__/subshells-rename-route.test.ts +++ b/apps/server/api/src/api/subshells/__tests__/subshells-rename-route.test.ts @@ -141,9 +141,4 @@ describe("PATCH /api/subshells/:id/name", () => { const bRow = await new SubshellsRepository(db).findById(b.id); expect(bRow?.name).toBe("rename-me"); }); - - it("unauthenticated -> 401", async () => { - const s = await subshellWithToken(); - expect((await patch(s.id, { name: "x" }, {})).status).toBe(401); - }); }); diff --git a/apps/server/api/src/api/subshells/__tests__/subshells-workspace-file.test.ts b/apps/server/api/src/api/subshells/__tests__/subshells-workspace-file.test.ts index eebbb2dac..22be63594 100644 --- a/apps/server/api/src/api/subshells/__tests__/subshells-workspace-file.test.ts +++ b/apps/server/api/src/api/subshells/__tests__/subshells-workspace-file.test.ts @@ -143,17 +143,6 @@ describe("subshell ↔ workspace filing", () => { expect((await get("/api/subshells?workspace=none")).status).toBe(200); }); - // The filter reads membership BEFORE the visibility resolver runs, so the - // door's own posture needs pinning on both filter shapes (spec 2026-10-17 - // Testing; .claude/rules/testing.md: new filters retain anonymous and - // scoped-bearer checks). The guard, not the filter, is what answers these. - it("is 401 for an anonymous caller on every filter shape", async () => { - for (const path of ["/api/subshells?workspace=none", `/api/subshells?workspace=${ownWorkspaceId}`]) { - const res = await app.fetch(new Request(`http://localhost:3080${path}`)); - expect(res.status).toBe(401); - } - }); - it("answers a subshell's own token exactly as its owner's cookie (the list-visibility ruling)", async () => { // One filed row and one loose row, so both filters have something to // separate; the pairs compared below are cookie vs bearer on the SAME diff --git a/apps/server/api/src/api/workspaces/__tests__/workspaces-route.test.ts b/apps/server/api/src/api/workspaces/__tests__/workspaces-route.test.ts index 83fa44b31..d0d91330a 100644 --- a/apps/server/api/src/api/workspaces/__tests__/workspaces-route.test.ts +++ b/apps/server/api/src/api/workspaces/__tests__/workspaces-route.test.ts @@ -144,11 +144,6 @@ describe("workspaces route", () => { expect(((await response.json()) as { id: string }[]).map((w) => w.id)).toEqual([id]); }); - it("anonymous -> 401", async () => { - const res = await workspaceRoutes.fetch(new Request("http://localhost:3080/api/workspaces")); - expect(res.status).toBe(401); - }); - it("creates a workspace and lists it back, with its panes inline", async () => { const token = await signIn(ownerEmail, password); const name = `ws-${crypto.randomUUID().slice(0, 8)}`; diff --git a/apps/server/api/src/db/__tests__/dialect.test.ts b/apps/server/api/src/db/__tests__/dialect.test.ts deleted file mode 100644 index 519961866..000000000 --- a/apps/server/api/src/db/__tests__/dialect.test.ts +++ /dev/null @@ -1,133 +0,0 @@ -import { afterEach, describe, expect, it } from "bun:test"; -import { Kysely, sql } from "kysely"; -import { BunSqliteDialect } from "kysely-bun-sqlite-dialect"; -import { openSqliteDatabase } from "@/db/open-database.js"; - -// We open a fresh :memory: database per test; the dialect is constructed per test. -function createKysely() { - const db = openSqliteDatabase(":memory:"); - const kysely = new Kysely({ - dialect: new BunSqliteDialect({ database: db }), - }); - return { db, kysely }; -} - -interface Users { - id?: number; - name?: string; - email?: string; - createdAt?: string; -} -interface TestDb { - users: Users; - posts: { - id: string; - user_id: string; - }; -} - -afterEach(() => { - // nothing shared; each test makes its own DB -}); - -describe("BunSqliteDialect", () => { - it("creates a table and inserts/selects rows", async () => { - const { db, kysely } = createKysely(); - await db.exec("drop table if exists users"); - await kysely.schema - .createTable("users") - .addColumn("id", "integer", (col) => col.primaryKey().autoIncrement()) - .addColumn("name", "text") - .addColumn("email", "text") - .execute(); - - const ins = await kysely - .insertInto("users") - .values({ name: "bob", email: "bob@example.com" }) - .executeTakeFirstOrThrow(); - expect(ins.insertId).toEqual(1n); - - const rows = await kysely.selectFrom("users").selectAll().execute(); - expect(rows).toHaveLength(1); - expect(rows[0]).toMatchObject({ name: "bob", email: "bob@example.com" }); - }); - - it("supports transactions (commit)", async () => { - const { db, kysely } = createKysely(); - await db.exec("drop table if exists users"); - await kysely.schema - .createTable("users") - .addColumn("id", "integer", (col) => col.primaryKey().autoIncrement()) - .addColumn("name", "text") - .execute(); - - await kysely.transaction().execute(async (trx) => { - await trx.insertInto("users").values({ name: "alice" }).execute(); - await trx.insertInto("users").values({ name: "carol" }).execute(); - }); - - const count = (await kysely.selectFrom("users").select(sql`count(*)`.as("c")).executeTakeFirstOrThrow()) as { - c: number; - }; - expect(count.c).toEqual(2); - }); - - it("rolls back a transaction on error", async () => { - const { db, kysely } = createKysely(); - await db.exec("drop table if exists users"); - await kysely.schema - .createTable("users") - .addColumn("id", "integer", (col) => col.primaryKey().autoIncrement()) - .addColumn("name", "text") - .execute(); - - await expect( - kysely.transaction().execute(async (trx) => { - await trx.insertInto("users").values({ name: "dave" }).execute(); - throw new Error("boom"); - }), - ).rejects.toThrow("boom"); - - const count = (await kysely.selectFrom("users").select(sql`count(*)`.as("c")).executeTakeFirstOrThrow()) as { - c: number; - }; - expect(count.c).toEqual(0); - }); - - it("honors foreign keys", async () => { - const { db, kysely } = createKysely(); - await db.exec("drop table if exists posts"); - await db.exec("drop table if exists users"); - await kysely.schema - .createTable("users") - .addColumn("id", "text", (col) => col.primaryKey()) - .execute(); - await kysely.schema - .createTable("posts") - .addColumn("id", "text", (col) => col.primaryKey()) - .addColumn("user_id", "text", (col) => col.references("users.id")) - .execute(); - - await expect(kysely.insertInto("posts").values({ id: "p1", user_id: "missing" }).execute()).rejects.toThrow( - /FOREIGN KEY/i, - ); - }); - - it("supports raw sql and returning (insert returning)", async () => { - const { db, kysely } = createKysely(); - await db.exec("drop table if exists users"); - await kysely.schema - .createTable("users") - .addColumn("id", "integer", (col) => col.primaryKey().autoIncrement()) - .addColumn("name", "text") - .addColumn("email", "text") - .execute(); - - const returned = await kysely - .insertInto("users") - .values({ name: "eve", email: "eve@example.com" }) - .returning(["name"]) - .executeTakeFirstOrThrow(); - expect(returned.name).toEqual("eve"); - }); -}); diff --git a/apps/server/api/src/db/__tests__/open-database.test.ts b/apps/server/api/src/db/__tests__/open-database.test.ts new file mode 100644 index 000000000..8b47e4449 --- /dev/null +++ b/apps/server/api/src/db/__tests__/open-database.test.ts @@ -0,0 +1,17 @@ +import { expect, test } from "bun:test"; +import { openSqliteDatabase } from "@/db/open-database.js"; + +// This checks our connection policy; repository tests exercise Kysely's CRUD +// and transaction paths against the application's real tables. +test("openSqliteDatabase enables foreign-key enforcement", () => { + const db = openSqliteDatabase(":memory:"); + try { + db.exec("CREATE TABLE parents (id TEXT PRIMARY KEY)"); + db.exec("CREATE TABLE children (parent_id TEXT REFERENCES parents(id))"); + expect(() => db.exec("INSERT INTO children VALUES ('missing')")).toThrow(/FOREIGN KEY/i); + db.exec("INSERT INTO parents VALUES ('present')"); + expect(() => db.exec("INSERT INTO children VALUES ('present')")).not.toThrow(); + } finally { + db.close(); + } +}); diff --git a/apps/server/api/src/lib/__tests__/context.test.ts b/apps/server/api/src/lib/__tests__/context.test.ts index c11d29f17..0182fd9ca 100644 --- a/apps/server/api/src/lib/__tests__/context.test.ts +++ b/apps/server/api/src/lib/__tests__/context.test.ts @@ -1,16 +1,13 @@ import { beforeAll, describe, expect, it } from "bun:test"; import { ensureMigratedTestDb } from "@/__tests__/helpers/test-database.js"; import { db } from "@/db/index.js"; -import { PresetsRepository } from "@/db/repositories/presets.repository.js"; -import { SubshellsRepository } from "@/db/repositories/subshells.repository.js"; import { ApiContext, getRequestlessContext, resetRequestlessContext } from "@/lib/context.js"; -import { SubshellsService } from "@/services/subshells.service.js"; import { getLogger } from "@/utils/logger.js"; /** * The requestless context is the entry point for non-request callers (ws, - * mcp). It must be a stable singleton (services hold no per-call state worth - * rebuilding) and arrive fully wired — repos and services included. + * mcp). Its identity and reset behavior belong here; HTTP and MCP tests + * exercise the repository and service wiring through real operations. * The per-process temp-file test DB from test-preload applies automatically. */ // Parallel `bun test` gives this file its own fresh (unmigrated) database, @@ -22,25 +19,6 @@ describe("getRequestlessContext", () => { expect(getRequestlessContext()).toBe(getRequestlessContext()); }); - it("exposes the shared db and a log", () => { - const ctx = getRequestlessContext(); - expect(ctx.db).toBe(db); - expect(ctx.log).toBeDefined(); - }); - - it("builds the repositories", () => { - const ctx = getRequestlessContext(); - expect(ctx.repos.subshells).toBeInstanceOf(SubshellsRepository); - expect(ctx.repos.presets).toBeInstanceOf(PresetsRepository); - }); - - it("builds the services and links the sibling map", () => { - const ctx = getRequestlessContext(); - expect(ctx.services.subshells).toBeInstanceOf(SubshellsService); - // BaseService.withServices wiring: every service sees the full map. - expect(ctx.services.subshells.services).toBe(ctx.services); - }); - it("resetRequestlessContext drops the cached instance (@internal test hook)", () => { const first = getRequestlessContext(); resetRequestlessContext(); diff --git a/apps/server/api/src/services/__tests__/notify-idle.test.ts b/apps/server/api/src/services/__tests__/notify-idle.test.ts index aed143759..17f2bea5d 100644 --- a/apps/server/api/src/services/__tests__/notify-idle.test.ts +++ b/apps/server/api/src/services/__tests__/notify-idle.test.ts @@ -55,13 +55,6 @@ function makeWatcher(opts: { /** A base clock far from zero so "quiet" arithmetic can't hide off-by-signs. */ const T0 = 1_700_000_000_000; -describe("idle watcher constants", () => { - it("exports the 20 s quiet window and 3 s tick", () => { - expect(IDLE_QUIET_MS).toBe(20_000); - expect(IDLE_TICK_MS).toBe(3_000); - }); -}); - describe("createIdleWatcher.tick", () => { it("(a) first tick only seeds state — a long-idle subshell never rings at boot", async () => { const rows = [fakeRow("a")]; diff --git a/apps/server/api/src/services/__tests__/ssh-relay.service.test.ts b/apps/server/api/src/services/__tests__/ssh-relay.service.test.ts index ba13ef3f6..2d0424648 100644 --- a/apps/server/api/src/services/__tests__/ssh-relay.service.test.ts +++ b/apps/server/api/src/services/__tests__/ssh-relay.service.test.ts @@ -7,7 +7,6 @@ import type { SshRelayOpenCommand, } from "@internal/subshell-protocol"; import { - NODE_PROTOCOL_VERSION, SSH_RELAY_LIFETIME_MS, SSH_RELAY_MAX_PER_NODE, SSH_RELAY_TEARDOWN_GRACE_MS, @@ -703,14 +702,6 @@ describe("sendRelayFrameOverNodeSocket (the plane's throw-on-drop glue, acceptan }); }); -describe("grammar/version posture", () => { - it("relays a protocol-24 world: the constant is pinned here as the broker's precondition", () => { - // The handler refuses every relay frame from a pre-18 agent upstream; - // this tripwire documents the dependency at the broker. - expect(NODE_PROTOCOL_VERSION).toBe(24); - }); -}); - describe("identity repair excludes concurrent and stale opens", () => { it("holds the barrier through deferred closes and registration/cache replacement", async () => { const closing = Promise.withResolvers(); diff --git a/apps/server/api/src/services/__tests__/subshell-manager-mcp.test.ts b/apps/server/api/src/services/__tests__/subshell-manager-mcp.test.ts index bf41ef7b6..e26e53eb0 100644 --- a/apps/server/api/src/services/__tests__/subshell-manager-mcp.test.ts +++ b/apps/server/api/src/services/__tests__/subshell-manager-mcp.test.ts @@ -192,7 +192,10 @@ describe("createSubshell MCP integration", () => { }); it("types the prompt after the pane shows output, then presses Enter", async () => { - tmux.captureScript = ["", "", "claude> ready"]; + // The fake must echo input after settling. A permanently unchanged ready + // screen exercises the ten-second render timeout instead of this success + // path; the shared prompt-delivery suite covers that fallback directly. + tmux.captureScript = ["", "", "claude> ready", "claude> fix the flaky test"]; const created = await manager.createSubshell({ userId: "u1", harnessId: "claude-code", diff --git a/apps/server/api/src/services/__tests__/subshells-resolve-launch-node.test.ts b/apps/server/api/src/services/__tests__/subshells-resolve-launch-node.test.ts index 8b577f6a5..efeb11b18 100644 --- a/apps/server/api/src/services/__tests__/subshells-resolve-launch-node.test.ts +++ b/apps/server/api/src/services/__tests__/subshells-resolve-launch-node.test.ts @@ -340,12 +340,6 @@ describe("resolveLaunchNode — server switched off as a node", () => { }); describe("resolveLaunchNode — implicit local (step 2) and auto-pick (step 3)", () => { - it("nothing requested + the local switch ON → local (today's behavior preserved)", async () => { - expect(await resolveLaunchNode({ userId: ownerId, machineActor: false }, deps)).toEqual({ - nodeId: LOCAL_NODE_ID, - }); - }); - it("local switch OFF + no online agents → 400 ApiError NODE_REQUIRED ('choose a node')", async () => { await deps.shares.replaceForNode(LOCAL_NODE_ID, [], systemId); try { diff --git a/apps/server/api/src/services/nodes/__tests__/link-session.test.ts b/apps/server/api/src/services/nodes/__tests__/link-session.test.ts index 4ce3eeea3..8d52b06e5 100644 --- a/apps/server/api/src/services/nodes/__tests__/link-session.test.ts +++ b/apps/server/api/src/services/nodes/__tests__/link-session.test.ts @@ -9,7 +9,6 @@ import { } from "@internal/subshell-protocol/node-link-crypto"; import { beginLinkUpgrade, - HANDSHAKE_TIMEOUT_MS, handleLinkFrame, handshakeIncomplete, type LinkFrame, @@ -665,11 +664,6 @@ describe("legacy mode", () => { }); describe("handshake timeout budget", () => { - it("exports a positive constant (Task 8 arms the timer)", () => { - expect(typeof HANDSHAKE_TIMEOUT_MS).toBe("number"); - expect(HANDSHAKE_TIMEOUT_MS).toBeGreaterThan(0); - }); - it("handshakeIncomplete is false for a legacy socket and true for any unfinished handshake", async () => { const h = await makeHarness(); expect(handshakeIncomplete(legacyData())).toBe(false); diff --git a/apps/server/api/src/services/nodes/__tests__/node-registry.test.ts b/apps/server/api/src/services/nodes/__tests__/node-registry.test.ts index e793f2437..0bbd416a4 100644 --- a/apps/server/api/src/services/nodes/__tests__/node-registry.test.ts +++ b/apps/server/api/src/services/nodes/__tests__/node-registry.test.ts @@ -116,10 +116,6 @@ describe("node registry (spec 2026-08-31 §5.3)", () => { expect(getLive("n1")).toBe(first); }); - it("NODE_CLOSE_SUPERSEDED is 4409 (spec §5.3)", () => { - expect(NODE_CLOSE_SUPERSEDED).toBe(4409); - }); - // rotate-key / delete-node revoke the credential out from under a LIVE // socket (T8 carry): the registry side of that teardown. describe("disconnectNode", () => { diff --git a/apps/server/api/src/services/nodes/__tests__/preview-cache.test.ts b/apps/server/api/src/services/nodes/__tests__/preview-cache.test.ts index 102c20a82..fc4b812f3 100644 --- a/apps/server/api/src/services/nodes/__tests__/preview-cache.test.ts +++ b/apps/server/api/src/services/nodes/__tests__/preview-cache.test.ts @@ -42,10 +42,6 @@ describe("preview-cache", () => { expect(previewCacheGet("s2", 1000)).toEqual(["b"]); }); - it("TTL is 60 s (the sweep's cadence)", () => { - expect(PREVIEW_CACHE_TTL_MS).toBe(60_000); - }); - it("per-entry TTL: a short-TTL entry expires while a default entry survives", () => { resetPreviewCacheForTests(); previewCachePut("local", ["x"], LOCAL_PREVIEW_TTL_MS); diff --git a/apps/server/api/src/utils/__tests__/log-file.test.ts b/apps/server/api/src/utils/__tests__/log-file.test.ts index 71b726987..14283ca2f 100644 --- a/apps/server/api/src/utils/__tests__/log-file.test.ts +++ b/apps/server/api/src/utils/__tests__/log-file.test.ts @@ -46,9 +46,6 @@ describe("readServerLogTail", () => { it("answers empty for a missing file", async () => { expect(await readServerLogTail("/nonexistent/server.log", 10)).toEqual({ lines: [], bytes: 0 }); }); - it("the cap is 200 KB", () => { - expect(SERVER_LOG_CAP_BYTES).toBe(204_800); - }); }); describe("CappedFileTransport", () => { diff --git a/apps/server/api/src/ws/__tests__/ws-token.test.ts b/apps/server/api/src/ws/__tests__/ws-token.test.ts index c873bafbe..ca90fca00 100644 --- a/apps/server/api/src/ws/__tests__/ws-token.test.ts +++ b/apps/server/api/src/ws/__tests__/ws-token.test.ts @@ -4,7 +4,6 @@ import { consumeWsToken, dropUserTokensFor, issueWsToken, - MAX_PENDING_WS_TOKENS, setWsTokenCapForTests, WsTokenCapacityError, } from "@/ws/ws-token.js"; @@ -32,10 +31,6 @@ describe("ws-token mint cap", () => { setWsTokenCapForTests(null); }); - it("exports the production cap of 10 000 outstanding tokens", () => { - expect(MAX_PENDING_WS_TOKENS).toBe(10_000); - }); - it("refuses at the cap with the named error rather than growing the store", () => { const previous = setWsTokenCapForTests(3); try { diff --git a/apps/server/api/src/ws/capture-text.ts b/apps/server/api/src/ws/capture-text.ts index 237d75c52..f4315af9e 100644 --- a/apps/server/api/src/ws/capture-text.ts +++ b/apps/server/api/src/ws/capture-text.ts @@ -1,4 +1,4 @@ -import { stripSyncMarkers } from "@/ws/sync-stripper.js"; +import { stripSyncMarkers } from "./sync-stripper.js"; /** * The one boundary that turns a `capture-pane` payload into bytes safe to diff --git a/apps/server/desktop/ui/src/__tests__/config-form.test.ts b/apps/server/desktop/ui/src/__tests__/config-form.test.ts index fd250265a..280a0bdbf 100644 --- a/apps/server/desktop/ui/src/__tests__/config-form.test.ts +++ b/apps/server/desktop/ui/src/__tests__/config-form.test.ts @@ -327,28 +327,3 @@ describe("the assistant's wiring, pinned at the source", () => { expect(status).toContain("docsUrl"); }); }); - -describe("the assistant's stylesheet", () => { - /** - * `.warn-text` and `.hint` are both single-class selectors, so for an element - * carrying BOTH the later declaration wins. The problem lines this page - * builds are `class="hint warn-text"` and rendered amber deliberately — with - * `.warn-text` declared first they came out muted grey, indistinguishable - * from the ordinary field hint directly above them. Same cascade LAYER is - * part of the contract: moved into different layers, layer order would beat - * source order and the fix would silently invert. No test can see a computed - * colour here, so the ORDER is what gets pinned. - */ - test("`.warn-text` is declared after `.hint` in the same layer, or a combined class renders muted", () => { - const css = readFileSync(join(ROOT, "ui/src/styles.css"), "utf8"); - const hint = css.indexOf(".hint {"); - const warn = css.indexOf(".warn-text {"); - expect(hint).toBeGreaterThan(-1); - expect(warn).toBeGreaterThan(hint); - // Same cascade LAYER: a `@layer` opening between the two would move one - // of them, and layer precedence would silently beat declaration order — - // the exact failure this pin exists to catch, arriving by refactor - // rather than by reordering. - expect(css.slice(hint, warn)).not.toContain("@layer"); - }); -}); diff --git a/apps/server/desktop/ui/src/__tests__/host.test.tsx b/apps/server/desktop/ui/src/__tests__/host.test.tsx index 1ed546339..a08a34ae7 100644 --- a/apps/server/desktop/ui/src/__tests__/host.test.tsx +++ b/apps/server/desktop/ui/src/__tests__/host.test.tsx @@ -669,22 +669,4 @@ describe("the rail", () => { // Status holds the highlight while it stands. expect(screen.getByRole("button", { name: "Status" }).getAttribute("aria-current")).toBe("true"); }); - - it("still hands off, and opens the dashboard, when the window ARRIVES on a running machine", async () => { - // The select stopped bouncing; the ARRIVAL still hands off — a window - // reopened over a running server owes no result to a reader. This is the - // same auto-open the boot-gate suite's positive control pins; here it is - // the pair that keeps the new status branch from swallowing arrival - // (screen null) along with the select (screen "recovery"). - fake = installFakeIpc({ - probe: makeProbe({ next: "ready", onboarded: true }), - handlers: { - desktop_pending_screen: () => null, - desktop_open_main: () => undefined, - }, - }); - render(); - await waitFor(() => expect(routeOf()).toBe("handoff")); - await waitFor(() => expect(fake?.callsTo("desktop_open_main")).toHaveLength(1)); - }); }); diff --git a/apps/server/desktop/ui/src/__tests__/settings-screen.test.ts b/apps/server/desktop/ui/src/__tests__/settings-screen.test.ts index 0378bf0a2..f07fd9872 100644 --- a/apps/server/desktop/ui/src/__tests__/settings-screen.test.ts +++ b/apps/server/desktop/ui/src/__tests__/settings-screen.test.ts @@ -5,12 +5,8 @@ * and the value that caused it lived only on the dashboard that had just * stopped accepting a sign-in. * - * `ui/src/__tests__/` has no DOM harness — nothing here can mount the host — - * so every judgment the screen makes lives in `lib/settings-screen.ts` and is - * pinned here. Three of these tests are not about this screen alone but about - * two surfaces AGREEING: the https sentence against the dashboard's own words, - * the screen id against the closed Rust enum, and the label against the View - * menu's different word. + * These tests cover save payloads, refusal conditions, and the screen id's + * agreement with the Rust enum. Host interaction tests cover navigation. */ import { describe, expect, it } from "bun:test"; import { readFileSync } from "node:fs"; @@ -18,12 +14,8 @@ import { join } from "node:path"; import { seedAddressForm } from "../lib/config-form"; import type { Probe } from "../lib/ipc"; import { PANE_WARNING } from "../lib/pane-force"; -import { RAIL_SECTIONS } from "../lib/server-state"; import { - HTTPS_RESTART_NOTE, httpsBaseUrl, - SETTINGS_LABEL, - SETTINGS_RESTART_NOTE, settingsEdited, settingsForce, settingsKnown, @@ -34,13 +26,7 @@ import { } from "../lib/settings-screen"; import { REQUESTED_SCREENS, screenForRequest } from "../lib/wizard-state"; -const ADDRESSES_CARD = join(import.meta.dir, "../../../../web/src/components/networking/addresses-card.tsx"); const RESET_RS = join(import.meta.dir, "../../../src-tauri/src/reset.rs"); -const TRAY_RS = join(import.meta.dir, "../../../src-tauri/src/tray.rs"); -const MENU_RS = join(import.meta.dir, "../../../src-tauri/src/menu.rs"); - -/** JSX wraps a sentence across lines; only the words are the contract. */ -const words = (s: string): string => s.replace(/\s+/g, " ").trim(); /** A machine with a managed server running under a service that spares panes. */ function machine(over: Partial = {}): Probe { @@ -73,16 +59,6 @@ function machine(over: Partial = {}): Probe { } describe("the https warning", () => { - it("is the dashboard's own sentence, word for word", () => { - // § 14.3: the same sentence, because two surfaces disagreeing about a - // consequence is worse than either wording alone — and this is the screen - // someone reaches AFTER the consequence, so a second phrasing would read - // as a second problem. The dashboard states it before the save; this - // states it at the same field, in the app that pays for it. - const card = words(readFileSync(ADDRESSES_CARD, "utf8")); - expect(card).toContain(words(HTTPS_RESTART_NOTE)); - }); - it("fires on an https base URL and on nothing else", () => { expect(httpsBaseUrl("https://subshell.example.com")).toBe(true); // Typed, not yet tidied: a value on its way into a field carries whitespace @@ -245,39 +221,4 @@ describe("the screen id, across the three places that spell it", () => { expect(rust, `reset.rs must parse "${screen}"`).toContain(`Some("${screen}") =>`); } }); - - it("is a rail section, which is the door a signed-out machine still has", () => { - // The tray carried a dedicated door to this screen until the 2026-09-22 - // wave dropped it: the assistant's own rail already names the screen, so a - // tray item that opened a rail section was chrome answering a question the - // rail asks. Selecting the rail section is a pure in-window screen change - // that asks the server nothing — the door a machine signed OUT of its own - // dashboard still has. (The recovery screen's old link to it became a rail - // section in wave 2, so this one section is the whole route.) - const section = RAIL_SECTIONS.find((s) => s.id === "settings"); - expect(section?.label).toBe("Addresses"); - // And the door it replaced stays gone: the tray must not quietly re-add a - // second way in that this test's whole point is NOT to need. - expect(readFileSync(TRAY_RS, "utf8")).not.toContain("SETTINGS_SCREEN"); - }); - - it("is NOT called what the View menu already calls the dashboard's settings", () => { - // ⌘4 opens the SPA's Server Settings routes — pages that need a session, - // i.e. the exact thing a person on this screen may not have. Two doors with - // one name leading to two places, one of which is the trap the other - // repairs, is worse than a narrower name. - expect(readFileSync(MENU_RS, "utf8")).toContain('"Server Settings"'); - expect(SETTINGS_LABEL).not.toBe("Server Settings"); - }); -}); - -describe("what the screen says about restarting", () => { - it("names the three keys a restart is for, and the one it is not", () => { - // `TRUSTED_ORIGINS` became a live read on 2026-09-16; the other three are - // still read at boot. The dashboard's Addresses card decides its button - // from exactly that split, so a sentence here that lumped them together - // would be this app telling a different story about the same four keys. - expect(SETTINGS_RESTART_NOTE).toContain("restarts"); - expect(SETTINGS_RESTART_NOTE).toContain("while it runs"); - }); }); diff --git a/apps/server/desktop/ui/src/__tests__/update-act.test.ts b/apps/server/desktop/ui/src/__tests__/update-act.test.ts index 881fb9383..52e73226a 100644 --- a/apps/server/desktop/ui/src/__tests__/update-act.test.ts +++ b/apps/server/desktop/ui/src/__tests__/update-act.test.ts @@ -15,14 +15,7 @@ */ import { describe, expect, it } from "bun:test"; import type { AppUpdateCheck, PendingInstall, Probe } from "../lib/ipc"; -import { - leaveHeld, - rejectedResult, - UPDATE_TITLE, - type UpdateActInput, - type UpdateActSelection, - updateAct, -} from "../lib/update-act"; +import { leaveHeld, rejectedResult, type UpdateActInput, type UpdateActSelection, updateAct } from "../lib/update-act"; /** A machine with a managed server running, current with what this app ships. */ function machine(over: Partial = {}): Probe { @@ -611,15 +604,6 @@ describe("the refusals of §6", () => { }); }); -describe("the name", () => { - it("is one title for one act", () => { - // D6: the name becomes TRUE rather than being disambiguated. "Update Your - // Server" and "Update Subshell Server" differed by a possessive, for two - // acts; one act that updates both is honestly called this. - expect(UPDATE_TITLE).toBe("Update Subshell Server"); - }); -}); - /** * The way out is held shut only while something is actually running (review, * 2026-09-18). It matters because the press DESTROYS this window on a ready @@ -628,10 +612,6 @@ describe("the name", () => { * screen with it. */ describe("leaveHeld", () => { - it("holds the leave shut while the app download runs", () => { - expect(leaveHeld({ busy: false, state: "downloading" })).toBe(true); - }); - it("holds it shut while phase 2 installs and restarts", () => { // `act()` sets `busy` around the install-and-restart, and that is the only // signal phase 2 gives — the phase itself outlives the act. @@ -648,14 +628,6 @@ describe("leaveHeld", () => { expect(leaveHeld({ busy: false, state: "idle" })).toBe(false); }); - it("releases it again after a FAILED install, rather than stranding anyone", () => { - // The trap a gate keyed on `phase === "finishing"` would have set: a - // marker survives a failure, so the phase stays while nothing runs. - // `startAppUpdate`'s catch resets the state and `act`'s `finally` clears - // `busy`, so both inputs are false again and the screen can be left. - expect(leaveHeld({ busy: false, state: "idle" })).toBe(false); - }); - it("holds it shut for every non-idle act state, reachable today or not", () => { // `installing` is unreachable from this screen — nothing sets it — but it // is in `ActState`, and a rule named for holding a window shut during an diff --git a/apps/server/desktop/ui/src/__tests__/wizard-state.test.ts b/apps/server/desktop/ui/src/__tests__/wizard-state.test.ts index 607ed0546..187d3f120 100644 --- a/apps/server/desktop/ui/src/__tests__/wizard-state.test.ts +++ b/apps/server/desktop/ui/src/__tests__/wizard-state.test.ts @@ -17,10 +17,7 @@ import { permissionsAfterSetup, prereqState, REQUESTED_SCREENS, - RESET_LABEL, recoveryAction, - recoveryTitle, - SETUP_TITLE, screenForRequest, screensFor, setupRows, @@ -142,19 +139,6 @@ describe("auto-fire, pinned at the source", () => { expect(fireAt, "the fire effect's guard must exist").toBeGreaterThan(-1); const fire = setup.slice(setup.lastIndexOf("useLayoutEffect(() => {", fireAt), setup.indexOf("}, [", fireAt)); - it("exists, gated by the host flag and the pure decision", () => { - expect(host).toContain("const [autoFired, setAutoFired] = useState(false);"); - expect(fire).toContain("if (props.autoFired || running || failure !== null) return;"); - expect(fire).toContain("const decision = autoSetupDecision(probe, conflict, busy || running);"); - // The flag is set BEFORE the chain, or a re-entrant render would see it - // still clear and start a second chain. `onAutoFire` is the host's one - // place both happen, in that order. - const armAt = host.indexOf("onAutoFire={() => {"); - expect(armAt, "the host must hand the screen an onAutoFire").toBeGreaterThan(-1); - const arm = host.slice(armAt, host.indexOf("}}", armAt)); - expect(arm.indexOf("setAutoFired(true);")).toBeLessThan(arm.indexOf("void startSetup();")); - }); - it("holds fire until the port is MEASURED, so a conflicted machine gets the form, not a failed chain", () => { // `canSetup` treats an outstanding port check as free (a Set Up button // must not die for a beat per keystroke) — which is right for the button @@ -173,26 +157,6 @@ describe("auto-fire, pinned at the source", () => { expect(setup.indexOf("if (running) {")).toBeLessThan(setup.indexOf("if (failure) {")); }); - it("keeps the welcome INERT and the requested-screen routing intact", () => { - // Welcome left with spec 2026-09-17 and returned the next day by - // operator request. What the return must not smuggle back is a fire - // beside the greeting: the intro's whole honesty is that nothing has - // touched the machine while it is up, and the fire's placement inside - // the Setup screen is the structural guarantee of that. If Welcome ever - // grows a `startSetup`, the machine is configured while the reader is - // still reading — which is the sentence this pin exists to keep true. - const welcome = readFileSync(join(import.meta.dir, "../screens/welcome-screen.tsx"), "utf8"); - expect(welcome).not.toContain("startSetup"); - expect(welcome).not.toContain("autoFired"); - expect(welcome).toContain("Continue"); - // A requested screen still outranks the probe's family; `permissions` - // lost its dual-role and the routing lost the disambiguator with it. - // The routing is `route()` in the pure seam now. - const state = readFileSync(join(import.meta.dir, "../lib/server-state.ts"), "utf8"); - expect(state).toContain("if (isRequestedScreen(screen)) {"); - expect(state).not.toContain("isRequestedScreen(screen) && !list.includes(screen)"); - }); - it("advances a finished tmux screen PAST the welcome, not back to it", () => { // The re-resolution used to be `list[0]`, which WAS the act until // 2026-09-18 put the greeting at the head. A reader who installed tmux @@ -233,21 +197,7 @@ describe("the waiting handoff, pinned at the source", () => { const host = readFileSync(join(import.meta.dir, "../host.tsx"), "utf8"); const effectAt = host.indexOf('if (r.kind !== "handoff") return;'); expect(effectAt, "the handoff effect must exist").toBeGreaterThan(-1); - const effect = host.slice(effectAt, host.indexOf("}, [r.kind", effectAt)); - - it("opens the dashboard only when the view does not wait", () => { - // The whole report was an auto-navigation nobody dismissed: the open - // must live AFTER the waiting guard, and it must be the ONLY open call - // in the effect — the #74 shape (the call beside the frame, - // unconditional) is the behavior back. The waiting arm reaches the same - // open through the person's Continue, so the effect's one call is the - // only automatic one. - const guard = effect.indexOf(").wait) return;"); - const open = effect.indexOf("void ipc.openMain()"); - expect(guard).toBeGreaterThan(-1); - expect(open, "the handoff effect must still open the dashboard, guarded").toBeGreaterThan(guard); - expect(effect.indexOf("void ipc.openMain()", open + 1), "a second call site is an unguarded one").toBe(-1); - }); + const _effect = host.slice(effectAt, host.indexOf("}, [r.kind", effectAt)); it("holds the progress screen while a chain runs, so the poll cannot skip the press", () => { // The poll keeps ticking WHILE `running` (that is what keeps the progress @@ -281,15 +231,6 @@ describe("the waiting handoff, pinned at the source", () => { expect(setup).toContain("setupRows(props.probe, checklistAddresses(props.probe, props.form)"); }); - it("keeps the completed checklist on screen and offers the press", () => { - const handoff = readFileSync(join(import.meta.dir, "../screens/handoff-screen.tsx"), "utf8"); - expect(handoff).toContain(" { // Not the probe: `onboarded` flips on the first `ready`, which is the // same probe that reaches the handoff, so the page must remember the @@ -299,73 +240,7 @@ describe("the waiting handoff, pinned at the source", () => { }); }); -describe("RESET_LABEL", () => { - it("names what is reset, not the computer, and never ends on 'Mac'", () => { - // Both halves were reported from a screenshot on 2026-09-12, as one bug. - // "Reset this Mac…" reads as erasing the computer, which is nothing like - // what this does; and a label ending on "Mac" reads as a truncated - // "Machine", against a sibling string that really is "this machine" and - // under the ellipsis a button that opens a screen carries. - expect(RESET_LABEL).toBe("Reset this server"); - expect(RESET_LABEL.endsWith("Mac")).toBe(false); - // No platform word on either side of the split: everything this app does - // is on this machine, so saying so was only ever redundant. Subshell - // Client's reset title is this same string — one act, one name. - expect(RESET_LABEL).not.toContain("Machine"); - expect(RESET_LABEL).not.toContain("machine"); - }); - - it("is the Reset dialog's own title (its home moved with the frame)", () => { - // The confirmation became a dialog (operator ruling 2026-09-23), so - // RESET_LABEL is the Dialog's title rather than the frame shell's. It is - // the SAME constant as before, just in a new home — NOT the rail door's - // shorter "Reset" label (that lives in server-state.ts). While the chain - // runs the title follows the pane, which is what the meter's heading reads. - const dialog = readFileSync(join(import.meta.dir, "../screens/reset-dialog.tsx"), "utf8"); - expect(dialog).toContain('title={started ? "Resetting this server" : RESET_LABEL}'); - // And nothing claims the old frame home any more: `shell()` lost its - // reset case with the reset route kind. - const host = readFileSync(join(import.meta.dir, "../host.tsx"), "utf8"); - expect(host).not.toContain("title: RESET_LABEL"); - }); -}); - -describe("recoveryTitle / recoveryAction", () => { - it("names the step in the assistant's voice", () => { - expect(recoveryTitle("no-server")).toBe("No Server Found"); - expect(recoveryTitle("unreachable")).toBe("Your Server Isn't Responding"); - expect(recoveryTitle("init")).toBe("Your Server Needs Its Configuration"); - expect(recoveryTitle("install-service")).toBe("Your Server Isn't Installed as a Service"); - expect(recoveryTitle("start")).toBe("Your Server Is Stopped"); - }); - - it("gives the Set Up screen one name on both paths to it, naming the product", () => { - // One act reached two ways — first run, and recovery on a machine that - // lost its server binary — must not have two names (operator's call, - // 2026-09-12). - expect(recoveryTitle("setup")).toBe(SETUP_TITLE); - expect(SETUP_TITLE).toBe("Set Up Subshell Server"); - // `not.toContain` is right for THIS string and wrong for most: it names - // no machine at all, so either spelling is a regression. Do not copy the - // form onto a title that legitimately says "This Machine" — that string - // CONTAINS "Mac", and the prefix is the whole misreading. Anywhere a - // machine word is allowed, assert where the string STOPS instead, the - // way `recoveryTitle` is checked below. - expect(SETUP_TITLE).not.toContain("Mac"); - expect(SETUP_TITLE).not.toContain("machine"); - }); - - it("never lets a title END on 'Mac', which is what reads as truncated", () => { - // The reported bug was a label ending on "Mac" — a prefix of the other - // platform's own word — under an ellipsis. Every title, not one. - const steps = ["no-server", "unreachable", "init", "install-service", "start", "setup", "ready"] as const; - for (const step of steps) { - expect(recoveryTitle(step).endsWith("Mac"), step).toBe(false); - } - expect(RESET_LABEL.endsWith("Mac")).toBe(false); - expect(SETUP_TITLE.endsWith("Mac")).toBe(false); - }); - +describe("recoveryAction", () => { it("offers one primary action per step", () => { expect(recoveryAction("start")).toEqual({ label: "Start", kind: "start" }); expect(recoveryAction("install-service")).toEqual({ label: "Install and Start", kind: "install-service" }); diff --git a/apps/server/desktop/ui/src/screens/__tests__/status-details.test.tsx b/apps/server/desktop/ui/src/screens/__tests__/status-details.test.tsx index 9ee6a0ccb..dc89727d9 100644 --- a/apps/server/desktop/ui/src/screens/__tests__/status-details.test.tsx +++ b/apps/server/desktop/ui/src/screens/__tests__/status-details.test.tsx @@ -60,18 +60,6 @@ describe("the facts", () => { reveals[0].click(); expect(onReveal).toHaveBeenCalledTimes(1); }); - - it("puts dt and dd in the grid as DIRECT children, one row per pair", () => { - // `.facts` is `display:grid; grid-template-columns:132px 1fr` and reads - // its dt/dd as direct children; a wrapper div per row would make each row - // ONE grid item and collapse the table into alternating narrow columns. - renderDetails({ probe: makeProbe() }); - const dl = document.querySelector("dl.facts") as HTMLDListElement; - expect(dl.children.length).toBeGreaterThan(0); - for (const child of dl.children) { - expect(["DT", "DD"]).toContain(child.tagName); - } - }); }); describe("the tail's stick", () => { @@ -162,17 +150,3 @@ describe("the panes", () => { expect(screen.queryByText("Server CLI")).toBeNull(); }); }); - -describe("the inline rule (wave 2)", () => { - it("renders as the section's content, with no disclosure around it", () => { - // The operator's ruling, 2026-09-22: the Show Details disclosure is - // gone — a sidebar section that hides its own facts behind a second - // control is two navigations for one answer. The block is always on - // screen, and nothing about it collapses. - renderDetails({ lastTail: { text: "log line", source: "server", note: null } }); - expect(document.querySelector("details")).toBeNull(); - expect(document.querySelector("summary")).toBeNull(); - expect(screen.getByText("Server log")).toBeDefined(); - expect(screen.getByText("log line")).toBeDefined(); - }); -}); diff --git a/apps/server/web/src/components/__tests__/actions-menu.test.tsx b/apps/server/web/src/components/__tests__/actions-menu.test.tsx index 650cc2c2a..576e6bead 100644 --- a/apps/server/web/src/components/__tests__/actions-menu.test.tsx +++ b/apps/server/web/src/components/__tests__/actions-menu.test.tsx @@ -48,12 +48,6 @@ describe("ActionsMenu", () => { await waitFor(() => expect(screen.queryByRole("menuitem") === null).toBe(true)); }); - it("marks destructive items so they render in the destructive colour", async () => { - render(); - await openMenu("web"); - expect(screen.getByRole("menuitem", { name: "Delete" }).className).toContain("text-destructive"); - }); - it("groups destructive actions last with one divider, preserving each group's order", async () => { render( { expect(router.state.location.pathname).toBe("/nodes"); }); - it("test_panel_width_retired_rail: the panel sheds the retired rail's width and the desktop bar's fifth button still fits", async () => { - // Browser: four cells at the 44px touch size (4*44 + 3*4 gap + 16 padding = - // 204px) inside w-58 (232px); fine pointers take 36px cells and need less. - await renderRail("/nodes"); - expect(document.querySelector("aside")?.classList.contains("w-58")).toBe(true); - // Every bar cell keeps the 44px base for touch (the phone drawer shares this - // bar) and shrinks only under pointer-fine; the pair on each cell is the fix. - const webBar = screen.getByRole("navigation", { name: "Main sections" }); - for (const cell of [...within(webBar).getAllByRole("button"), ...within(webBar).getAllByRole("link")]) { - expect(cell.className.includes("size-11")).toBe(true); - expect(cell.className.includes("pointer-fine:size-9")).toBe(true); - } - cleanup(); - // Server desktop shell: the bar gains "Open in browser", and five cells at - // the 44px touch size (5*44 + 4*4 + 16 = 252px) need w-64 (256px); w-62 - // would clip them. Fine pointers take 36px cells, which fit with room left. - setUA(SERVER_UA); - await renderRail("/nodes", false, "desktop"); - const aside = document.querySelector("aside"); - expect(aside?.classList.contains("w-64")).toBe(true); - const bar = screen.getByRole("navigation", { name: "Main sections" }); - // Four cells ride the bar (Workspaces, mobile, Settings, Account) and the - // shell adds a fifth; only two of them are links now. - expect(within(bar).getAllByRole("link")).toHaveLength(2); - const openInBrowser = within(bar).getByRole("button", { name: "Open in browser" }); - expect(openInBrowser.className.includes("size-11")).toBe(true); - expect(openInBrowser.className.includes("pointer-fine:size-9")).toBe(true); - }); - it("test_no_collapse_pref_or_state: clicking the active section never collapses the panel or records a preference", async () => { const router = await renderRail("/nodes"); fireEvent.click(screen.getByRole("link", { name: "Settings" })); diff --git a/apps/server/web/src/components/__tests__/app-sidebar.test.ts b/apps/server/web/src/components/__tests__/app-sidebar.test.ts deleted file mode 100644 index 680199e71..000000000 --- a/apps/server/web/src/components/__tests__/app-sidebar.test.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { describe, expect, it } from "bun:test"; -import { ServerCog, Settings, SlidersHorizontal } from "lucide-react"; -import { isNavGroup, visibleNavEntries, visibleNavItems } from "@/components/sidebar/sidebar-nav"; - -describe("sidebar nav icons (spec 2026-09-02 §3, 2026-09-11 §3.1)", () => { - const entries = visibleNavEntries(true); - const items = visibleNavItems(true); - - it("Presets uses SlidersHorizontal, not the Server gear", () => { - expect(items.find((i) => i.to === "/presets")?.icon).toBe(SlidersHorizontal); - }); - - it("General keeps the plain Settings gear", () => { - // The gear stayed on the page it always named; the GROUP above it takes - // ServerCog, so the two never have to share (and Server stays on Nodes). - expect(items.find((i) => i.to === "/settings")?.icon).toBe(Settings); - }); - - it("the Server Settings group takes ServerCog", () => { - expect(entries.filter(isNavGroup).find((g) => g.id === "server-settings")?.icon).toBe(ServerCog); - }); - - it("SSH launches with subshells rather than a separate navigation page", () => { - expect(items.some((i) => i.to === "/connect")).toBe(false); - }); - - it("SSH presets replace the personal SSH settings page", () => { - expect(visibleNavItems(false).some((i) => i.to === "/settings/ssh")).toBe(false); - expect(visibleNavItems(false).some((i) => i.to === "/presets")).toBe(true); - }); - - it("no two visible icons are the same — group headers included", () => { - // Icons name the context-nav rows and the section-bar buttons, so a - // duplicate reads as one control seen twice. The group's own icon counts: - // it sits in the same column as every leaf above it. - const icons = [...entries.filter(isNavGroup).map((g) => g.icon), ...items.map((i) => i.icon)]; - expect(new Set(icons).size).toBe(icons.length); - }); -}); diff --git a/apps/server/web/src/components/__tests__/combobox.test.tsx b/apps/server/web/src/components/__tests__/combobox.test.tsx index 42f283a43..961dc6e98 100644 --- a/apps/server/web/src/components/__tests__/combobox.test.tsx +++ b/apps/server/web/src/components/__tests__/combobox.test.tsx @@ -183,13 +183,6 @@ describe("SearchableSelect — the popup's bottom edge (uneven-gap report 2026-0 return popup.querySelector('[role="status"]') as HTMLElement; }; - it("the mounted Empty region owns no padding box and no text while rows show", async () => { - const status = await openWith(OPTIONS); - expect(status).not.toBeNull(); // stays mounted — that is Base UI's doctrine, not our bug - expect(status.childElementCount).toBe(0); // no sentence while rows exist - expect(status.className).not.toMatch(/(^|\s)(p|px|py|pt|pb)-/); // the phantom 12px strip - }); - it("still renders the empty sentence inside the live region when there are no rows", async () => { const status = await openWith([]); // contains, not equals: Base UI sprinkles a word-joiner (U+2060) in the diff --git a/apps/server/web/src/components/__tests__/desktop-app-update-row.test.tsx b/apps/server/web/src/components/__tests__/desktop-app-update-row.test.tsx index 51e3ae7ce..9db68b606 100644 --- a/apps/server/web/src/components/__tests__/desktop-app-update-row.test.tsx +++ b/apps/server/web/src/components/__tests__/desktop-app-update-row.test.tsx @@ -199,62 +199,6 @@ describe("DesktopAppUpdateRow", () => { expect(screen.queryByRole("button", { name: "Dismiss update notice" })).toBeNull(); }); - it("renders the upgrade icon ONLY when there is news", async () => { - setUA(SERVER_UA); - fakeTauri({ currentVersion: "0.7.2", availableVersion: null }); - const { container } = renderRow(); - await waitFor(() => expect(screen.getByText(/Subshell Server App 0\.7\.2/)).toBeTruthy()); - // The icon is always in the DOM for alignment; what changes is whether its - // SLOT is `invisible`. So the news test reads the slot, not the glyph. - expect(container.querySelector("span[aria-hidden]")?.className).toContain("invisible"); - cleanup(); - fakeTauri({ currentVersion: "0.7.2", availableVersion: "0.8.0" }); - const withNews = renderRow(); - await waitFor(() => - expect( - screen.getByRole("button", { name: "Subshell Server App 0.7.2: v0.8.0 available. Open updates." }), - ).toBeTruthy(), - ); - expect(withNews.container.querySelector("span[aria-hidden]")?.className).not.toContain("invisible"); - expect(withNews.container.querySelectorAll(".text-warning")).toHaveLength(1); - }); - - /** - * The two footer rows have to line up. `DesktopServerPill` always draws its - * status circle, so its text carries the dot's indent; a version row whose - * dot was conditionally ABSENT started flush left whenever there was no - * news, and the two lines disagreed (operator's report, 2026-09-18). The - * spacer is therefore always in the layout and merely `invisible`. - */ - it("keeps the marker's space whether or not there is news, so the rows align", async () => { - setUA(SERVER_UA); - fakeTauri({ currentVersion: "0.7.2", availableVersion: null }); - const quiet = renderRow(); - await waitFor(() => expect(screen.getByText(/Subshell Server App 0\.7\.2/)).toBeTruthy()); - const quietSpacer = quiet.container.querySelector("span[aria-hidden]"); - expect(quietSpacer).not.toBeNull(); - expect(quietSpacer?.className).toContain("invisible"); - - cleanup(); - fakeTauri({ currentVersion: "0.7.2", availableVersion: "0.8.0" }); - const loud = renderRow(); - await waitFor(() => - expect( - screen.getByRole("button", { name: "Subshell Server App 0.7.2: v0.8.0 available. Open updates." }), - ).toBeTruthy(), - ); - const loudSpacer = loud.container.querySelector("span[aria-hidden]"); - expect(loudSpacer?.className).not.toContain("invisible"); - expect(loudSpacer?.querySelector("svg")).not.toBeNull(); - - // Same box either way — that IS the alignment. `size-3.5` since - // 2026-09-19: the marker became a 14px ArrowUpCircle, and - // `DesktopServerPill` centres its 8px status dot in a slot of this same - // width so the two footer rows still start their text at one x. - expect(quietSpacer?.className).toContain("size-3.5"); - expect(loudSpacer?.className).toContain("size-3.5"); - }); - /** * The backstop, at the ROW rather than only in the model: a new page meeting * an old binary whose stored notice outlived the install it announced must diff --git a/apps/server/web/src/components/__tests__/detail-back-header.test.tsx b/apps/server/web/src/components/__tests__/detail-back-header.test.tsx index be2183953..5de03e01e 100644 --- a/apps/server/web/src/components/__tests__/detail-back-header.test.tsx +++ b/apps/server/web/src/components/__tests__/detail-back-header.test.tsx @@ -63,61 +63,6 @@ describe("DetailBackHeader", () => { // NOTE: no relying on the ambient matchMedia — happy-dom's own answers // width queries against a 1024px window (i.e. "wide"), so every test // forces its viewport and pointer explicitly. - it("phone (narrow, coarse pointer): chrome row, then the title with the subtitle stacked UNDER it", async () => { - const restore = forceViewport({ wide: false, coarse: true }); - try { - const header = await renderHeader({ subtitle: "/home/theo/projects/subshell" }); - expect(header?.className).toContain("flex-col"); - const title = screen.getByText("Alpha"); - const subtitle = screen.getByText("/home/theo/projects/subshell"); - // Same stacked container: the subtitle's parent is a flex-col that - // also holds the title — never a sibling on the title's line. - expect(subtitle.parentElement).toBe(title.parentElement); - expect(subtitle.parentElement?.className).toContain("flex-col"); - } finally { - restore(); - } - }); - - it("narrow desktop window (fine pointer): one row, title over subtitle inside it", async () => { - const restore = forceViewport({ wide: false, coarse: false }); - try { - const header = await renderHeader({ subtitle: "/home/theo/projects/subshell" }); - // One row: the header itself is a horizontal bar, no chrome/title split. - expect(header?.className).not.toContain("flex-col"); - const title = screen.getByText("Alpha"); - const subtitle = screen.getByText("/home/theo/projects/subshell"); - // Two lines: title and subtitle share a vertical block flanked by the - // chrome and actions, rather than competing for one baseline. - expect(subtitle.parentElement).toBe(title.parentElement); - expect(title.parentElement?.className).toContain("flex-col"); - } finally { - restore(); - } - }); - - it("wide: one row with the title/subtitle block beside the actions", async () => { - const restore = forceViewport({ wide: true, coarse: false }); - try { - const header = await renderHeader({ subtitle: "/tmp/x" }); - expect(header?.className).not.toContain("flex-col"); - expect(screen.getByText("Alpha")).toBeDefined(); - expect(screen.getByText("/tmp/x")).toBeDefined(); - } finally { - restore(); - } - }); - - it("subtitle absent: phone layout carries just the title", async () => { - const restore = forceViewport({ wide: false, coarse: true }); - try { - const header = await renderHeader(); - expect(header?.className).toContain("flex-col"); - expect(screen.getByText("Alpha")).toBeDefined(); - } finally { - restore(); - } - }); it("hideBack drops the back control from the chrome row", async () => { const restore = forceViewport({ wide: false, coarse: true }); diff --git a/apps/server/web/src/components/__tests__/existing-subshell-list.test.tsx b/apps/server/web/src/components/__tests__/existing-subshell-list.test.tsx index ce2d12827..11d59dea4 100644 --- a/apps/server/web/src/components/__tests__/existing-subshell-list.test.tsx +++ b/apps/server/web/src/components/__tests__/existing-subshell-list.test.tsx @@ -191,11 +191,6 @@ describe("ExistingSubshellList", () => { // …and does NOT claim the workspace already holds them all. expect(screen.queryByText(/Every subshell is already on this workspace/)).toBeNull(); }); - - it("still claims full attachment for an empty unfiltered list", () => { - render(); - expect(screen.getByText(/Every subshell is already on this workspace/)).toBeDefined(); - }); }); }); diff --git a/apps/server/web/src/components/__tests__/network-plugin-card.test.tsx b/apps/server/web/src/components/__tests__/network-plugin-card.test.tsx index cbcfaf062..6134ec39e 100644 --- a/apps/server/web/src/components/__tests__/network-plugin-card.test.tsx +++ b/apps/server/web/src/components/__tests__/network-plugin-card.test.tsx @@ -388,39 +388,6 @@ describe("NetworkPluginCard: the state matrix", () => { expect(screen.queryByRole("button", { name: "Install" })).toBeNull(); }); - it("leads with the state's own sentence, ABOVE the numbered steps", async () => { - // What a person meets first has to be what is wrong. The sentence is the - // one thing the live status knows and the manifest cannot, and it used to - // render UNDER the steps it explains — so a card opened on a machine with - // nothing installed began with "1. Install the Tailscale daemon" and - // buried "Tailscale is not installed on this machine" in the middle, in - // the same muted grey as a step label. - await renderCard( - row({ - state: "not-installed", - privileged: [ - { label: "Install the daemon", command: "brew install tailscale" }, - { label: "Allow this server to control Tailscale", command: "sudo tailscale set --operator=$USER" }, - ], - status: { - state: "not-installed", - addresses: [], - hints: [{ text: "Tailscale is not installed on this machine.", docsUrl: "https://ts.net/install" }], - }, - }), - ); - const card = screen.getByRole("group", { name: "Tailscale" }); - const text = card.textContent ?? ""; - expect(text.indexOf("Tailscale is not installed on this machine.")).toBeLessThan( - text.indexOf("1.Install the daemon"), - ); - // A lead sentence is not step 1 of anything, so the steps still start at 1. - expect(text).toContain("1.Install the daemon"); - expect(text).toContain("2.Allow this server to control Tailscale"); - // Each manifest command is rendered exactly ONCE. - expect(screen.getAllByRole("button", { name: "Copy" }).length).toBe(2); - }); - it("keeps a sentence that FOLLOWS the plugin's own commands where the plugin put it", async () => { // The mirror of the test above, and the reason the split is "hints before // the FIRST COMMAND" rather than "every hint without a command". Once a @@ -481,19 +448,6 @@ describe("NetworkPluginCard: the state matrix", () => { expect(card.innerHTML).not.toContain("javascript:"); }); - it("a single thing to do is not numbered", async () => { - await renderCard( - row({ - state: "not-installed", - status: { state: "not-installed", addresses: [], hints: [{ text: "Install Tailscale on this machine." }] }, - }), - ); - const card = screen.getByRole("group", { name: "Tailscale" }); - expect(card.textContent).toContain("Install Tailscale on this machine."); - // A lone "1." promises a second step that never comes. - expect(card.textContent).not.toContain("1.Install Tailscale"); - }); - it("not-installed offers Install only where the server may run the command, and streams it", async () => { const calls = mockFetch((url) => url.pathname === "/api/network/tailscale/install" @@ -526,33 +480,6 @@ describe("NetworkPluginCard: the state matrix", () => { expect(screen.queryByRole("button", { name: /^Publish/ })).toBeNull(); }); - it("numbers nothing at all on a daemon that is down, however many hints there are", async () => { - // The shape the first shipped plugin emits: the platform's start command, - // then the daemon's own first line of stderr explaining a case that - // command does not fix. Numbering belongs to the `not-installed` row, - // which is a sequence a person walks; these are the state of a machine - // right now, and "2." in front of an explanation tells someone to go and - // do something that is not a thing to do. The card passes no `startAt` - // here, so there is nothing to get wrong — this pins that. - await renderCard( - row({ - state: "daemon-down", - status: { - state: "daemon-down", - addresses: [], - hints: [ - { text: "tailscaled is not running.", command: "sudo systemctl start tailscaled", privileged: true }, - { text: "failed to connect to local tailscaled; is it running?" }, - ], - }, - }), - ); - const card = screen.getByRole("group", { name: "Tailscale" }); - expect(card.textContent).toContain("failed to connect to local tailscaled; is it running?"); - expect(screen.queryByText("1.")).toBeNull(); - expect(screen.queryByText("2.")).toBeNull(); - }); - it("needs-privilege is the same shape as a daemon that is down — a hint and nothing else", async () => { await renderCard( row({ @@ -1129,74 +1056,6 @@ describe("NetworkPluginCard: the state matrix", () => { expect(screen.queryByText(/You can skip this/)).toBeNull(); }); - it("names the act by the label the button under it actually carries", async () => { - // The defect: the sentence said "publishing" while the button said - // something else — NetBird's "Use this address" made an operator ask how to - // publish. One expression feeds both, so a plugin's own vocabulary is what - // the reader is told to press, and a plugin that names no label still gets - // the fallback in both places. - const netbird = row({ - id: "netbird", - name: "NetBird", - state: "joined", - labels: { credential: "Setup key", publish: "Use this address" }, - status: { state: "joined", addresses: ADDRESSES, hints: [] }, - }); - await renderCard(netbird); - expect( - screen.getByText( - "Subshell is not published on NetBird yet. “Use this address” is what lets your other devices open this dashboard over the network.", - ), - ).toBeTruthy(); - expect(screen.getByRole("button", { name: "Use this address" })).toBeTruthy(); - cleanup(); - - await renderCard( - row({ state: "joined", labels: {}, status: { state: "joined", addresses: ADDRESSES, hints: [] } }), - ); - expect( - screen.getByText( - "Subshell is not published on Tailscale yet. “Publish” is what lets your other devices open this dashboard over the network.", - ), - ).toBeTruthy(); - expect(screen.getByRole("button", { name: "Publish" })).toBeTruthy(); - }); - - it("labels each address above its value with the shared fact styling", async () => { - // Facts use smaller bold, muted labels and regular full-size values across cards and dialogs. - await renderCard(row({ state: "joined", status: { state: "joined", addresses: ADDRESSES, hints: [] } })); - const items = ADDRESSES.map((address) => screen.getByText(address.label).closest("div") as HTMLElement); - const first = items[0] as HTMLElement; - expect(first.children[0]?.textContent).toBe("MagicDNS name"); - expect(first.children[0]?.className).toContain("text-muted-foreground"); - expect(first.children[0]?.className).toContain("font-strong"); - expect(first.children[0]?.className).not.toContain("text-label"); - expect(first.children[0]?.className).toContain("text-detail"); - expect(first.children[1]?.className).toContain("text-body"); - expect(first.children[1]?.textContent).toContain("https://box.tail1234.ts.net"); - // The secure-context sentence keeps its OWN element below the value (a - // merged line would pass a plain textContent check), and it is still the - // address's own — one per address, comparative by repetition. - const sentence = first.children[1]?.querySelector("p"); - expect(sentence?.textContent).toBe("Passkeys and secure cookies work at this address."); - // The value and its copy button come first; the sentence follows them. - expect(first.children[1]?.firstElementChild).not.toBe(sentence); - const second = items[1] as HTMLElement; - expect(second.children[0]?.textContent).toBe("Tailscale IP"); - }); - - it("renders the addresses in the order the server sent them", async () => { - // The plugin's order is its own decision — tailscale puts its https - // origin first — and this page prints the list it was handed. Re-sorting - // here by scheme, by secure context or by label would make the page - // contradict its own data for a reason no reader can see. - await renderCard(row({ state: "joined", status: { state: "joined", addresses: ADDRESSES, hints: [] } })); - const list = screen.getByText(ADDRESSES[0]?.label ?? "").closest("dl"); - const shown = Array.from(list?.children ?? [], (item) => item.textContent ?? ""); - expect(shown[0]).toContain("https://box.tail1234.ts.net"); - expect(shown[1]).toContain("http://100.64.0.1:3080"); - }); - it("offers to copy every address, joined ones included", async () => { // The copy button used to arrive only with `published`, on a prop that // called an unpublished address "a preview". A joined mesh address @@ -1955,38 +1814,6 @@ describe("hint numbering", () => { }); }); -describe("NetworkPluginCard: no state offers a Re-check button", () => { - // Operator's sixth live read, 2026-09-16: "do we need it?" No. The button - // duplicated the page poll beside it — every capability it had is the - // poll's — and the settings page had reasoned to that rule before the - // card contradicted itself ("no Refresh button: the page polls"). The - // cadences are untouched; the affordance is gone from EVERY state of - // BOTH frames, and the plugin hints that used to end "then re-check" now - // say the page notices. The states that once carried the button are - // listed so the deletion is pinned per state, not once in the abstract. - const hadOneOnce: NetworkState[] = [ - "not-installed", - "daemon-down", - "needs-privilege", - "needs-login", - "joined", - "published", - ]; - - for (const state of hadOneOnce) { - it(`offers no Re-check in ${state}`, async () => { - await renderCard( - row({ - state, - privileged: [{ label: "Install it", command: "sudo apt install meshtool" }], - status: { state, addresses: [], hints: [{ text: "Something is not right yet." }] }, - }), - ); - expect(screen.queryByRole("button", { name: "Re-check" })).toBeNull(); - }); - } -}); - describe("NetworkPluginCard: the disable reason reaches a screen reader", () => { it("associates the reason with every field rather than only placing it above", async () => { // A screen reader tabbing this form skips disabled inputs entirely, so a diff --git a/apps/server/web/src/components/__tests__/new-subshell-form.test.tsx b/apps/server/web/src/components/__tests__/new-subshell-form.test.tsx index dd350efca..22e6a75a1 100644 --- a/apps/server/web/src/components/__tests__/new-subshell-form.test.tsx +++ b/apps/server/web/src/components/__tests__/new-subshell-form.test.tsx @@ -794,21 +794,6 @@ describe("NewSubshellForm working-dir defaults", () => { } }); - it("takes the directory default from the node the pick ends up on", async () => { - const ENROLLED_ONLY = node({ id: "a1", name: "mac", harnesses: [TERM_ON] }); - const restore = mockFetch([ENROLLED_ONLY], [], [], [], (n) => ({ - paths: [], - home: n === "a1" ? "/home/on-a1" : "/home/left-behind", - })); - try { - const { latest } = await renderForm(); - await waitFor(() => expect(latest().workingDir).toBe("/home/on-a1")); - expect(latest().nodeId).toBe("a1"); - } finally { - restore(); - } - }); - it("will not arm the pre-fill before the node list has settled the pick", async () => { // The review's ordering case: the recents query answers WHILE `nodes` is // still loading. Arming then fills local's home and closes the door diff --git a/apps/server/web/src/components/__tests__/not-found-page.test.tsx b/apps/server/web/src/components/__tests__/not-found-page.test.tsx index d29d3e824..94fdf1066 100644 --- a/apps/server/web/src/components/__tests__/not-found-page.test.tsx +++ b/apps/server/web/src/components/__tests__/not-found-page.test.tsx @@ -47,19 +47,4 @@ describe("SubshellNotFoundCard", () => { const link = screen.getByRole("link", { name: "Back to subshells" }); expect(link.getAttribute("href")).toBe("/subshells"); }); - - it("wears the primary button, matching the router 404's action, not the outline variant", async () => { - renderWithRouter(SubshellNotFoundCard); - const card = await screen.findByRole("link", { name: "Back to subshells" }); - cleanup(); - renderWithRouter(NotFoundPage); - const page = await screen.findByRole("link", { name: "Go to subshells" }); - // Both not-found cards are one standard: the primary action button. - // Equality with the router card's action is the drift guard (operator - // flag 2026-10-20: the subshell card wore the outline variant while the - // router 404 card wore the default). - expect(card.className).toBe(page.className); - expect(card.className).toContain("var(--button-primary-from)"); - expect(card.className).not.toContain("border-input"); - }); }); diff --git a/apps/server/web/src/components/__tests__/segmented.test.tsx b/apps/server/web/src/components/__tests__/segmented.test.tsx index 436b2bf22..4676c1066 100644 --- a/apps/server/web/src/components/__tests__/segmented.test.tsx +++ b/apps/server/web/src/components/__tests__/segmented.test.tsx @@ -88,36 +88,4 @@ describe("Segmented", () => { fireEvent.click(target); expect(picked).toEqual(["new"]); }); - - it("dense halves the button box height; without it every button keeps the shared sm table", () => { - // The byte-identical guarantee for every non-dense consumer (home tiles - // toggle, the dialogs): the pin below is the button's ACTUAL className, - // h-8 included. `dense` is opt-in and merges `h-6` — the rail's density - // ask (24px, the cell grid's own rhythm); `cn` is twMerge, so h-6 wins - // over the variant's h-8 in any order, and px-3 (width) stays untouched. - const { unmount } = render( {}} />); - const plain = screen.getByRole("button", { name: "New subshell" }); - expect(plain.className).toContain("h-8"); - expect(plain.className).not.toContain("h-6"); - unmount(); - render( {}} dense />); - for (const btn of screen.getAllByRole("button")) { - expect(btn.className).toContain("h-6"); - expect(btn.className).not.toContain("h-8"); - // Width untouched: the operator rejected width changes twice. - expect(btn.className).toContain("px-3"); - } - }); - - it("fills by default and sizes to its words with fill=false", () => { - // The page-tab rule (design-system.md, 2026-09-25): the tab strip is - // content-sized; the in-row switch keeps the equal share. - const { unmount } = render( {}} />); - expect(screen.getByRole("group").className).not.toContain("w-fit"); - expect(screen.getByRole("button", { name: "New subshell" }).className).toContain("flex-1"); - unmount(); - render( {}} fill={false} />); - expect(screen.getByRole("group").className).toContain("w-fit"); - expect(screen.getByRole("button", { name: "New subshell" }).className).not.toContain("flex-1"); - }); }); diff --git a/apps/server/web/src/components/__tests__/step-dots.test.tsx b/apps/server/web/src/components/__tests__/step-dots.test.tsx index bb92f9fbe..7a9252879 100644 --- a/apps/server/web/src/components/__tests__/step-dots.test.tsx +++ b/apps/server/web/src/components/__tests__/step-dots.test.tsx @@ -14,8 +14,4 @@ describe("StepDots", () => { expect(dots[5]?.getAttribute("data-dot")).toBe("upcoming"); expect(screen.getByText("Step 4 of 6")).toBeTruthy(); }); - it("is not navigation", () => { - render(); - expect(document.querySelectorAll("button, a")).toHaveLength(0); - }); }); diff --git a/apps/server/web/src/components/__tests__/subshell-actions-menu.test.tsx b/apps/server/web/src/components/__tests__/subshell-actions-menu.test.tsx index 6b9b4cd43..c8a94b5ce 100644 --- a/apps/server/web/src/components/__tests__/subshell-actions-menu.test.tsx +++ b/apps/server/web/src/components/__tests__/subshell-actions-menu.test.tsx @@ -220,25 +220,6 @@ describe("SubshellActionsMenu — access gating (spec §4.1)", () => { } }); - it("the removed actions stay gone: no Terminate, no title pin, no Terminal history (spec 2026-09-03)", async () => { - const { restore } = mockFetch(); - try { - // A dead row is the one state that still carries a lifecycle item, so - // its absence there proves the removal, not just the alive-gate. - for (const alive of [true, false]) { - await renderMenu(makeSubshell({ alive })); - await openMenu("subshell"); - expect(screen.queryByRole("menuitem", { name: "Terminate" })).toBeNull(); - expect(screen.queryByRole("menuitem", { name: "Pin this title" })).toBeNull(); - expect(screen.queryByRole("menuitem", { name: "Resume auto title" })).toBeNull(); - expect(screen.queryByRole("menuitem", { name: "Terminal history…" })).toBeNull(); - cleanup(); - } - } finally { - restore(); - } - }); - it("Clone… is owner-only (an edit grantee's clone would 404 on the preset) and opens the clone dialog", async () => { const { restore } = mockFetch(); try { diff --git a/apps/server/web/src/components/__tests__/subshell-dot.test.tsx b/apps/server/web/src/components/__tests__/subshell-dot.test.tsx index e462373eb..7c7d51bb3 100644 --- a/apps/server/web/src/components/__tests__/subshell-dot.test.tsx +++ b/apps/server/web/src/components/__tests__/subshell-dot.test.tsx @@ -18,58 +18,6 @@ const probe = (overrides: Partial = {}): SubshellView => ...overrides, }) as SubshellView; -describe("SubshellDot", () => { - it("maps each indicator to its fill and tooltip word", () => { - const cases: Array<[Partial, string, string]> = [ - // GREEN is the alive family since 2026-09-24: printing is full green, - // quiet-but-running is dim green. Gray now means NOT running. - [{ activity: "active" }, "bg-success", "working"], - [{ activity: "idle" }, "bg-success/50", "idle"], - [{ waitingSince: "2026-09-03T00:00:00.000Z" }, "bg-warning", "waiting for you"], - [{ alive: false }, "bg-muted-foreground/50", "exited"], - [{ status: "terminated", alive: false, activity: "terminated" }, "border-muted-foreground", "ended"], - // Unreachable is RED, not amber, since 2026-09-24 — the palette's own - // error role, deliberately the loudest fill on the dot. - [{ nodeOffline: true }, "bg-destructive", "node unreachable"], - ]; - for (const [overrides, fill, label] of cases) { - const { unmount } = render(); - const dot = document.querySelector('[aria-hidden="true"]'); - expect(dot?.getAttribute("class") ?? "").toContain(fill); - expect(dot?.getAttribute("title")).toBe(label); - unmount(); - } - }); -}); - -describe("SubshellDot — the working blink (2026-09-24)", () => { - afterEach(cleanup); - - const classOfDot = () => document.querySelector('[aria-hidden="true"]')?.getAttribute("class") ?? ""; - - it("the ACTIVE dot carries the blink", () => { - render(); - expect(classOfDot()).toContain("subshell-dot-blink"); - }); - - it("nothing else blinks — the motion is the difference between working and quiet", () => { - // Dim green already says "alive, quiet"; the blink says "printing NOW". - // If idle blinked too, the pair would differ only by brightness again. - for (const overrides of [ - { activity: "idle" }, - { waitingSince: "2026-09-24T00:00:00.000Z" }, - { alive: false }, - { nodeOffline: true }, - { unseenPush: true, activity: "active" }, // the bell stays still - ] satisfies Partial[]) { - const { unmount } = render(); - expect(classOfDot()).not.toContain("subshell-dot-blink"); - expect(document.querySelector("svg")?.getAttribute("class") ?? "").not.toContain("subshell-dot-blink"); - unmount(); - } - }); -}); - describe("SubshellDot — the header's variant (2026-09-20)", () => { // Each case queries the document, so a leftover render would answer for it. afterEach(cleanup); @@ -122,11 +70,6 @@ describe("SubshellDot — the unseen-notification bell (spec 2026-09-23)", () => expect(el?.getAttribute("data-alive")).toBe("true"); }); - it("keeps the indicator's tone on the bell", () => { - render(); - expect(document.querySelector("svg")?.getAttribute("class") ?? "").toContain("text-success"); - }); - it("no bell, no glyph — the dot stays when nothing is unseen", () => { render(); expect(document.querySelector("svg")).toBeNull(); diff --git a/apps/server/web/src/components/__tests__/terminal-key-bar.test.tsx b/apps/server/web/src/components/__tests__/terminal-key-bar.test.tsx index 27af5df05..ef1d5d921 100644 --- a/apps/server/web/src/components/__tests__/terminal-key-bar.test.tsx +++ b/apps/server/web/src/components/__tests__/terminal-key-bar.test.tsx @@ -453,19 +453,6 @@ describe("TerminalKeyBar", () => { expect(img.matches(":disabled, [aria-disabled=true]")).toBe(true); }); - it("keeps keys and the image button on one horizontally scrollable row", () => { - // Controls keep their touch targets and scroll horizontally rather than wrapping. - render( {}} onPickImage={() => {}} />); - const escRow = screen.getByRole("button", { name: "Send Escape" }).parentElement; - const enterRow = screen.getByRole("button", { name: "Send Enter" }).parentElement; - const slashRow = screen.getByRole("button", { name: "Send slash" }).parentElement; - const imgRow = screen.getByRole("button", { name: "Attach image" }).parentElement; - expect(enterRow).toBe(escRow); // controls stay together - expect(slashRow).toBe(escRow); - expect(escRow?.classList.contains("overflow-x-auto")).toBe(true); - expect(imgRow).toBe(slashRow); - }); - it("shows no scroll buttons without handlers, and working ones with them", () => { const { unmount } = render( {}} />); expect(screen.queryByRole("button", { name: "Scroll to top" })).toBeNull(); @@ -559,21 +546,6 @@ describe("TerminalKeyBar", () => { expect(screen.getByRole("button", { name: "Send Escape" })).toBeDefined(); }); - it("keeps a compact bottom gutter in both modes", () => { - // Both modes use the same compact gutter without stacking safe-area padding. - for (const readOnly of [false, true]) { - render( {}} />); - const bar = readOnly - ? screen.getByRole("toolbar", { name: "Terminal scrolling" }) - : screen.getByRole("toolbar", { name: "Terminal special keys" }); - // The WHOLE pb-* set, not a substring hunt: a second pb-* utility - // wins by stylesheet order, not attribute order, and a full inset - // re-wrapped as `pb-[calc(env(...))]` would slip past a - // `pb-[env(` regex. - expect([...bar.classList].filter((c) => c.startsWith("pb-"))).toEqual(["pb-1"]); - } - }); - it("scroll buttons share the row with the image button", () => { render( el.textContent?.includes("Socket") ?? false; -const isBadge = (el: HTMLElement): boolean => el.textContent?.includes("⌨") ?? false; - -describe("TerminalOverlayStack layout (the HUD never moves; the badge yields; the badge lives only in the diagnostics state)", () => { - it("the HUD's top edge is identical with the badge empty and with a keystroke pending", () => { - // Badge empty: the HUD is the stack's only plate, at the anchor. - const empty = renderStack(true, false); - const emptyPlates = plates(empty.container); - expect(emptyPlates).toHaveLength(1); - expect(isHud(emptyPlates[0] as HTMLElement)).toBe(true); - cleanup(); - - // Badge at depth 1: the HUD is STILL the first plate, so its top edge is - // the anchor's, unchanged from the empty case. - const withPending = renderStack(true, true); - const pendingPlates = plates(withPending.container); - expect(pendingPlates).toHaveLength(2); - expect(isHud(pendingPlates[0] as HTMLElement)).toBe(true); - expect(pendingPlates[0]?.className).toBe(emptyPlates[0]?.className); - // The badge is the yielding plate: after the HUD, exactly one gap below. - expect(isBadge(pendingPlates[1] as HTMLElement)).toBe(true); - cleanup(); - }); - +describe("TerminalOverlayStack diagnostics visibility", () => { it("with diagnostics off the stack renders nothing, even with a keystroke pending", () => { // Issue #243: the badge is diagnostic text, so it exists only inside the // diagnostics state. Normal typing leaves this corner of the screen diff --git a/apps/server/web/src/components/__tests__/trust-notice-banner.test.tsx b/apps/server/web/src/components/__tests__/trust-notice-banner.test.tsx index 73ad89dcf..f61e6de60 100644 --- a/apps/server/web/src/components/__tests__/trust-notice-banner.test.tsx +++ b/apps/server/web/src/components/__tests__/trust-notice-banner.test.tsx @@ -1,6 +1,6 @@ import { afterEach, beforeEach, describe, expect, it } from "bun:test"; import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; -import { TrustNoticeBanner, VISIBLE_MS } from "@/components/trust-notice-banner"; +import { TrustNoticeBanner } from "@/components/trust-notice-banner"; import { noticeSeen, setTrustBannersEnabled } from "@/lib/trust-notice-prefs"; import type { TrustNotice } from "@/lib/trust-notices"; @@ -45,13 +45,6 @@ describe("TrustNoticeBanner", () => { // the budget is there to absorb starvation, not duration. }, 15_000); - // The dwell the app actually ships. The case above proves the retire path - // with a short one, so without this the 5 s product decision would be - // asserted nowhere and could be changed by accident. - it("dwells for five seconds by default", () => { - expect(VISIBLE_MS).toBe(5_000); - }); - it("retires when dismissed, well before the 5 s dwell, and remembers that", async () => { render(); fireEvent.click(screen.getByRole("button", { name: /got it/i })); diff --git a/apps/server/web/src/components/account/__tests__/new-account-fields.test.tsx b/apps/server/web/src/components/account/__tests__/new-account-fields.test.tsx index 282a9e34f..49552c09a 100644 --- a/apps/server/web/src/components/account/__tests__/new-account-fields.test.tsx +++ b/apps/server/web/src/components/account/__tests__/new-account-fields.test.tsx @@ -95,33 +95,6 @@ describe("normalizeNewAccount", () => { describe("NewAccountFields", () => { afterEach(cleanup); - it("renders the four labelled boxes setup has always asked for", () => { - renderFields(); - expect(screen.getByLabelText("Name")).toBeDefined(); - expect(screen.getByLabelText("E-mail")).toBeDefined(); - expect(screen.getByLabelText("Password")).toBeDefined(); - expect(screen.getByLabelText("Confirm password")).toBeDefined(); - }); - - it("states the password requirement quietly while the box is empty", () => { - renderFields(); - const line = screen.getByText(PASSWORD_REQUIREMENT); - expect(line.className).toContain("text-muted-foreground"); - expect(line.className).not.toContain("text-destructive"); - }); - - it("turns the requirement red once something too short has been typed", () => { - renderFields({ password: "abc" }); - const line = screen.getByText(PASSWORD_REQUIREMENT); - expect(line.className).toContain("text-destructive"); - }); - - it("goes quiet again once the password is long enough", () => { - renderFields({ password: FULL.password }); - const line = screen.getByText(PASSWORD_REQUIREMENT); - expect(line.className).toContain("text-muted-foreground"); - }); - it("describes the password box with the requirement line", () => { renderFields(); const box = screen.getByLabelText("Password"); diff --git a/apps/server/web/src/components/plugins/__tests__/installed-plugins-card.test.tsx b/apps/server/web/src/components/plugins/__tests__/installed-plugins-card.test.tsx index 7bf458439..d934c99d8 100644 --- a/apps/server/web/src/components/plugins/__tests__/installed-plugins-card.test.tsx +++ b/apps/server/web/src/components/plugins/__tests__/installed-plugins-card.test.tsx @@ -5,15 +5,8 @@ import { InstalledPluginsCard } from "@/components/plugins/installed-plugins-car import type { InstancePluginRow } from "@/hooks/use-instance-plugins"; /** - * The Installed list is a headerless table (2026-09-14): ONE grid for every - * row, each row `display: contents` so its cells are the grid's own children - * and the four columns share their tracks. The card used to render a bordered - * flex box per row, and `auto` sizes per container — so the badge, the switch - * and Uninstall landed at a different x in every row. - * - * These cases pin the two halves that a layout change can silently break: the - * full-width lines (description, the broken notice, a row's own error) still - * render, and both controls are still reachable by their accessible names. + * Keep management controls accessible to managers and absent for readers. + * Grid alignment belongs in browser review; Happy DOM cannot measure it. */ function row(over: Partial & { id: string; name: string }): InstancePluginRow { @@ -38,29 +31,6 @@ function renderCard(plugins: InstancePluginRow[], canManage = true) { afterEach(cleanup); describe("InstalledPluginsCard", () => { - it("renders every row's cells into ONE grid, so the columns align", () => { - const { container } = renderCard([ - row({ id: "pi", name: "Pi", version: "1.0.0" }), - row({ id: "claude-code", name: "Claude Code", version: "2.11.5", binary: "claude" }), - ]); - // Exactly one grid: a second one would size its own tracks, which is the - // ragged staircase this layout replaced. - const grids = container.querySelectorAll("div.grid"); - expect(grids).toHaveLength(1); - // And the rows draw nothing themselves — `contents` is what promotes - // their cells to the grid's own children. - const rows = (grids[0] as HTMLElement).querySelectorAll(":scope > div.contents"); - expect(rows).toHaveLength(2); - }); - - it("keeps the full-width lines: the description and the broken notice", () => { - renderCard([row({ id: "pi", name: "Pi", description: "Drives the Pi CLI", broken: "missing entry file" })]); - const description = screen.getByText("Drives the Pi CLI"); - expect(description.className).toContain("col-span-full"); - const broken = screen.getByText(/missing entry file/); - expect(broken.className).toContain("col-span-full"); - }); - it("both controls stay reachable by their accessible names", () => { renderCard([row({ id: "pi", name: "Pi" })]); expect(screen.getByRole("switch", { name: "Pi enabled" })).toBeDefined(); diff --git a/apps/server/web/src/components/presets/__tests__/preset-table-row.test.tsx b/apps/server/web/src/components/presets/__tests__/preset-table-row.test.tsx index 661025985..5db0a7e68 100644 --- a/apps/server/web/src/components/presets/__tests__/preset-table-row.test.tsx +++ b/apps/server/web/src/components/presets/__tests__/preset-table-row.test.tsx @@ -65,26 +65,12 @@ function renderRow(preset: PresetRow) { afterEach(cleanup); -it("shows an enabled preset's switch as on", () => { - const mock = mockFetch(); - renderRow(rowPreset({ enabled: 1 })); - expect(screen.getByRole("switch", { name: "Fast enabled" }).getAttribute("aria-checked")).toBe("true"); - mock.restore(); -}); - -it("shows a disabled preset's switch as off and dims the row", () => { - const mock = mockFetch(); - renderRow(rowPreset({ enabled: 0 })); - const switchEl = screen.getByRole("switch", { name: "Fast enabled" }); - expect(switchEl.getAttribute("aria-checked")).toBe("false"); - expect((switchEl.closest("tr") as HTMLElement).className).toContain("opacity-70"); - mock.restore(); -}); - it("patches the row enabled when the switch is flipped", async () => { const mock = mockFetch(); renderRow(rowPreset({ enabled: 0 })); - fireEvent.click(screen.getByRole("switch", { name: "Fast enabled" })); + const toggle = screen.getByRole("switch", { name: "Fast enabled" }); + expect(toggle.getAttribute("aria-checked")).toBe("false"); + fireEvent.click(toggle); await waitFor(() => { const patch = mock.calls.find((c) => c.method === "PATCH"); expect(patch).toBeTruthy(); @@ -97,7 +83,9 @@ it("patches the row enabled when the switch is flipped", async () => { it("flips an enabled preset off through the same PATCH", async () => { const mock = mockFetch(); renderRow(rowPreset({ enabled: 1 })); - fireEvent.click(screen.getByRole("switch", { name: "Fast enabled" })); + const toggle = screen.getByRole("switch", { name: "Fast enabled" }); + expect(toggle.getAttribute("aria-checked")).toBe("true"); + fireEvent.click(toggle); await waitFor(() => { expect(mock.calls.some((c) => c.method === "PATCH" && c.body === JSON.stringify({ enabled: false }))).toBe(true); }); diff --git a/apps/server/web/src/components/presets/__tests__/ssh-preset-library.test.tsx b/apps/server/web/src/components/presets/__tests__/ssh-preset-library.test.tsx index 9a51a0559..fa4147e80 100644 --- a/apps/server/web/src/components/presets/__tests__/ssh-preset-library.test.tsx +++ b/apps/server/web/src/components/presets/__tests__/ssh-preset-library.test.tsx @@ -84,56 +84,40 @@ afterEach(cleanup); const sshList = (calls: { method: string; url: string }[]) => calls.filter((c) => c.method === "GET" && c.url === "/api/presets?kind=ssh").length; -it("shows a disabled SSH preset's switch as off and dims the row", async () => { - const mock = mockFetch([sshPreset({ enabled: 0 })]); - await renderLibrary(); - const switchEl = await screen.findByRole("switch", { name: "Build box enabled" }); - expect(switchEl.getAttribute("aria-checked")).toBe("false"); - expect((switchEl.closest("tr") as HTMLElement).className).toContain("opacity-70"); - mock.restore(); -}); - it("flipping an enabled SSH preset off pins the PATCH route and body", async () => { const mock = mockFetch([sshPreset({ enabled: 1 })]); await renderLibrary(); - fireEvent.click(await screen.findByRole("switch", { name: "Build box enabled" })); + const toggle = await screen.findByRole("switch", { name: "Build box enabled" }); + expect(toggle.getAttribute("aria-checked")).toBe("true"); + fireEvent.click(toggle); await waitFor(() => { const patch = mock.calls.find((c) => c.method === "PATCH"); expect(patch).toBeTruthy(); expect(patch?.url).toBe("/api/presets/p1"); expect(patch?.body).toBe(JSON.stringify({ enabled: false })); }); + // The list read must follow the PATCH; an initial fetch alone proves no + // invalidation. Check it on the same toggle instead of mounting again. + await waitFor(() => { + const patchIndex = mock.calls.findIndex((c) => c.method === "PATCH"); + expect(sshList(mock.calls.slice(0, patchIndex))).toBeGreaterThan(0); + expect(sshList(mock.calls.slice(patchIndex + 1))).toBeGreaterThan(0); + }); mock.restore(); }); it("flipping a disabled SSH preset on PATCHes enabled true", async () => { const mock = mockFetch([sshPreset({ enabled: 0 })]); await renderLibrary(); - fireEvent.click(await screen.findByRole("switch", { name: "Build box enabled" })); + const toggle = await screen.findByRole("switch", { name: "Build box enabled" }); + expect(toggle.getAttribute("aria-checked")).toBe("false"); + fireEvent.click(toggle); await waitFor(() => { expect(mock.calls.some((c) => c.method === "PATCH" && c.body === JSON.stringify({ enabled: true }))).toBe(true); }); mock.restore(); }); -it("refetches the SSH list after a successful toggle", async () => { - const mock = mockFetch([sshPreset({ enabled: 1 })]); - await renderLibrary(); - fireEvent.click(await screen.findByRole("switch", { name: "Build box enabled" })); - await waitFor(() => expect(mock.calls.some((c) => c.method === "PATCH")).toBe(true)); - // ORDER, not a count: the GETs must bracket the PATCH, one answering the - // initial load before it and at least one landing after the invalidation. - // `>= 2` alone would pass on a mutation that never refetched. waitFor polls, - // so the async refetch is awaited rather than slept on. - await waitFor(() => { - const patchIndex = mock.calls.findIndex((c) => c.method === "PATCH"); - expect(patchIndex).toBeGreaterThanOrEqual(0); - expect(sshList(mock.calls.slice(0, patchIndex)) >= 1).toBe(true); - expect(sshList(mock.calls.slice(patchIndex + 1)) >= 1).toBe(true); - }); - mock.restore(); -}); - it("gives a view-only SSH viewer a plain yes instead of a switch", async () => { const mock = mockFetch([sshPreset({ access: "view", enabled: 1 })]); await renderLibrary(); diff --git a/apps/server/web/src/components/sidebar/__tests__/rail-subshells.test.tsx b/apps/server/web/src/components/sidebar/__tests__/rail-subshells.test.tsx index 9c85fd55c..aa9fcb0b0 100644 --- a/apps/server/web/src/components/sidebar/__tests__/rail-subshells.test.tsx +++ b/apps/server/web/src/components/sidebar/__tests__/rail-subshells.test.tsx @@ -237,17 +237,6 @@ describe("the rail view control", () => { }); }); - it("the mode toggle renders dense buttons (operator: half the button padding)", async () => { - await withRail([sub({ id: "a", name: "one" })], () => { - // The rail's view switch opts into Segmented's `dense` (h-6, the cell - // grid's own rhythm) — the vertical saving is the buttons' air, not the - // gap to the filter box (operator correction 2026-09-25). - for (const name of ["Row view", "Cell view", "Flat cell view"]) { - expect(screen.getByRole("button", { name }).className).toContain("h-6"); - } - }); - }); - it("every mode button renders its icon — DOM, not props (operator: blank pills live)", async () => { await withRail([sub({ id: "a", name: "one" })], () => { // Regression pin: composing the option tooltip through Base UI's diff --git a/apps/server/web/src/components/sidebar/__tests__/subshell-cell-grid.test.tsx b/apps/server/web/src/components/sidebar/__tests__/subshell-cell-grid.test.tsx index f3973ede1..2854d5d67 100644 --- a/apps/server/web/src/components/sidebar/__tests__/subshell-cell-grid.test.tsx +++ b/apps/server/web/src/components/sidebar/__tests__/subshell-cell-grid.test.tsx @@ -9,13 +9,7 @@ import { } from "@tanstack/react-router"; import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; import type { ReactNode } from "react"; -import { - flatCellRows, - nodeTintBucket, - paneInitial, - SubshellCell, - SubshellCellGrid, -} from "@/components/sidebar/subshell-cell-grid"; +import { flatCellRows, paneInitial, SubshellCell, SubshellCellGrid } from "@/components/sidebar/subshell-cell-grid"; import type { SubshellNodeGroup } from "@/lib/subshell-node-groups"; import { groupSubshellsByNode } from "@/lib/subshell-node-groups"; import { onWorkspacePaneFocusRequest, takePaneFocusOnArrive } from "@/lib/workspace-focus"; @@ -138,56 +132,11 @@ describe("paneInitial (the flat grid's glyph)", () => { expect(paneInitial("beta")).toBe("B"); }); - it("is a hint, not a key: two panes sharing a first letter read identically, by design", () => { - // The collision rule the machine-letter version earned and the single - // initial inherits harder (many panes start alike): the tooltip is the - // truth. Pinned so nobody "fixes" this back into per-grid state. - expect(paneInitial("mac-build")).toBe(paneInitial("mcp-debug")); - }); - it("answers the empty string for an empty name rather than crashing", () => { expect(paneInitial("")).toBe(""); }); }); -describe("nodeTintBucket (the flat grid's machine plate)", () => { - it("is deterministic — the same name never moves between calls", () => { - expect(nodeTintBucket("mac-mini")).toBe(nodeTintBucket("mac-mini")); - const once = nodeTintBucket("Server"); - for (let i = 0; i < 25; i++) expect(nodeTintBucket("Server")).toBe(once); - }); - - it("stays inside the eight-bucket palette for any name", () => { - const names = ["Server", "mac-mini", "mbp", "", "x", "studio-display", "gcp-a", "gcp-b", "π-π-π", "dev-box-9"]; - for (const name of names) { - const bucket = nodeTintBucket(name); - expect(Number.isInteger(bucket)).toBe(true); - expect(bucket).toBeGreaterThanOrEqual(0); - expect(bucket).toBeLessThan(8); - } - }); - - it("spreads realistic labels across several buckets", () => { - const names = Array.from({ length: 24 }, (_, i) => `node-${i}`); - const buckets = new Set(names.map(nodeTintBucket)); - expect(buckets.size).toBeGreaterThanOrEqual(4); - }); - - it("keys on the name alone: two different names may share a bucket or differ", () => { - // The hash is pure over the label — an admin's rename moves a machine's - // tint when the new name hashes elsewhere (documented in the function), - // and distinct names are never promised distinct buckets (hint-not-key, - // same rule as the glyph). No rename LOGIC exists to test, so this pins - // the input space instead of inventing one. - const seen = new Map(); - for (const name of ["Server", "mac-mini", "mbp", "work-laptop", "studio-mini"]) { - seen.set(name, nodeTintBucket(name)); - } - // Same second pass, same answers — purity, not just determinism. - for (const [name, bucket] of seen) expect(nodeTintBucket(name)).toBe(bucket); - }); -}); - describe("flatCellRows (one grid, same set as grouped mode)", () => { const rows = [ sub({ id: "w", nodeId: "n1", activity: "idle", waitingSince: "2026-09-25T00:00:00.000Z" }), @@ -281,65 +230,9 @@ describe("SubshellCell", () => { } }); - it("wears the dot's own fill for its state, and the focused one gets the ring", async () => { - const restore = mockFetch(); - try { - renderCell( - , - ); - const link = await screen.findByRole("link"); - expect(link.className).toContain("bg-destructive"); - // The focused pane's mark is the full-white ring at width 1 (operator - // ruling 2026-09-27, the fifth and final form that day: /70 over the - // dark border read as nothing, ring-2 read heavy, a focus bead read as - // a status dot, both orchids failed against the green fills, and a - // SET-wide ring was rejected outright — "ALL items have a white - // border". Two levels of one white, focus alone at full). A ringed cell - // wears no frame under it. Asserted as bare TOKENS: the keyboard focus - // variant is `focus-visible:ring-2`/`ring-ring` and hover is - // `hover:ring-1`/`hover:ring-foreground`, all prefixed, so a bare - // `ring-1` or `ring-foreground` can only be the focus ring. - const toks = link.className.split(/\s+/); - expect(toks).toContain("ring-1"); - expect(toks).not.toContain("ring-2"); - expect(toks).toContain("ring-foreground"); - expect(toks).not.toContain("ring-primary"); - expect(toks).toContain("border-0"); - expect(toks).not.toContain("border-foreground/25"); - expect(link.className).toContain("focus-visible:ring-ring"); - // Hover answers "which one am I on" with the same white at full - // brightness (operator ask: cells need a hover outline). - expect(link.className).toContain("hover:ring-1"); - expect(link.className).toContain("hover:ring-foreground"); - } finally { - restore(); - } - }); - // The mark is ONE crisp line: a terminated cell's frame (its whole state // fill) must yield to the ring, or the focused and unfocused cells are two // faint frames apart. - it("a ringed terminated cell drops its hollow frame", async () => { - const restore = mockFetch(); - try { - renderCell( - , - ); - const toks = (await screen.findByRole("link")).className.split(/\s+/); - expect(toks).toContain("border-0"); - expect(toks).toContain("ring-foreground"); - } finally { - restore(); - } - }); it("a plain left-click means the tab: focus here, else go to its workspace (spec 2026-10-17)", async () => { const restore = mockFetch(); @@ -441,164 +334,6 @@ describe("SubshellCell", () => { } }); - it("the working cell pulses its fill only: no solid-green beat, one white initial (operator ruling 2026-09-27)", async () => { - const restore = mockFetch(); - try { - renderCell( - , - ); - const link = await screen.findByRole("link"); - // The blink lives on the FILL alone: a half-green (`bg-success/50`, the - // idle fill) tile pulsing on the plain `subshell-dot-blink` over the - // bare rail, with a constant white initial above it. The retired shape - // blinked full-green + dark-knockout over half-green + white so no beat - // lacked a box; the operator killed the solid-green beat the same day - // (transparent background, white letter). The anchor carries posture - // only: relative, transparent, NO fill and NO pulse of its own. - const has = (el: Element | undefined, tok: string) => !!el && el.className.split(/\s+/).includes(tok); - expect(link.className).toContain("relative"); - expect(link.className).toContain("bg-transparent"); - // Exact tokens: a solid `bg-success` (the retired ACTIVE fill) must be - // gone while `bg-success/50` is its own token and still present. - const linkToks = link.className.split(/\s+/); - expect(linkToks).not.toContain("bg-success"); - expect(linkToks).not.toContain("subshell-dot-blink"); - expect(link.className).not.toContain("border-success"); - - const spans = [...link.querySelectorAll("span")]; - expect(spans.find((s) => s.className.includes("bg-muted"))).toBeUndefined(); - - // The FILL: half green, pulsing, aria-hidden, and it carries NO letter - // (the retired `blink-alt` complement and the dark knockout are gone - // with the solid-green beat). - const fill = spans.find((s) => has(s, "subshell-dot-blink")); - expect(fill).toBeTruthy(); - expect(has(fill, "bg-success/50")).toBe(true); - expect(spans.filter((s) => s.className.includes("subshell-dot-blink"))).toHaveLength(1); - expect(spans.find((s) => s.className.includes("blink-alt"))).toBeUndefined(); - expect(has(fill, "absolute") && has(fill, "inset-0") && has(fill, "rounded-md")).toBe(true); - expect(fill?.getAttribute("aria-hidden")).toBe("true"); - expect(fill?.textContent).toBe(""); - - // The LETTER: exactly one, white, positioned above the fill. - expect(link.textContent).toBe("A"); - const letter = fill?.nextElementSibling ?? undefined; - expect(has(letter, "relative")).toBe(true); - expect(has(letter, "text-foreground")).toBe(true); - expect(has(letter, "text-background")).toBe(false); - expect(letter?.textContent).toBe("A"); - // The viewed pane still rings on the anchor: the set's full white, - // width 1. The ring IS the frame here, so no border rides along with it. - expect(has(link, "ring-1")).toBe(true); - expect(has(link, "ring-foreground")).toBe(true); - expect(has(link, "ring-2")).toBe(false); - expect(has(link, "border-border")).toBe(false); - expect(has(link, "border-foreground/25")).toBe(false); - } finally { - restore(); - } - }); - - it("an un-ringed working cell keeps the dark frame the fill blinks inside (operator 2026-09-27)", async () => { - const restore = mockFetch(); - try { - renderCell( - , - ); - const link = await screen.findByRole("link"); - const has2 = (el: Element | undefined, tok: string) => !!el && el.className.split(/\s+/).includes(tok); - // The transparent half must not be a letter floating on bare rail: the - // anchor carries a static `--border` frame the fill pulses inside. Only - // where there is no ring — a focused cell's frame is the ring. - expect(has2(link, "border")).toBe(true); - expect(has2(link, "border-foreground/25")).toBe(true); - expect(has2(link, "ring-foreground")).toBe(false); - expect(has2(link, "ring-primary")).toBe(false); - expect(has2(link, "ring-1")).toBe(false); - // And it does NOT blink: the frame is on the anchor, the pulse stays on - // the fill child. - expect(has2(link, "subshell-dot-blink")).toBe(false); - } finally { - restore(); - } - }); - - it("non-blinking states render exactly as before: fill on the anchor, no plate", async () => { - const restore = mockFetch(); - try { - renderCell( - <> - - - , - ); - const links = await screen.findAllByRole("link"); - const [idle, ended] = links.map((l) => l as HTMLElement); - if (!idle || !ended) throw new Error("fixture broke"); - expect(idle.className).toContain("bg-success/50"); - expect(idle.className).not.toContain("bg-muted"); - expect(idle.querySelectorAll("span")).toHaveLength(0); - // `terminated` stays a hollow ring: border + nothing behind it. - expect(ended.className).toContain("border-foreground/25"); - expect(ended.className).not.toContain("bg-muted"); - expect(ended.querySelectorAll("span")).toHaveLength(0); - } finally { - restore(); - } - }); - - it("letter color per state: dark on the two solid fills, white elsewhere (delegated ruling 2026-09-27)", async () => { - const restore = mockFetch(); - try { - renderCell( - <> - - - - , - ); - await screen.findAllByRole("link"); - // Scope to each cell's own link (a tooltip wrapper span elsewhere in the - // document carries no color class and would collide with a document-wide - // query) and select the letter by the color utility it must wear. - const linkFor = (t: string) => (screen.getAllByRole("link") as HTMLElement[]).find((l) => l.textContent === t); - // `waiting` (bg-warning) and `node-offline` (bg-destructive) defeat white - // (1.34:1 / 2.77:1): a dark knockout. - expect(linkFor("W")?.querySelector("span.text-background")).not.toBeNull(); - expect(linkFor("W")?.querySelector("span.text-foreground")).toBeNull(); - expect(linkFor("O")?.querySelector("span.text-background")).not.toBeNull(); - // `idle` (bg-success/50) keeps white (4.34:1). - expect(linkFor("I")?.querySelector("span.text-foreground")).not.toBeNull(); - expect(linkFor("I")?.querySelector("span.text-background")).toBeNull(); - } finally { - restore(); - } - }); - it("shows the bell for an owner's unseen push, not the fill or an initial", async () => { const restore = mockFetch(); try { @@ -724,117 +459,6 @@ describe("SubshellCellGrid", () => { } }); - it("plates runs of the same machine like a button group: one plate per run", async () => { - const restore = mockFetch(); - try { - // Two mac-mini cells then one mbp cell — the order flatCellRows - // guarantees (contiguous clusters), so grouping here is by CONSECUTIVE - // equal tint: same-machine run → ONE shared plate; the machine change - // starts a new one. Tints stubbed 0/1 so the run boundaries are the - // fixture's, not hash luck. - const rows = [ - sub({ id: "a", name: "one", nodeId: "n1" }), - sub({ id: "b", name: "two", nodeId: "n1" }), - sub({ id: "c", name: "three", nodeId: "n2" }), - ]; - renderCell( - ({ nodeLabel: "m", agentLabel: "A" })} - tintOf={(s) => (s.nodeId === "n1" ? 0 : 1)} - />, - ); - const links = await screen.findAllByRole("link"); - const plateA = links[0]?.closest("[class*='bg-node-tint']"); - expect(plateA).not.toBeNull(); - // The shared plate holds BOTH n1 cells, and nothing else. - expect(plateA?.querySelectorAll("a")).toHaveLength(2); - expect(links[1]?.closest("[class*='bg-node-tint']")).toBe(plateA); - const plateC = links[2]?.closest("[class*='bg-node-tint']"); - expect(plateC).not.toBeNull(); - expect(plateC).not.toBe(plateA); - expect(plateC?.querySelectorAll("a")).toHaveLength(1); - // Two DIFFERENT tint runs must not sit inside one plate. - expect(plateA?.className).not.toBe(plateC?.className); - // Grouped-button shape: outer rounding, 6px inner rhythm matching the - // GROUPED view's cell-to-cell exactly (operator: "we should have the - // same gap as we do in the grouped view"), and a 4px tint gutter on ALL - // sides — the selection ring paints OUTSIDE the cell box, so p-0.5 left - // it touching the plate edge (p-1 clears the ring-1 selection on every side). - expect(plateA?.className).toContain("p-1"); - expect(plateA?.className).toContain("gap-1.5"); - expect(plateA?.className).toContain("rounded-lg"); - // Outer rhythm between plates: the SAME 6px as the grouped grid's cells - // — plate separation reads larger than it is because each plate adds - // its own 4px gutter (6 + 4 + 4 = 14px cell-to-cell across a boundary). - expect(plateA?.parentElement?.className).toContain("gap-1.5"); - // The tinted grid's own inset is px-2, NOT the px-3 the plain grid - // uses: a plate carries a 4px gutter, so px-3 would land the first - // cell 16px in while headers and grouped cells sit at 12. Two insets, - // one visual line (operator live review of the flat grid). - expect(plateA?.parentElement?.className).toContain("px-2"); - // A plate is layout, not semantics: no aria-hidden subtree (that would - // hide the links), no interactive content beyond the cells. - expect(plateA?.getAttribute("aria-hidden")).toBeNull(); - } finally { - restore(); - } - }); - - it("a single-cell machine is just a plate with one cell — no special casing", async () => { - const restore = mockFetch(); - try { - renderCell( - ({ nodeLabel: "mac-mini", agentLabel: "A" })} - tintOf={(s) => nodeTintBucket(s.nodeId ?? "")} - />, - ); - const link = await screen.findByRole("link"); - const plate = link.closest("[class*='bg-node-tint']"); - expect(plate).not.toBeNull(); - expect(plate?.querySelectorAll("a")).toHaveLength(1); - } finally { - restore(); - } - }); - - it("rings the focused pane alone; every other cell wears the dim frame", async () => { - const restore = mockFetch(); - try { - const rows = [sub({ id: "a", name: "one" }), sub({ id: "b", name: "two" }), sub({ id: "c", name: "three" })]; - renderCell( - ({ nodeLabel: "m", agentLabel: "A" })} />, - ); - await waitFor(() => expect(screen.getAllByRole("link")).toHaveLength(3)); - const toks = (id: string) => - ( - (screen.getAllByRole("link") as HTMLElement[]).find((l) => l.getAttribute("href") === `/subshells/${id}`) - ?.className ?? "" - ).split(/\s+/); - // (operator 2026-09-27, final form): the focused pane alone wears the - // full-white ring — open-but-unfocused `a` does NOT, which is exactly - // the ruling that killed the set-wide ring ("when in a workspace, ALL - // items have a white border"). Every other cell, `a` and `b` alike, - // wears the DIM white frame. Bare tokens: hover/focus add only - // prefixed variants. - expect(toks("c")).toContain("ring-1"); - expect(toks("c")).toContain("ring-foreground"); - expect(toks("c")).toContain("border-0"); - expect(toks("c")).not.toContain("ring-2"); - for (const id of ["a", "b"]) { - expect(toks(id)).not.toContain("ring-1"); - expect(toks(id)).not.toContain("ring-foreground"); - expect(toks(id)).toContain("border-foreground/25"); - } - } finally { - restore(); - } - }); - it("renders a flat grid where a Needs-Attention row shows in its band AND its cluster (a duplicated id) without a key collision", async () => { const restore = mockFetch(); try { diff --git a/apps/server/web/src/components/sidebar/__tests__/tooltip-labelled-lines.test.tsx b/apps/server/web/src/components/sidebar/__tests__/tooltip-labelled-lines.test.tsx index b886e8988..cc0b0d36d 100644 --- a/apps/server/web/src/components/sidebar/__tests__/tooltip-labelled-lines.test.tsx +++ b/apps/server/web/src/components/sidebar/__tests__/tooltip-labelled-lines.test.tsx @@ -13,16 +13,6 @@ afterEach(cleanup); * not recognize is kept, never dropped. */ describe("TooltipLabelledLines", () => { - it("bolds our label constants at the first colon-space", () => { - const { container } = render(); - const lines = Array.from(container.children); - expect(lines).toHaveLength(2); - const firstLabel = lines[0]?.querySelector("span span"); - expect(firstLabel?.textContent).toBe("Name:"); - expect(firstLabel?.className).toContain("font-strong"); - expect(lines[0]?.textContent).toBe("Name: auth-refactor"); - }); - it("splits only at the FIRST colon-space, so values keep theirs", () => { const { container } = render(); const label = container.querySelector("span span"); diff --git a/apps/server/web/src/components/sidebar/__tests__/workspace-drop-create.test.tsx b/apps/server/web/src/components/sidebar/__tests__/workspace-drop-create.test.tsx index 2cd9e1b2e..bcaa35aaf 100644 --- a/apps/server/web/src/components/sidebar/__tests__/workspace-drop-create.test.tsx +++ b/apps/server/web/src/components/sidebar/__tests__/workspace-drop-create.test.tsx @@ -161,20 +161,6 @@ afterEach(() => { resetWorkspaceFocusForTests(); }); -test("the empty zone shows nothing at rest: no border affordance before a drag", async () => { - const fx = rig(); - const { container } = await mount(fx); - // Operator ruling 2026-10-20: the target reveals itself only while a - // subshell drag is over it, so the resting container wears no border and - // no highlight classes at all. - await screen.findByText("Select a workspace or drag a subshell here to get started."); - const zone = zoneOf(container) as HTMLElement; - expect(zone.className.includes("border")).toBe(false); - expect(zone.className.includes("rounded")).toBe(false); - // Nothing has been dragged, so the hover overlay is still absent. - expect(within(zone).queryByRole("status") === null).toBe(true); -}); - test("a subshell drag in flight dresses the zone with a dashed ring, and dragend strips it", async () => { const fx = rig(); const { container } = await mount(fx); diff --git a/apps/server/web/src/components/subshell-picker/__tests__/new-subshell-form.test.tsx b/apps/server/web/src/components/subshell-picker/__tests__/new-subshell-form.test.tsx index b64ce062d..008d75ea4 100644 --- a/apps/server/web/src/components/subshell-picker/__tests__/new-subshell-form.test.tsx +++ b/apps/server/web/src/components/subshell-picker/__tests__/new-subshell-form.test.tsx @@ -111,29 +111,6 @@ const dir = () => screen.getByLabelText("Working directory") as HTMLInputElement describe("NewSubshellForm working-dir pre-fill", () => { afterEach(cleanup); - it("fills an empty working dir with the most recent path", async () => { - const restore = mockEndpoints([ - { path: "/srv/app", label: "app" }, - { path: "/srv/older", label: null }, - ]); - try { - await renderForm(emptyNewSubshellForm()); - await waitFor(() => expect(dir().value).toBe("/srv/app")); - } finally { - restore(); - } - }); - - it("never overwrites a working dir the caller already set", async () => { - const restore = mockEndpoints([{ path: "/srv/app", label: null }]); - try { - await renderForm({ ...emptyNewSubshellForm(), workingDir: "/keep/me" }); - expect(dir().value).toBe("/keep/me"); - } finally { - restore(); - } - }); - it("stays empty when the user has no history yet", async () => { const restore = mockEndpoints([]); try { diff --git a/apps/server/web/src/components/ui/__tests__/dropdown-menu.test.tsx b/apps/server/web/src/components/ui/__tests__/dropdown-menu.test.tsx deleted file mode 100644 index 02a03533e..000000000 --- a/apps/server/web/src/components/ui/__tests__/dropdown-menu.test.tsx +++ /dev/null @@ -1,44 +0,0 @@ -import { afterEach, describe, expect, it } from "bun:test"; -import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu"; - -/** - * The popup must clamp to the viewport's available height and scroll within - * it, the way the Select and Combobox popups do. A menu that grows past the - * screen gets clipped, not scrollable — items below the fold are unreachable, - * and a box taller than the viewport can't even flip to the other side (the - * library only avoids collision when the box CAN fit). The Radix→Base UI - * migration is how this clamp was once lost; this pin is what keeps the next - * one from losing it again. The subshell actions menu is the surface that - * showed the cut-off, but the fix and the pin live on the shared wrapper. - */ -describe("DropdownMenuContent height clamp", () => { - afterEach(cleanup); - - it("clamps the popup to the available height and lets it scroll", async () => { - render( - - Open - - One - Two - - , - ); - // Base UI triggers open on pointerdown, which happy-dom cannot emulate; - // the keyboard path is equivalent (same posture as subshell-actions-menu). - fireEvent.keyDown(screen.getByRole("button", { name: "Open" }), { key: "ArrowDown" }); - // Boolean asserts in polls: expect(element).toBeNull() makes bun serialize - // the whole DOM graph on a failed attempt and can wedge the update the - // poll is waiting for (web AGENTS.md). - await waitFor(() => expect(document.querySelector('[data-slot="menu-content"]') !== null).toBe(true)); - const popup = document.querySelector('[data-slot="menu-content"]'); - expect(popup?.className.includes("max-h-[var(--available-height)]")).toBe(true); - expect(popup?.className.includes("overflow-y-auto")).toBe(true); - }); -}); diff --git a/apps/server/web/src/components/ui/__tests__/tooltip.test.tsx b/apps/server/web/src/components/ui/__tests__/tooltip.test.tsx index a79fb36bd..4929b7df5 100644 --- a/apps/server/web/src/components/ui/__tests__/tooltip.test.tsx +++ b/apps/server/web/src/components/ui/__tests__/tooltip.test.tsx @@ -37,20 +37,6 @@ describe("ui/tooltip", () => { expect(screen.getByText("hover me").hasAttribute("data-base-ui-tooltip-trigger")).toBe(true); }); - it("caller classes merge onto the popup, so multi-line content can ask for pre-line", async () => { - render( - - - t - {"Name: x\nNode: y"} - - , - ); - const popup = await screen.findByText(/Name: x/); - expect(popup.closest(".whitespace-pre-line")).not.toBeNull(); - expect(popup.textContent).toContain("Node: y"); - }); - it("draws an arrow by default (operator rule, 2026-09-25)", async () => { render( diff --git a/apps/server/web/src/components/users/__tests__/user-row-actions.test.tsx b/apps/server/web/src/components/users/__tests__/user-row-actions.test.tsx index 5c865901c..75d5374df 100644 --- a/apps/server/web/src/components/users/__tests__/user-row-actions.test.tsx +++ b/apps/server/web/src/components/users/__tests__/user-row-actions.test.tsx @@ -81,11 +81,6 @@ async function pick(item: string, email?: string): Promise { afterEach(cleanup); describe("UserRowActions", () => { - it("offers one action menu naming the user it belongs to", () => { - renderRow({ email: "dana@example.com" }); - expect(trigger("dana@example.com")).toBeDefined(); - }); - it("lists the flip named by the TARGET role, spelled like the badge's word", async () => { // "Promote to admin" / "Demote to user" — the word comes from // USER_ROLE_LABELS lowercased, so menu and Role column can never spell a diff --git a/apps/server/web/src/hooks/__tests__/use-harness-subagents.test.tsx b/apps/server/web/src/hooks/__tests__/use-harness-subagents.test.tsx index cdc984eba..902f35728 100644 --- a/apps/server/web/src/hooks/__tests__/use-harness-subagents.test.tsx +++ b/apps/server/web/src/hooks/__tests__/use-harness-subagents.test.tsx @@ -1,4 +1,4 @@ -import { afterEach, expect, test } from "bun:test"; +import { afterEach, expect, spyOn, test } from "bun:test"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { act, cleanup, render } from "@testing-library/react"; import { setFetchRouter } from "@/test-setup"; @@ -16,11 +16,6 @@ function Probe({ id, enabled }: { id: string; enabled: boolean }) { return {data ? "ready" : "waiting"}; } -/** - * The budget here rides the REAL poll interval (2 s), not faked timers: each - * two-settle window straddles exactly the ticks that should fire, with 600 ms - * of slack per side. `bun test`'s 5 s default is outgrown on purpose. - */ test("one batch fetch per tick serves every watching row, and hidden tabs stop watching", async () => { let gets = 0; setFetchRouter(async (url) => { @@ -28,37 +23,84 @@ test("one batch fetch per tick serves every watching row, and hidden tabs stop w return Response.json({}); }); const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); - render( - - - - - - , - ); - // The window sleeps inside `act`, so refetch-driven observer notifications - // land in a wrapped flush instead of the "not wrapped in act" warning, and - // a rejection surfaces at its await rather than as an unhandled error. - const settle = () => act(async () => new Promise((resolve) => setTimeout(resolve, 2600))); - // The mount fetch plus the two ticks inside the window: three active - // observers collapsed to one request each, and the inactive row - // registered nothing extra. - await settle(); - await settle(); - expect(gets, `gets=${gets}`).toBe(3); - // A backgrounded tab pauses the ticker, which is the whole point of the - // shared interval being ours rather than TanStack's per-observer - // `refetchInterval`. - Object.defineProperty(document, "visibilityState", { configurable: true, get: () => "hidden" }); - await settle(); - await settle(); - expect(gets, `gets=${gets}`).toBe(3); - Object.defineProperty(document, "visibilityState", { configurable: true, get: () => "visible" }); - await settle(); - expect(gets, `gets=${gets}`).toBe(4); - await act(async () => cleanup()); - await settle(); - // With no watchers left the interval is gone, not merely skipped. - expect(gets, `gets=${gets}`).toBe(4); - client.clear(); -}, 30_000); + const visibility = Object.getOwnPropertyDescriptor(document, "visibilityState"); + const show = (state: string) => + Object.defineProperty(document, "visibilityState", { configurable: true, get: () => state }); + const realSetInterval = globalThis.setInterval; + const realClearInterval = globalThis.clearInterval; + const callbacks = new Map, () => void>(); + // Drive only interval ticks. Fetch promises and React Query notifications + // still run normally; waiting through production polling adds no coverage. + const schedule = spyOn(globalThis, "setInterval").mockImplementation((( + callback: TimerHandler, + _delay?: number, + ...args: unknown[] + ) => { + if (typeof callback !== "function") throw new Error("Expected an interval callback"); + const timer = realSetInterval(() => {}, 86_400_000); + callbacks.set(timer, () => callback(...args)); + return timer; + }) as unknown as typeof setInterval); + const cancel = spyOn(globalThis, "clearInterval").mockImplementation((timer) => { + if (timer !== undefined) callbacks.delete(timer as ReturnType); + realClearInterval(timer); + }); + const settled = () => + act(async () => { + await Promise.all( + client + .getQueryCache() + .getAll() + .map((query) => query.promise), + ); + }); + const tick = async () => { + await act(async () => { + for (const callback of [...callbacks.values()]) callback(); + }); + await settled(); + }; + try { + show("visible"); + const { rerender } = render( + + + + + + , + ); + await settled(); + expect(gets).toBe(1); + expect(callbacks.size).toBe(1); + await tick(); + await tick(); + expect(gets).toBe(3); + + show("hidden"); + await tick(); + await tick(); + expect(gets).toBe(3); + show("visible"); + await tick(); + expect(gets).toBe(4); + + // An inactive observer must not keep the shared ticker alive. + rerender( + + + , + ); + expect(callbacks.size).toBe(0); + await tick(); + expect(gets).toBe(4); + } finally { + cleanup(); + for (const timer of callbacks.keys()) realClearInterval(timer); + cancel.mockRestore(); + schedule.mockRestore(); + client.clear(); + if (visibility) Object.defineProperty(document, "visibilityState", visibility); + else Reflect.deleteProperty(document, "visibilityState"); + } +}); diff --git a/apps/server/web/src/lib/__tests__/build-id.test.ts b/apps/server/web/src/lib/__tests__/build-id.test.ts index 62aa82f28..b971379cf 100644 --- a/apps/server/web/src/lib/__tests__/build-id.test.ts +++ b/apps/server/web/src/lib/__tests__/build-id.test.ts @@ -26,10 +26,4 @@ describe("buildIdFrom", () => { expect(buildIdFrom("http://localhost:5174/src/lib/api.ts")).toBe("dev"); expect(buildIdFrom("")).toBe("dev"); }); - - it("a different build reports a different id — what makes a reload visible", () => { - expect(buildIdFrom("https://x/assets/index-AAAAAAAA.js")).not.toBe( - buildIdFrom("https://x/assets/index-BBBBBBBB.js"), - ); - }); }); diff --git a/apps/server/web/src/lib/__tests__/create-subshell.test.ts b/apps/server/web/src/lib/__tests__/create-subshell.test.ts index 8aaa013a6..28c36ab55 100644 --- a/apps/server/web/src/lib/__tests__/create-subshell.test.ts +++ b/apps/server/web/src/lib/__tests__/create-subshell.test.ts @@ -97,11 +97,6 @@ describe("toSubshellCreateBody", () => { expect(body.prompt).toBe("first\n\nsecond"); }); - it("a presetless empty stack still sends NO prompt field (untouched form, no fallback to fear)", () => { - const body = toSubshellCreateBody({ harnessId: "p1", workingDir: "/tmp/x", name: "n" }); - expect(JSON.parse(JSON.stringify(body))).toEqual({ harnessId: "p1", workingDir: "/tmp/x", name: "n" }); - }); - it("a presetless launch sends NO presetId — null is absence, not a field (spec §2.2)", () => { for (const presetId of [null, undefined] as const) { const body = toSubshellCreateBody({ harnessId: "p1", presetId, workingDir: "/tmp/x", name: "n" }); diff --git a/apps/server/web/src/lib/__tests__/replay-alignment.test.ts b/apps/server/web/src/lib/__tests__/replay-alignment.test.ts index 7365625e1..2b13f76bc 100644 --- a/apps/server/web/src/lib/__tests__/replay-alignment.test.ts +++ b/apps/server/web/src/lib/__tests__/replay-alignment.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "bun:test"; import { Terminal } from "@xterm/xterm"; +import { captureToReplayText } from "../../../../api/src/ws/capture-text.js"; /** * The CLIENT half of the attach-replay contract (2026-09-04 garble). @@ -15,10 +16,8 @@ import { Terminal } from "@xterm/xterm"; * the new frame, and once those rows scroll into scrollback nothing repaints * them. * - * The frame format is the server's `captureToReplayText` (apps/server/api - * ws/capture-text.ts); it is reproduced here rather than imported because this - * asserts the CLIENT's response to the wire format, and the wire is the - * contract between them. Keep the two in step. + * Feed the production serializer's output into real xterm. A test-local copy + * of the frame builder cannot catch a regression in the server's output. */ /** How the client writes an attach: reset, then the replay frame verbatim. */ @@ -45,17 +44,10 @@ function paneRows(rows: string[], height: number): string[] { * rather than bare LF is load-bearing on its own — LF keeps the column, which * staircases each row right by the previous row's length. */ -function replayFrame(rows: string[], cursor?: { x: number; y: number }): string { - return rows.join("\r\n") + (cursor ? CUP(cursor) : ""); +function replayFrame(rows: string[], cursor: { x: number; y: number }): string { + return captureToReplayText(`${rows.join("\n")}\n`, cursor); } -/** The shipped-and-reported form: the same frame with the final terminator kept. */ -function replayFrameWithTrailingTerminator(rows: string[], cursor: { x: number; y: number }): string { - return `${rows.join("\r\n")}\r\n${CUP(cursor)}`; -} - -const CUP = (cursor: { x: number; y: number }) => `\x1b[${cursor.y + 1};${cursor.x + 1}H`; - describe("attach replay leaves the client aligned with the pane", () => { const ROWS = 10; const COLS = 40; @@ -93,18 +85,4 @@ describe("attach replay leaves the client aligned with the pane", () => { expect(buf.getLine(1)?.translateToString(true)).toBe("fresh2"); expect(buf.baseY).toBe(0); // still no scroll: nothing was pushed away }); - - it("REGRESSION: keeping the capture's trailing terminator scrolls the viewport out of step", async () => { - // The shipped-and-reported form. tmux terminates the LAST row too, so the - // cursor goes one row past the grid, which at the bottom margin scrolls — - // and every viewport-relative coordinate is then off by one. - const term = await writeReplay(replayFrameWithTrailingTerminator(PANE, PANE_CURSOR), COLS, ROWS); - const buf = term.buffer.active; - - expect(buf.baseY).toBe(1); // the pane's first row was pushed into scrollback - expect(buf.length).toBe(ROWS + 1); - // The CUP asked for pane row 2. It landed on pane row 3 — one row of drift - // per attach, which is the whole bug. - expect(buf.cursorY + buf.baseY).toBe(PANE_CURSOR.y + 1); - }); }); diff --git a/apps/server/web/src/lib/__tests__/sidebar-rail-view-pref.test.ts b/apps/server/web/src/lib/__tests__/sidebar-rail-view-pref.test.ts index 2602adcaf..0656f0dee 100644 --- a/apps/server/web/src/lib/__tests__/sidebar-rail-view-pref.test.ts +++ b/apps/server/web/src/lib/__tests__/sidebar-rail-view-pref.test.ts @@ -32,8 +32,4 @@ describe("rail subshells view (per-device preference)", () => { localStorage.setItem(KEY, "grid"); expect(railSubshellsView()).toBe("rows"); }); - - it("exposes the runtime list so a writer and the type cannot drift", () => { - expect(RAIL_SUBSHELLS_VIEWS).toEqual(["rows", "cells", "cells-flat"]); - }); }); diff --git a/apps/server/web/src/routes/__tests__/root-frame-guards.test.ts b/apps/server/web/src/routes/__tests__/root-frame-guards.test.ts index 1828a6a1c..3c95a4902 100644 --- a/apps/server/web/src/routes/__tests__/root-frame-guards.test.ts +++ b/apps/server/web/src/routes/__tests__/root-frame-guards.test.ts @@ -51,28 +51,3 @@ describe("the root frame's session-only children", () => { }); } }); - -/** - * The frame's bottom safe-area padding must stay gated on the shell knowing - * a page does NOT own its own bottom edge. The padding exists for the - * scrolling pages; stacked under the key bar's own identical padding it - * renders a background band below the bar — half of the PWA short-height - * report (the other half is the standalone `dvh` bug, see - * `use-visual-viewport-insets`). Asserted at the source because, like the - * session guards above, this is JSX in `Shell`, not a value any pure - * function returns; the predicate itself is unit-tested in - * `lib/__tests__/app-frame.test.ts`. - */ -describe("the root frame's bottom padding", () => { - const source = readFileSync(ROOT, "utf8"); - - test("the outlet wrapper's safe-area padding is gated on the bottom-owner check", () => { - const lines = source.split("\n").filter((l) => l.includes("pb-[env(safe-area-inset-bottom)]")); - expect(lines.length, "no bottom safe-area padding line in __root.tsx").toBeGreaterThan(0); - // EVERY occurrence, not the first — an ungated second one elsewhere - // in the frame is exactly the regression this catches. - for (const line of lines) { - expect(line, `bottom padding ungated: ${line.trim()}`).toContain("bottomOwner"); - } - }); -}); diff --git a/apps/website/components/__tests__/install-column.test.tsx b/apps/website/components/__tests__/install-column.test.tsx index 4445ca583..04026040d 100644 --- a/apps/website/components/__tests__/install-column.test.tsx +++ b/apps/website/components/__tests__/install-column.test.tsx @@ -45,17 +45,6 @@ async function mounted() { await act(async () => {}); } -// Issue #233: the fork must name each choice by the machine's JOB, carry one -// sentence saying what the picked job IS, and offer the non-download path -// (add a machine to a plane that already runs) on the same surface. -test("the fork names jobs, not product names, and states the picked role", async () => { - await mounted(); - expect(screen.getByRole("group", { name: "What this machine should do" })).toBeDefined(); - expect(screen.getByRole("button", { name: "Server" })).toBeDefined(); - expect(screen.getByRole("button", { name: "Client / Node" })).toBeDefined(); - expect(screen.getByText("Host your dashboard and manage your agents.")).toBeDefined(); -}); - test("picking the client chip says what the client is, node enrollment included", async () => { await mounted(); fireEvent.click(screen.getByRole("button", { name: "Client / Node" })); diff --git a/apps/website/lib/__tests__/ui.test.tsx b/apps/website/lib/__tests__/ui.test.tsx index f20f4dfb4..d2743545e 100644 --- a/apps/website/lib/__tests__/ui.test.tsx +++ b/apps/website/lib/__tests__/ui.test.tsx @@ -1,18 +1,6 @@ import { describe, expect, test } from "bun:test"; import { renderToStaticMarkup } from "react-dom/server"; import { Analytics } from "../../components/analytics"; -import { GitHubIcon } from "../../components/icons"; - -describe("GitHubIcon", () => { - test("renders GitHub's Octicons mark at its native size", () => { - const html = renderToStaticMarkup(); - expect(html).toContain('viewBox="0 0 16 16"'); - expect(html).toContain("M6.766 11.328c-2.063-.25-3.516-1.734-3.516-3.656"); - expect(html).toContain('width="16" height="16"'); - expect(html).toContain('fill="currentColor"'); - expect(html).toContain("aria-hidden"); - }); -}); describe("Analytics", () => { test("renders nothing when the id is unset (the default)", () => { diff --git a/brand/package.json b/brand/package.json index 03a6f5201..36bed6318 100644 --- a/brand/package.json +++ b/brand/package.json @@ -5,7 +5,8 @@ "private": true, "scripts": { "make": "bun generate.ts", - "check": "bun test" + "check": "bun test", + "test": "bun test" }, "devDependencies": { "@resvg/resvg-js": "2.6.2" diff --git a/bun.lock b/bun.lock index 977622d4d..ff6cf2a85 100644 --- a/bun.lock +++ b/bun.lock @@ -353,6 +353,7 @@ "version": "1.0.0", "devDependencies": { "@internal/mcp-core": "workspace:*", + "@internal/node": "workspace:*", "@internal/server": "workspace:*", "@internal/server-web": "workspace:*", "@internal/subshell-protocol": "workspace:*", diff --git a/docs/README.md b/docs/README.md index 0b55f177d..5e7617dc9 100644 --- a/docs/README.md +++ b/docs/README.md @@ -31,3 +31,5 @@ implementation records. Paths and proposed behavior in dated records can refer to former files or designs. Use the current implementation and app instructions for present behavior; do not maintain a second current user guide in these archives. + +- [Testing policy and audit](testing.md): test value, affected verification, release gates, and runner measurements. diff --git a/docs/release-and-ci.md b/docs/release-and-ci.md index d42c33525..bdc90686d 100644 --- a/docs/release-and-ci.md +++ b/docs/release-and-ci.md @@ -338,10 +338,10 @@ keeping machines cut-ready is standing human attention that stopped being paid. A hosted runner is disposable and identical; a fleet runner that has drifted is invisible until a job dies on it. -The org fleet still exists, and every workflow is written so it could pick -them up UNCHANGED: the un-root steps, the `git tag -f`, and the -`safe.directory` fixes each stay for exactly that reason. But no job names -its labels any more. `runner-maintenance.yml` and +The org fleet still exists, but no product job names its labels. Release +container jobs retain their ownership cleanup and `safe.directory` handling. +Test jobs now assume ephemeral hosted workspaces; moving them back to the +fleet would require reviewing container ownership and cleanup first. `runner-maintenance.yml` and `reset-linux-runner-workspace.yml` stay as fleet ops; with no jobs selecting the fleet, they simply find nothing to do. @@ -355,34 +355,31 @@ the repo variable `NPM_PUBLISH_ENABLED` is `true`, so ordinary pushes never attempt a publish. The alternative it displaced, an `NPM_TOKEN` checked into repo secrets, remains rejected rather than quietly adopted. -`test.yml`'s eleven jobs run **inside the repo's own builder image** -(`ghcr.io/subshell-ai/desktop-builder:ubuntu24.04`, which is therefore the CI -image as well as the release one). The first job routes the other ten: -`scripts/ci-test-plan.ts` asks turbo which packages changed since the base -plus their dependents, and the test jobs `if:` on those flags: a docs-only -PR runs Plan and greys the rest, and every path the router cannot answer -fails wide to run-everything. Rust inputs are not in the turbo graph, so the -two Rust jobs route on file predicates registered in that same router -(`rustTouched`), and a change to `test.yml` itself runs every slice. It -already carries bun (1.4.2, pinned to -the root `packageManager` so CI runs what developers run), rustup stable and -Tauri's system dependencies; `tmux`, `rustfmt` and `clippy` were added for -CI's sake. That is what lets `setup-bun`, `dtolnay/rust-toolchain` and every -`sudo apt-get` stay out of the workflow. The repository is public. Container -package visibility is configured separately, and CI authenticates pulls of the same-repo GHCR image with -the job token. The `packages: read` permission enables that access; a PAT -is not needed and none is used. Inside a container we simply ARE -root, which is also what makes Playwright's `install-deps` possible. - -`lint.yml` and `cla.yml` run BARE: bun and a JS action need no system -libraries, and the builder image would buy nothing there. +`test.yml` handles PR and main-branch verification and exposes the same jobs +as a reusable workflow for releases. The planner reads workspace manifests +without installing dependencies, follows dependency edges, and selects at most +three unit jobs. Each runs only selected packages; Turbo builds their required +dependencies. Build/type/lint checks share that selection. Script tests run for +the repository contracts they read, not for every app edit. Pure test edits stay +in their owning package. Unknown inputs or missing history run everything. + +Browser and compiled CLI checks share an integration job on a bare Ubuntu +runner, where Testcontainers can reach the host Docker daemon and mapped ports. +Rust checks run selected crates on Linux in the builder image and on macOS; +a shared-core change selects both desktop consumers. The single `Tests passed` +result requires every selected job to succeed, including after a cancellation +or upstream failure. See [the testing policy and audit](testing.md). + +The remaining `lint.yml` job checks release-manifest freshness on pushes to +main. Static checks and dependency auditing now live in `test.yml`; PRs don't +pay for a second full build/lint workflow. `cla.yml` remains separate. Four things this arrangement makes load-bearing: - **Toolchains come from the workflow, never from the runner image.** The fleet-era lesson (its node 18 broke rolldown's `styleText` floor) applies verbatim to hosted images, which float their own versions: `setup-bun` - pins 1.4.0 where a job runs bare, `setup-node` pins 24 (the root + pins 1.4.4 where a job runs bare, `setup-node` pins 24 (the root `package.json`'s `engines.node`), and `actions/setup-node` is not optional just because the runner "already has node". - **Every job needs `timeout-minutes`.** Minutes are now metered, so a hung @@ -394,23 +391,16 @@ Four things this arrangement makes load-bearing: warm state. The fleet-era defect class (`git tag -f` in the plan job, because a tag deleted upstream survived in the runner's clone) cannot recur here; the flag stays so the fleet still works if it ever returns. -- **The un-root steps closing the container jobs are vestigial, kept on - purpose.** A hosted runner is ephemeral, so chowning the workspace back +- **Release container jobs retain ownership cleanup for fleet reuse.** A hosted runner is ephemeral, so chowning the workspace back changes nothing. They stay because the org fleet can still run these workflows, and there, skipping them poisons the next checkout with `EACCES`, the failure `reset-linux-runner-workspace.yml` exists to repair. -**Not done: running the containers as a non-root user.** It would restore -the one test skipped under root (`uploads-route.test.ts`, which chmods a -directory to 0500; root ignores permission bits) and let both the Chromium -`--no-sandbox` workaround and the un-root steps go. It needs -`container.options: --user 1000`; on the fleet that hardcoded a uid the -un-root step discovers at runtime with `stat`, and a differently-provisioned -machine made it fail nondeterministically. Hosted runners remove that -variance, so the trade is better than it was, but it still buys one test -and re-opens every container step to a CI round trip of proving, while the -un-root steps have to stay for the fleet's sake regardless. +JavaScript tests now run on bare hosted runners as the runner user, so file +permission checks can execute normally. Only the Linux Rust check and release +builds use the builder container. The browser suite still carries its existing +CI launch configuration; changing that is separate from test selection. The wall-clock cost the fleet-era write-up predicted but did not see did materialise after the move. The persisted `target/` and `node_modules` made @@ -637,7 +627,15 @@ which is why they share their own smoke, parameterized by app id. `-f version=X.Y.Z`; blank = read `apps//package.json`). `all` is the input's default: every component is cuttable, and each desktop bundle ships the CLI it wraps, so the whole set is the safe cut. - The plan job pushes the missing tag(s) FIRST, then one build shard per + The reusable full verification workflow must succeed on the dispatch's + revision before the plan job can create or push tags. It runs all workspace, + script, browser, compiled CLI, Rust, and native-helper tests, plus static + checks and the dependency audit. Unit and browser retries are disabled. + Failed, cancelled, or missing required checks block the cut; fix the cause + and dispatch the corrected revision. npm publication uses the same gate. + A manifest-only refresh and version-PR maintenance don't publish binaries or + packages and don't need this gate. + The plan job then pushes the missing tags, followed by one build shard per app×triple on GitHub-hosted runners, each triple on hardware of its own arch: linux on `ubuntu-24.04` (linux-x64 native; linux-arm64 cross-built there, so it alone gets the `file` magic check and is never exec'd), @@ -730,19 +728,11 @@ any dispatch not on `main` and any `-f version=` that disagrees with refusal, same reason, as release.yml's); the tag has three cases: absent creates, **present at THIS commit redeploys** (re-dispatch is how you re-push a broken deploy, because for docs the deploy is the release), present at -another commit refuses until the tag is deleted. There is **no CI gate**: the -`ci-gate` job that waited for this commit's push-triggered Test + Lint runs -was removed by operator ruling of 2026-09-25, the day a flaky job in an -unrelated package's Test run blocked both site deploys until a human re-ran -it. What proves a site is `lint.yml` building its export on every push, and -PR CI before that; the gate only borrowed server/node test luck. (The specs -that designed it, 2026-09-16 docs and 2026-09-23 website, keep their gate -sections as dated records.) The export is static for now -(`output: "export"`); the vinext-at-1.0 follow-up lifts that. Broken docs -fail PR CI, not just the deploy: the package's `build` script runs inside -`bun run build`, which `lint.yml` runs on every push. The marketing site -(`apps/website`, `website-v*` tags, `website.yml`, subshell.sh) follows the -identical deploy shape, gate removed there too. +another commit refuses until the tag is deleted. Both site workflows call +`test.yml` with `full: true` before their tag-writing plan, following the same full-verification rule as app and npm releases. This +replaces the earlier no-gate policy: a failed, cancelled, or missing required +check blocks tag creation and deployment. The verification runs against the +same revision the site will build. See [the testing policy](testing.md). **The website also hosts the install scripts.** The root `install-server.sh` and `install-client.sh` are the source, but subshell.sh serves them: the diff --git a/docs/testing.md b/docs/testing.md new file mode 100644 index 000000000..8d2d11f30 --- /dev/null +++ b/docs/testing.md @@ -0,0 +1,261 @@ +# Testing + +Tests earn their cost by catching a plausible failure in behavior we own. +Prefer a small regression test at the boundary that can observe the failure. +A new function or component doesn't automatically need a new test file. + +## What to keep and what to remove + +Keep coverage for permissions and privacy, data loss, migrations, wire +compatibility, process lifecycle, cleanup, and asynchronous state transitions. +Keep browser tests for behavior that depends on real browser layout, input, or +networking, and compiled CLI tests for behavior source imports can't establish. +Test both the allowed action and the relevant refusal when a boundary needs it. + +Before adding a test, name the regression it would catch and check whether an +existing test already catches it. Extend that suite when possible. A second +layer needs a different failure to justify its setup and maintenance cost. + +Remove tests that only assert fixed copy, CSS classes, wrapper markup, trivial +prop forwarding, TypeScript guarantees, or third-party implementation details. +Don't test a copy of production logic or a helper that exists only in the test. +Don't turn every input combination into another test when it exercises the +same branch. Keep meaningful boundaries, accessibility, and input validation. +Happy DOM class assertions don't prove clipping, contrast, or responsive layout; +review those in a real browser instead. + +Compare what tests execute, not just their names. The same cases against two +independent implementations still protect two places that can break. Conversely, +a test-local copy of an algorithm proves nothing about the production function; +exercise the real function or remove the redundant experiment. Runtime parsing +tests still matter for untrusted inputs even when TypeScript rejects their shape. + +When culling, name the check that retains the important behavior. Remove obsolete +UI-absence checks and literal internal-default assertions when they add no signal; +retain wire compatibility, permission boundaries, and anti-vacuity guards. Don't +hide the same workload in loops merely to lower the reported test count. + +Reuse shared boundary tests where they execute the same production path. For +example, `api-surface-auth.test.ts` checks every mounted guarded API route with +anonymous and node credentials, so a per-resource test that only repeats its +401 result adds no distinct coverage. Keep multipart requests, conditional +authentication, permission scopes, ownership, and tests that verify refused +writes leave state unchanged where they exercise something the shared check doesn't. + +When a render-only test repeats the setup of an interaction test, preserve any +useful accessible-state assertion on that interaction instead of mounting again. +For polling, control the scheduler and await actual query promises; don't sleep +through production intervals. Successful fakes must provide the response the +operation awaits. Real deadline tests still matter for cancellation and process +termination. + +## Choose a verification scope + +| Change | During iteration | Before handoff or in PR CI | +| --- | --- | --- | +| Internal prose | Prose check | Prose check; no product suites | +| Copy or styling without behavior changes | Content/visual review and relevant lint | The same focused checks; CI conservatively selects the owning package | +| Documentation site | Content tests and changed-page review | Docs build, types, content/export checks | +| Unit test or test setup | Changed files from the package directory | Owning package's suite | +| App behavior | Existing covering files | App suite and affected consumers; integration where applicable | +| Shared package | Package suite | Transitive consumer builds and tests | +| Rust app | Selected crate | That app on Linux and macOS | +| Shared Rust/native helper | Focused native checks | Core and both desktop apps on Linux and macOS | +| Toolchain, CI selection, or unknown input | Planner/workflow checks | Full verification | +| Release | Explicit full verification | Fresh full verification on the release revision before publication | + +Use the package `test` script for a suite run, so it gets its worker settings +and cleanup. A single `bun test ./path/to/file.test.ts` from that package is +fine during editing. Check the reported file count: a typo can select nothing. +Automation shells must clear `SHELLOPTS` and `BASHOPTS`. Capture output once and +inspect that file after a failure; rerun after a fix, not just to recover a name +lost behind `tail`. + +```bash +bun run test:changed -- --plan +bun run test:changed -- --base origin/main +bun run test:changed -- --unit +bun run verify:changed +bun run test:full +``` + +The local planner includes the branch diff from the base's merge point, staged +and unstaged edits, and untracked files. Both sides of a rename count. Missing +history or an unknown input selects everything and reports the reason. Fetch a +missing base rather than narrowing a fallback by hand. A successful relevant +check doesn't need repeating at every commit, push, PR, or handoff. + +`test:changed` runs the selected unit, integration, and Rust tiers; `--unit` +is the iteration shortcut. `verify:changed` also builds and type-checks affected +packages, runs their lint, and checks repository contracts. `test:full` includes +all of those plus the live dependency audit. `bun run test` remains workspace +unit tests only. Pre-push runs affected types/lint and fast repository checks, +without duplicating CI's test runs. + +## How CI selects work + +`scripts/ci-test-plan.ts` discovers workspace manifests and follows local +production, development, peer, and optional dependency edges. It selects the +changed workspace and its transitive consumers. Tests, fixtures under test +folders, and preloads affect only their owning suite. Rust source follows the +Cargo path dependencies separately; ordinary desktop React edits don't start +Rust builds. Rust changes also select the owning desktop's JS contract tests +for IPC, wire names, and signing configuration; shared core changes select both. +Global toolchain or verification changes run every tier. + +This is package selection, not a claim that static imports describe every test +input. Browser fixtures/configuration changes run the whole browser suite; +a change only to browser specs runs those specs with account setup. Product +changes reaching the browser stack run the complete browser suite because the +specs share state and production paths cross feature boundaries. Script tests +have explicit repository-input rules for manifests, publishing, installers, and +shared release helpers. Keep those rules current when a test begins reading an +external file. An unknown root input or deleted workspace selects everything. + +PR CI has one planner, one static-check job, up to three unit jobs, one selected +integration job, and selected Linux/macOS native jobs. `Tests passed` aggregates +those results and rejects a required job that failed, was skipped, or was +cancelled. Only selected consumers and their dependencies build; planning needs +neither a dependency install nor Turbo. All existing workspace test scripts are +discovered automatically, including docs and node-web. The e2e workspace +explicitly depends on the real node daemon its fixtures launch, so a node-only +production change also selects browser coverage. Brand tests and e2e +capture-helper tests now have ordinary workspace `test` scripts too. + +Full release verification bypasses change selection. `release.yml`, `docs.yml`, and `website.yml` call the +same workflow with `full: true` before their tag-writing plans; npm +publication uses that gate too. Unit and browser tests run without retries. A failed release gate +requires fixing the failure before publishing; it cannot borrow a green result +from another revision. Local full verification covers the current host, while +CI covers both Linux and macOS. Published-artifact smoke checks still run on the +release shards, and the existing post-cut download check remains separate. + +## Audit and runner assessment, 2026-10-25 + +The initial inventory found more than 1,000 TypeScript test files and more than +12,000 statically declared cases. This is an inventory count, not a runner count: +parameterized cases and platform skips change the latter. The review covered the +runner/configuration, suite distribution, source-reading tests, and presentation +and helper duplicates. It did not attempt to declare every remaining case valuable. + +The first pass removed 62 cases across 12 files: CSS/layout assertions in sidebar +cells, headers, segmented controls, the assistant frame/rail and dropdowns; +redundant tint determinism checks; and tests of a test-only icon filter plus a +hardcoded plugin count; and repeated bundle-length and sourcemap assertions +for each built-in. The plugin manifest, entry-presence, and import-portability +checks remain. API-version validation no longer pins the number of plugins. Navigation, +permissions, notification ownership, accessible state, drag/drop mutations, +failure recovery, and generated-route/security checks remain. No product code +was removed in that pass. It was a narrow cull, not a complete value audit. + +The follow-up inventory covered 1,085 TypeScript test/spec files. A second review +removed another 83 cases, bringing the total removed to 145. It examined duplicate +bodies, source-reading checks, literal/constructor assertions, presentation checks, +and overlapping suites across the API, web, desktop, mobile, site, and shared +packages. Inventory and candidate screening do not establish that every remaining +test is valuable. Examples of the decisions made: + +| Removed or replaced | Coverage retained | +| --- | --- | +| Kysely's generic CRUD, returning, and transaction demonstrations | Real repository/route persistence and atomicity tests; a focused `openSqliteDatabase` foreign-key enforcement check | +| Context constructor shape assertions | Requestless-context identity/reset checks and real HTTP/MCP service execution | +| Repeated launch-directory scenarios | Recent-path precedence, delayed-node answers, machine switches, and caller-provided directories | +| Fixed icons, copy, spacing, old control names, and repeated no-Re-check cases | Navigation, callbacks, accessibility, gating, and mutation tests; content/layout review | +| Internal timing/type assertions and duplicate legal constants | Timeout/idle behavior, wire-version compatibility, license notices, and `lint:licenses` | +| Several source-string assertions about desktop wiring | Existing host interaction tests for setup firing, requested screens, and handoff | +| Replay tests using a locally rebuilt "correct" frame | The real server serializer feeding real xterm; both retained tests fail when the trailing-newline regression is reintroduced in a temporary copy | + +The mobile and web mouse-report suites and the two desktop release suites remain: +they exercise separate production implementations. The largest suites include +runtime frame validation, service lifecycle, CLI dispatch, and node connection +handling; their case counts alone don't establish redundancy. Native signing/IPC +checks, migrations, cleanup/races, and published-artifact integration checks also +remain. Further culls should use the same evidence standard, not a quota or a +blanket claim that a particular layer is redundant. + +A third pass removed 58 more cases, bringing the total to 203. Of these, 24 +only repeated anonymous rejection already exercised by the composed API route +suite. Multipart rejection, conditional setup authentication, scoped permissions, +ownership, and refused writes leaving data intact remain in their resource suites. +A temporary mutation removing the meta route's guard failed the retained route +inventory, confirming that deleting its duplicate 401 case didn't lose that guard. + +The other removals include identical handoff, launch-body, launch-node, and update +state cases; duplicate protocol constants now pinned only in the canonical frame +suite; internal timing/cap literals; fixed SVG/layout/copy checks; redundant grid +properties; and random backoff sampling already covered by deterministic bounds. +Preset toggles now check their accessible state and refetch ordering in the same +interaction, avoiding four extra mounts without losing those assertions. + +Two slow tests kept their behavior and lost unnecessary waits: + +| Test | Previous full-run duration | After the change, full run | +| --- | ---: | ---: | +| Shared subagent polling | 15,620 ms | 19 ms | +| MCP prompt delivery success | 10,033 ms | 26 ms | + +The polling test drives the real interval callbacks and awaits real query promises. +Four temporary mutations verified that it catches hidden-tab polling, duplicate +fetches, interval leaks, and disabled observers keeping a timer alive. The prompt +fake now echoes the submitted input, so the success test no longer waits for the +ten-second fallback; the shared delivery suite retains timeout coverage. These +are individual test durations, not an equivalent reduction in parallel suite wall +time: the complete workspace run took 44.07 seconds, versus 46.29 previously. + +The larger measured cost was orchestration. In +[PR run 40253886630](https://github.com/subshell-ai/subshell/actions/runs/40253886630), +planning took 71 seconds including installation. Mobile tests took less than a +second after a 47-second full-repo build. Script tests took about two seconds +after a 56-second build, and the job also ran unrelated website tests. The old +router sent every app/package edit to scripts and treated existing docs/node-web +workspaces as unknown, selecting every job. The old verification instructions +also demanded a full suite at every task boundary. Those costs are addressed by +selection and shared guidance; hosted wall-time savings need measurement after +this workflow runs in CI. + +Keep Bun. Both runners already have parallel file workers, isolation, sharding, +and worker controls: [Bun's execution options](https://bun.sh/docs/test/parallel) +and [Vitest's performance guidance](https://vitest.dev/guide/improving-performance). +Vitest additionally offers Vite-aware related/changed selection and browser +mode, but its normal [worker pools](https://vitest.dev/config/pool) use Node. +Our server uses `bun:sqlite`, and many suites use `Bun.serve` and `Bun.spawn`. +A migration would require retaining Bun for those suites or replacing runtime +behavior in the tests. That isn't justified by a parallelism feature we already +have. Static import selection also needs explicit rules for files read at runtime. + +A local comparison used Bun 1.4.4 and Vitest 5.0.5 under Node 26.8.1 on the same +six existing pure-logic suites (105 cases): ANSI stripping, directory allowlists, +shared geometry, lingering, release paths, and versions. The Vitest copies only +changed test imports and source paths. Three complete process launches per mode, +including startup, produced these median wall times: + +| Runner | Median | +| --- | ---: | +| Bun serial | 0.070 s | +| Bun, four isolated workers | 0.131 s | +| Bun, four reused workers | 0.128 s | +| Vitest, four forks | 0.334 s | +| Vitest, four threads | 0.311 s | + +The retained web suite also passed all 3,102 cases in each of three Bun modes. +Single local runs took 96.85 s serially, 32.52 s with four isolated workers, and +16.77 s with twelve. That supports keeping the existing local web width of +twelve; it doesn't establish a safe width on hosted runners. + +This small sample measures startup and pure logic, not the backend database or +Happy DOM suites, and uses a newer Node than CI's pinned Node 24. It supports no +claim about a whole-repo migration speedup. It does show that adding workers to +very short suites can cost more than it saves. Don't raise worker counts by +instinct, enable in-file concurrency on shared DB/DOM state, or disable isolation +without measuring the representative suite. Keep the existing local package +widths and CI's measured serial regime until hosted measurements justify a change. + +After the third cull, workspace unit verification on Bun 1.4.4 reports 12,251 +passed, two skipped, and none failed across 1,000 files. All 60 Turbo tasks +succeeded in 44.07 seconds locally, with 20 unchanged tasks served from cache. +The preceding run reported 12,309 passes; the difference confirms the additional +58 removals. Repository-script tests passed 335 cases, with three macOS-only skips. +Workflow validation, shell lint, the new drivers' type checks, and the changed +packages' types/lint also passed. These are local results; the hosted browser, +compiled CLI, and Linux/macOS native tiers still need their first run of the new +workflow before any release can pass its gate. diff --git a/e2e/AGENTS.md b/e2e/AGENTS.md index 3e9bc40c2..b12070250 100644 --- a/e2e/AGENTS.md +++ b/e2e/AGENTS.md @@ -14,7 +14,8 @@ E2E_VERBOSE=1 bun run test:e2e # stream backend stdout/stderr for debugging Run from the repo root via the root `test:e2e` script (which routes here), or inside `e2e/` directly. Never part of `turbo test`, `bun run test`, or the -pre-push hook; CI runs it as its own job. +pre-push hook; CI runs it in the selected integration job. The workspace +`test` script covers only the capture helpers, not browser scenarios. ## How the stack boots @@ -164,8 +165,8 @@ Spec 25 runs the browser signer against a real sshd on the host (not Testcontainers): it needs `ssh`, `sshd` and `ssh-keygen` present, and it skips loudly naming the missing binaries where they are not. Run all SSH specs together with `01-setup-wizard.spec.ts` (which seeds the account for spec 24). -CI mounts its Docker socket into the e2e job so Testcontainers can start the -same destination fixture. +CI runs integration checks on a bare Ubuntu runner, so Testcontainers uses +the host Docker daemon and its mapped ports directly. ## SSH documentation screenshots diff --git a/e2e/package.json b/e2e/package.json index e719f1cdf..e79bfa708 100644 --- a/e2e/package.json +++ b/e2e/package.json @@ -12,10 +12,12 @@ "lint": "biome check --no-errors-on-unmatched --write --unsafe .", "lint:check": "biome check --no-errors-on-unmatched .", "lint:staged": "biome check --no-errors-on-unmatched --write --unsafe --staged .", - "verify-types": "tsc --noEmit" + "verify-types": "tsc --noEmit", + "test": "bun test ./__tests__/" }, "devDependencies": { "@internal/mcp-core": "workspace:*", + "@internal/node": "workspace:*", "@internal/server": "workspace:*", "@internal/server-web": "workspace:*", "@internal/subshell-protocol": "workspace:*", diff --git a/lefthook.yml b/lefthook.yml index 586db24c9..7e20d9203 100644 --- a/lefthook.yml +++ b/lefthook.yml @@ -34,11 +34,11 @@ pre-push: parallel: true commands: "verify types": - run: bun run verify-types + run: bun scripts/check-changed.ts types tags: - types "lint": - run: bun run lint:check + run: bun scripts/check-changed.ts lint tags: - lint "licenses": @@ -54,4 +54,4 @@ pre-push: tags: - lint # NOTE: the test suite deliberately does NOT run here — CI (.github/workflows/test.yml) - # owns it; a local push shouldn't cost 30+ s. Run `bun run test` by hand when you want it. + # owns it; a local push shouldn't cost 30+ s. Run `bun run verify:changed` for the affected checks before handoff. diff --git a/package.json b/package.json index 499fc5aec..79c9b78be 100644 --- a/package.json +++ b/package.json @@ -56,7 +56,10 @@ "release:desktop-client": "bun run --cwd apps/client/desktop compile:release", "changeset": "changeset", "version-packages": "changeset version && bun run lint:lockfile:fix", - "publish-packages": "changeset publish" + "publish-packages": "changeset publish", + "test:changed": "bun scripts/test-changed.ts", + "verify:changed": "bun scripts/test-changed.ts --checks", + "test:full": "bun scripts/test-changed.ts --full" }, "dependencies": { "@elysiajs/eden": "1.4.9", diff --git a/packages/assistant/src/__tests__/frame.test.tsx b/packages/assistant/src/__tests__/frame.test.tsx index bea8aed75..95d98a2c9 100644 --- a/packages/assistant/src/__tests__/frame.test.tsx +++ b/packages/assistant/src/__tests__/frame.test.tsx @@ -1,7 +1,6 @@ import { afterEach, describe, expect, it } from "bun:test"; import { cleanup, render, screen } from "@testing-library/react"; import { Frame } from "../frame"; -import { Rail, type RailSection } from "../rail"; // Same registration rail.test.tsx carries, for the same reason: one document // per process, so an un-unmounted render leaks into the next test's queries. @@ -49,39 +48,3 @@ describe("Frame", () => { expect(status.textContent).toBe("launchctl: 5: Operation not permitted"); }); }); - -describe("Frame's rail slot", () => { - const SECTIONS: RailSection[] = [ - { id: "status", label: "Status" }, - { id: "update", label: "Update" }, - ]; - - it("places the rail node before the content column, full height", () => { - render( - {}} />} - > -

content

- , - ); - // The rail is the frame's FIRST child and the column (scroll region plus - // bar) is the second — the SPA sidebar's arrangement, so the 72px bar - // belongs to the content it serves. - const row = document.querySelector("div.flex.h-screen") as HTMLElement; - expect(row.children[0].tagName).toBe("NAV"); - expect(row.children[1].className).toContain("min-w-0 flex-1"); - expect(screen.getByRole("navigation", { name: "Main" })).toBeDefined(); - expect(screen.getByText("content")).toBeDefined(); - }); - - it("renders the same single-column structure without a rail", () => { - render( - -

content

- , - ); - const row = document.querySelector("div.flex.h-screen") as HTMLElement; - expect(row.children).toHaveLength(1); - }); -}); diff --git a/packages/assistant/src/__tests__/rail.test.tsx b/packages/assistant/src/__tests__/rail.test.tsx index 56e9b44e3..83d0e44d9 100644 --- a/packages/assistant/src/__tests__/rail.test.tsx +++ b/packages/assistant/src/__tests__/rail.test.tsx @@ -33,41 +33,6 @@ describe("Rail", () => { } }); - it("styles a danger section in the destructive token, and never marks it active", () => { - render( - {}} - />, - ); - const reset = screen.getByRole("button", { name: "Reset" }); - expect(reset.className).toContain("text-destructive"); - expect(reset.className).not.toContain("linear-gradient"); - expect(screen.getByRole("button", { name: "Status" }).className).toContain("text-muted-foreground"); - }); - - it("keeps a danger section in the danger styling even when a caller marks it active", () => { - // Structural, not caller discipline: the active gradient is FORBIDDEN for - // a destructive section, whoever asks for it. - render( - {}} - />, - ); - const reset = screen.getByRole("button", { name: "Reset" }); - expect(reset.className).toContain("text-destructive"); - expect(reset.className).not.toContain("linear-gradient"); - }); - it("selects with the section's id", () => { const picked: string[] = []; render( picked.push(id)} />); diff --git a/packages/pane-runtime/src/__tests__/built-in-api-versions.test.ts b/packages/pane-runtime/src/__tests__/built-in-api-versions.test.ts index f478e346f..15b4d9ab4 100644 --- a/packages/pane-runtime/src/__tests__/built-in-api-versions.test.ts +++ b/packages/pane-runtime/src/__tests__/built-in-api-versions.test.ts @@ -31,10 +31,7 @@ function readBuiltInManifests(): DeclaredVersion[] { return ( readdirSync(PLUGINS_DIR, { withFileTypes: true }) .filter((entry) => entry.isDirectory()) - // A directory with no package.json is not a built-in — a stray `dist`, - // an editor's scratch folder. Skipping it lets the count assertion below - // report a real disappearance instead of this function dying on ENOENT - // before any assertion runs. + // Ignore directories that aren't plugin packages. .filter((entry) => existsSync(join(PLUGINS_DIR, entry.name, "package.json"))) .map((entry) => { const raw = readFileSync(join(PLUGINS_DIR, entry.name, "package.json"), "utf8"); @@ -47,11 +44,8 @@ function readBuiltInManifests(): DeclaredVersion[] { describe("built-in plugin manifests", () => { it("pins each built-in's declared apiVersion to the host's PLUGIN_API_VERSION", () => { const manifests = readBuiltInManifests(); - // The built-ins that ship (AGENTS.md). Asserted so a broken path — or a - // built-in whose `subshell` block went missing — fails loudly here rather - // than passing vacuously over a shorter list. Another built-in updates - // this number on purpose. - expect(manifests.length).toBe(13); + // Catch a broken discovery path without pinning how many plugins we ship. + expect(manifests.length).toBeGreaterThan(0); for (const m of manifests) { expect(m.apiVersion, `${m.name} declares a stale subshell.apiVersion`).toBe(PLUGIN_API_VERSION); } diff --git a/packages/pane-runtime/src/__tests__/built-in-icons.test.ts b/packages/pane-runtime/src/__tests__/built-in-icons.test.ts index d4be45739..7878d7098 100644 --- a/packages/pane-runtime/src/__tests__/built-in-icons.test.ts +++ b/packages/pane-runtime/src/__tests__/built-in-icons.test.ts @@ -67,33 +67,7 @@ function readBuiltInManifests(): DeclaredIcon[] { ); } -describe("iconsLeftOutOfTarball", () => { - const WITH_ICON = { dir: "x", name: "@subshell-ai/plugin-x", icon: "icon.png" }; - - it("treats an ABSENT `files` array as shipping everything — npm ships the whole directory", () => { - // Review nit on the #64 guard: the absent-array default was read backwards, - // so a future icon-bearing built-in with no `files` would fail the guard - // for a reason that is not true of its tarball. - expect(iconsLeftOutOfTarball([{ ...WITH_ICON, files: undefined }])).toEqual([]); - }); - - it("treats an EMPTY `files` array as shipping nothing — the icon is left out", () => { - expect(iconsLeftOutOfTarball([{ ...WITH_ICON, files: [] }])).toHaveLength(1); - }); - - it("passes the icon only when `files` names it", () => { - expect(iconsLeftOutOfTarball([{ ...WITH_ICON, files: ["dist", "icon.png"] }])).toEqual([]); - expect(iconsLeftOutOfTarball([{ ...WITH_ICON, files: ["dist", "icon.svg"] }])).toHaveLength(1); - }); -}); - describe("built-in plugin icon manifests", () => { - it("pins the number of plugins declaring an icon, so a silent disappearance is loud", () => { - // Ten of the thirteen built-ins ship a mark (grok-build, headscale, and cloudflare-tunnel - // declare none). A new icon-bearing built-in updates this number on purpose. - expect(readBuiltInManifests().filter((m) => m.icon).length).toBe(10); - }); - it("declares no icon that is absent from the npm `files` list", () => { const violations = iconsLeftOutOfTarball(readBuiltInManifests()).map( (m) => `${m.name} declares icon "${m.icon}" but its files array would leave it out of the tarball`, diff --git a/packages/pane-runtime/src/__tests__/embedded-plugins.test.ts b/packages/pane-runtime/src/__tests__/embedded-plugins.test.ts index 1b8bcde08..8d53c9325 100644 --- a/packages/pane-runtime/src/__tests__/embedded-plugins.test.ts +++ b/packages/pane-runtime/src/__tests__/embedded-plugins.test.ts @@ -65,12 +65,6 @@ describe("built-in plugin bytes", () => { expect(parsed).toEqual(plugin.manifest); }); - it(`${id}: the entry is a real module, not an empty placeholder`, async () => { - const plugin = await readBuiltIn(id); - if (!plugin) throw new Error(`no bytes for ${id}`); - expect((plugin.files[plugin.manifest.entry] ?? "").length).toBeGreaterThan(100); - }); - it(`${id}: the entry imports nothing but node builtins`, async () => { // A plugin loaded from disk cannot resolve a bare specifier, so bytes // carrying one would install cleanly and then fail at load. @@ -85,12 +79,6 @@ describe("built-in plugin bytes", () => { .filter((spec) => spec !== undefined && !spec.startsWith("node:") && !spec.startsWith(".")); expect(bare).toEqual([]); }); - - it(`${id}: carries no .d.ts or sourcemap, which an install must not write`, async () => { - const plugin = await readBuiltIn(id); - if (!plugin) throw new Error(`no bytes for ${id}`); - expect(Object.keys(plugin.files).filter((f) => f.endsWith(".d.ts") || f.endsWith(".map"))).toEqual([]); - }); } }); diff --git a/packages/subshell-protocol/src/__tests__/legal.test.ts b/packages/subshell-protocol/src/__tests__/legal.test.ts index 365e11991..951e07b79 100644 --- a/packages/subshell-protocol/src/__tests__/legal.test.ts +++ b/packages/subshell-protocol/src/__tests__/legal.test.ts @@ -1,40 +1,6 @@ import { describe, expect, test } from "bun:test"; import { readFileSync } from "node:fs"; -import { - COMPANY_URL, - COPYRIGHT_HOLDER, - COPYRIGHT_LINE, - COPYRIGHT_YEAR, - LICENSE_EXCEPTION_SUMMARY, - LICENSE_SUMMARY, - LICENSE_URL, - licenseNotice, -} from "../legal.js"; - -describe("legal constants", () => { - test("the copyright line is composed of its parts", () => { - expect(COPYRIGHT_LINE).toBe(`Copyright ${COPYRIGHT_YEAR} ${COPYRIGHT_HOLDER}`); - }); - - // The registered entity, not the DBA. A trade name cannot hold title or - // contract, which is why CLA.md names this string specifically. - test("the holder is the registered entity", () => { - expect(COPYRIGHT_HOLDER).toBe("Disaresta, LLC"); - }); - - test("the company URL is the holder's own site", () => { - expect(COMPANY_URL).toBe("https://disaresta.com"); - expect(COMPANY_URL).not.toBe(LICENSE_URL); - }); - - // "Dual-licensed" alone leaves a reader unable to tell what governs the part - // they are holding, which is the only question the line exists to answer. - test("the summary names both halves and which is which", () => { - expect(LICENSE_SUMMARY).toContain("AGPL-3.0-only"); - expect(LICENSE_SUMMARY).toContain("Apache-2.0"); - expect(LICENSE_SUMMARY).toContain("control plane"); - }); -}); +import { COPYRIGHT_LINE, LICENSE_EXCEPTION_SUMMARY, LICENSE_SUMMARY, LICENSE_URL, licenseNotice } from "../legal.js"; describe("licenseNotice", () => { const notice = licenseNotice("subshell", "1.2.3"); diff --git a/packages/subshell-protocol/src/__tests__/node-results.test.ts b/packages/subshell-protocol/src/__tests__/node-results.test.ts index 5c928c6e1..98436411e 100644 --- a/packages/subshell-protocol/src/__tests__/node-results.test.ts +++ b/packages/subshell-protocol/src/__tests__/node-results.test.ts @@ -3,8 +3,6 @@ import { FS_LS_MAX_ENTRIES, MAX_ARCHIVE_BYTES, MAX_MANIFEST_PAGE_ENTRIES, - NODE_CLOSE_SUPERSEDED, - NODE_CLOSE_UPDATE_REQUIRED, type NodePreviewPortWire, parseNodeArchiveCreateResult, parseNodeArchiveExtractResult, @@ -30,11 +28,6 @@ import { } from "../index.js"; describe("node result contracts (spec §3.3, phase-2 wire note)", () => { - it("hoists the two shared close codes", () => { - expect(NODE_CLOSE_UPDATE_REQUIRED).toBe(4406); - expect(NODE_CLOSE_SUPERSEDED).toBe(4409); - }); - it("probe entries: accept well-formed (incl. optional fields), reject junk", () => { expect( parseNodeProbeEntries([ diff --git a/packages/subshell-protocol/src/__tests__/relay-frames.test.ts b/packages/subshell-protocol/src/__tests__/relay-frames.test.ts index dcc1c7a7b..6b953045f 100644 --- a/packages/subshell-protocol/src/__tests__/relay-frames.test.ts +++ b/packages/subshell-protocol/src/__tests__/relay-frames.test.ts @@ -1,11 +1,5 @@ import { describe, expect, it } from "bun:test"; -import { - NODE_PROTOCOL_VERSION, - parseNodeEvent, - parseRelayFrame, - relayFrameOverCap, - relayFrameRefIfOverCap, -} from "../node-frames.js"; +import { parseNodeEvent, parseRelayFrame, relayFrameOverCap, relayFrameRefIfOverCap } from "../node-frames.js"; import { SSH_RELAY_FRAME_MAX_BYTES } from "../ssh-limits.js"; /** @@ -32,16 +26,6 @@ const good = { blob: b64OfRawLength(1024), } as const; -describe("NODE_PROTOCOL_VERSION 24 (current bump: saved HTTP(S) services)", () => { - it("pins the current version so a stale build cannot ride the exact-match gate", () => { - // Every capability added since relay frames (20) depends on this number: - // the plane and the agent only speak on an EXACT match, so a binary that - // never learned the preview surface (23) is refused BEFORE any frame is - // accepted. Never half-relay. - expect(NODE_PROTOCOL_VERSION).toBe(24); - }); -}); - describe("parseRelayFrame", () => { it("accepts a valid frame and preserves every field", () => { expect(parseRelayFrame(good)).toEqual({ ...good }); diff --git a/packages/subshell-protocol/src/__tests__/shared-geometry.test.ts b/packages/subshell-protocol/src/__tests__/shared-geometry.test.ts index 7d8e989be..d1e42213e 100644 --- a/packages/subshell-protocol/src/__tests__/shared-geometry.test.ts +++ b/packages/subshell-protocol/src/__tests__/shared-geometry.test.ts @@ -1,11 +1,5 @@ import { describe, expect, it } from "bun:test"; -import { - DEFAULT_GRID, - decideSharedGrid, - MIN_SHARED_COLS, - MIN_SHARED_ROWS, - resolveSharedGrid, -} from "../shared-geometry.js"; +import { decideSharedGrid, MIN_SHARED_COLS, MIN_SHARED_ROWS, resolveSharedGrid } from "../shared-geometry.js"; /** Terse viewer literal: a capacity with a generated id. */ let seq = 0; @@ -43,11 +37,6 @@ describe("resolveSharedGrid — smallest wins, per axis", () => { expect(shuffled).toEqual(forward as { cols: number; rows: number }); }); - it("is idempotent: re-resolving its own answer changes nothing", () => { - const once = resolveSharedGrid([v(120, 40), v(80, 24)]) as { cols: number; rows: number }; - expect(resolveSharedGrid([v(once.cols, once.rows)])).toEqual(once); - }); - it("ignores a mid-layout viewer rather than shrinking everyone to a 2x1 strip", () => { // Observed live: `ws attach … geometry 2x1` — FitAddon's degenerate floor, // reported by a client measuring before its layout settled. With several @@ -89,12 +78,6 @@ describe("resolveSharedGrid — smallest wins, per axis", () => { it("drops only the malformed entries, keeping the rest", () => { expect(resolveSharedGrid([v(120, 40), v(Number.NaN, 24), v(90, 30)])).toEqual({ cols: 90, rows: 30 }); }); - - it("has a sane default for callers with nothing to go on", () => { - // tmux births a detached window at 80x24; the fallback matches so a pane - // nobody has measured is not a surprise. - expect(DEFAULT_GRID).toEqual({ cols: 80, rows: 24 }); - }); }); describe("hidden viewers do not hold the pane down", () => { @@ -106,10 +89,6 @@ describe("hidden viewers do not hold the pane down", () => { expect(resolveSharedGrid([v(120, 40), v(60, 20, true)])).toEqual({ cols: 120, rows: 40 }); }); - it("counts it again the moment it is shown", () => { - expect(resolveSharedGrid([v(120, 40), v(60, 20, false)])).toEqual({ cols: 60, rows: 20 }); - }); - it("uses the hidden ones when EVERY viewer is hidden", () => { // Something has to size the pane, and the last thing anyone looked at // beats an arbitrary default. @@ -222,22 +201,6 @@ describe("decideSharedGrid — naming the device that holds each axis", () => { expect(d?.reason).toBe("fallback"); expect(d?.grid).toEqual({ cols: 10, rows: 3 }); }); - - it("agrees with resolveSharedGrid on every shape", () => { - // The wrapper must not drift from the function it wraps. - const cases = [ - [v(120, 40)], - [v(120, 40), v(80, 24)], - [v(200, 10), v(40, 60)], - [v(2, 1), v(10, 3)], - [v(40, 20, true), v(120, 40)], - [{ id: "n", capacity: null }], - [], - ]; - for (const viewers of cases) { - expect(resolveSharedGrid(viewers)).toEqual(decideSharedGrid(viewers)?.grid ?? null); - } - }); }); describe("who gets a say: rungs, not one visible/all split", () => { diff --git a/scripts/__tests__/ci-test-plan.test.ts b/scripts/__tests__/ci-test-plan.test.ts index 75e5442d4..9e275a435 100644 --- a/scripts/__tests__/ci-test-plan.test.ts +++ b/scripts/__tests__/ci-test-plan.test.ts @@ -1,197 +1,193 @@ -import { describe, expect, test } from "bun:test"; -import { type AffectedPackage, type allFlags, computePlan, rustTouched, scriptsTouched } from "../ci-test-plan"; - -const pkg = (name: string, dir: string): AffectedPackage => ({ name, dir }); - -describe("computePlan", () => { - test("routes each registered workspace to exactly its slice", () => { - const cases: [AffectedPackage, keyof ReturnType][] = [ - [pkg("@internal/server-web", "apps/server/web"), "web"], - [pkg("@internal/mobile", "apps/client/mobile"), "mobile"], - [pkg("@internal/desktop-server", "apps/server/desktop"), "desktop"], - [pkg("@internal/desktop-client", "apps/client/desktop"), "desktop"], - [pkg("@internal/server", "apps/server/api"), "serverNode"], - [pkg("@internal/node", "apps/node/agent"), "serverNode"], - [pkg("@internal/e2e", "e2e"), "e2e"], - // The website's suites run inside the Scripts job, so its package - // routes there by NAME — an unregistered website would fail-wide. - [pkg("@internal/website", "apps/website"), "scripts"], +import { afterAll, describe, expect, test } from "bun:test"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + changedFiles, + computePlan, + fullPlan, + planChanges, + ROOT, + RUST_CRATES, + readWorkspaces, + unitLanes, + type Workspace, +} from "../ci-test-plan"; + +const workspaces = readWorkspaces(); +const plan = (files: string[]) => computePlan(workspaces, files); + +describe("change selection", () => { + test("internal prose and changesets don't start product suites", () => { + const selected = plan(["AGENTS.md", "docs/testing.md", "apps/server/web/AGENTS.md", ".changeset/fix.md"]); + expect(selected.packages).toEqual([]); + expect(selected.scripts || selected.e2e || selected.smoke || selected.cli).toBe(false); + expect(selected.rust).toEqual([]); + }); + + test("docs and node-web are ordinary workspaces, not unknown-package full runs", () => { + expect(plan(["apps/docs/content/docs/index.mdx"]).packages).toEqual(["@internal/docs"]); + const nodeWeb = plan(["apps/node/web/src/main.tsx"]); + expect(nodeWeb.full).toBe(false); + expect(nodeWeb.packages).toContain("@internal/node-web"); + expect(nodeWeb.rust).toEqual([]); + }); + + test("production changes reach transitive consumers, including browser and Metro builds", () => { + const selected = plan(["packages/subshell-protocol/src/frames.ts"]); + for (const name of [ + "@internal/subshell-protocol", + "@internal/server", + "@internal/node", + "@internal/server-web", + "@internal/mobile", + "@internal/e2e", + ]) + expect(selected.packages).toContain(name); + expect(selected.e2e).toBe(true); + expect(selected.smoke).toBe(true); + }); + + test("a unit-test-only edit stays in its owning package", () => { + const selected = plan(["packages/subshell-protocol/src/__tests__/frames.test.ts"]); + expect(selected.packages).toEqual(["@internal/subshell-protocol"]); + expect(selected.e2e || selected.smoke || selected.cli).toBe(false); + const web = plan(["apps/server/web/src/components/__tests__/x.test.tsx"]); + expect(web.packages).toEqual(["@internal/server-web"]); + expect(web.scripts).toBe(false); + expect(plan(["apps/server/api/src/test-preload.ts"]).packages).toEqual(["@internal/server"]); + expect(plan(["apps/server/api/scripts/test-run.sh"]).e2e).toBe(false); + }); + + test("a web edit doesn't run the website, scripts, or Rust", () => { + const selected = plan(["apps/server/web/src/components/example.tsx"]); + expect(selected.packages).not.toContain("@internal/website"); + expect(selected.scripts).toBe(false); + expect(selected.rust).toEqual([]); + expect(selected.e2e).toBe(true); + }); + + test("browser-only specs keep account setup and fixtures widen to every scenario", () => { + expect(plan(["e2e/tests/04-presets.spec.ts"]).e2eFiles).toEqual([ + "e2e/tests/01-setup-wizard.spec.ts", + "e2e/tests/04-presets.spec.ts", + ]); + expect(plan(["e2e/helpers.ts"]).e2eFiles).toEqual([]); + expect(plan(["e2e/helpers.ts"]).e2e).toBe(true); + expect(plan(["e2e/__tests__/capture-file-paths.test.ts"]).e2e).toBe(false); + }); + + test("Rust follows path dependencies but an app edit doesn't test its sibling", () => { + expect(plan(["apps/client/desktop/src-tauri/src/lib.rs"]).rust).toEqual(["apps/client/desktop/src-tauri"]); + expect(plan(["apps/client/desktop/src-tauri/src/lib.rs"]).packages).toEqual(["@internal/desktop-client"]); + expect(plan(["crates/desktop-core/src/lib.rs"]).rust).toEqual(RUST_CRATES); + expect(plan(["crates/desktop-core/src/lib.rs"]).packages).toEqual([ + "@internal/desktop-client", + "@internal/desktop-server", + ]); + expect(plan(["native/display-helper/Consent.swift"]).rust).toEqual(RUST_CRATES); + expect(plan(["apps/client/desktop/ui/src/main.tsx"]).rust).toEqual([]); + }); + + test("the real node used by browser fixtures stays in the dependency graph", () => { + expect(plan(["apps/node/agent/src/main.ts"]).e2e).toBe(true); + expect(plan(["apps/node/web/src/main.tsx"]).cli).toBe(true); + }); + + test("copied site and release inputs select their consumers", () => { + expect(plan(["releases.json"]).packages).toContain("@internal/website"); + expect(plan(["install-server.sh"]).cli).toBe(true); + expect(plan(["install-client.sh"]).packages).toContain("@internal/website"); + expect(plan(["scripts/cli-release-fetch.ts"]).packages).toEqual([ + "@internal/desktop-client", + "@internal/desktop-server", + ]); + expect(plan(["scripts/test-compiled-smoke.sh"]).smoke).toBe(true); + expect(plan(["scripts/macos-entitlements.plist"]).packages).toEqual([ + "@internal/desktop-client", + "@internal/desktop-server", + ]); + expect(plan(["apps/server/web/src/types/permissions.ts"]).packages).toContain("@internal/desktop-server"); + expect(plan(["apps/server/api/src/services/releases.ts"]).scripts).toBe(true); + expect( + plan(["packages/subshell-protocol/src/__tests__/fixtures/release-signature/publisher-pubkey.txt"]).scripts, + ).toBe(true); + }); + + test("new workspaces route automatically and dependency cycles terminate", () => { + const graph: Workspace[] = [ + { name: "new", dir: "packages/new", dependencies: ["consumer"], scripts: { test: "bun test" } }, + { name: "consumer", dir: "apps/new", dependencies: ["new"], scripts: { test: "bun test" } }, ]; - for (const [p, slice] of cases) { - const { flags } = computePlan([p], []); - for (const key of [ - "web", - "mobile", - "desktop", - "serverNode", - "packages", - "scripts", - "e2e", - "rustCore", - "rustApps", - ] as const) { - // Every named-package slice routes ALONE; `scripts` stays closed for - // the other packages with an empty changed-file list, and opens for - // website from the registration alone (`||=`, not `=`). `smoke` is - // excluded because server ∈ affected is ITS trigger too — its own - // test below pins that pair. The Rust pair is IN because nothing - // routes there by package: an affected set with no files must find - // both false. - expect(flags[key]).toBe(key === slice); - } - expect(flags.smoke).toBe(p.name === "@internal/server"); - } - }); - - test("any package under packages/ opens the packages slice, nested plugins included", () => { - const { flags } = computePlan([pkg("@subshell-ai/plugin-tailscale", "packages/plugins/tailscale")], []); - expect(flags.packages).toBe(true); - expect(flags.web).toBe(false); - // ...and the dependent half is turbo's job, not ours: the affected SET - // arrives with dependents already expanded, so a protocol change reaching - // web arrives here as server-web ∈ affected, tested by the case above. - }); - - test("the root pseudo-package in the affected set runs everything", () => { - // A changed global input (bun.lock, root tsconfig/biome, turbo.json) - // invalidates every hash; no slice may claim exemption. - const { flags, reason } = computePlan([pkg("//", "")], ["bun.lock"]); - expect(Object.values(flags).every(Boolean)).toBe(true); - expect(reason).toContain("global"); - }); - - test("an affected package with no registered slice runs everything", () => { - // The failure this guard exists for: someone adds a workspace and forgets - // to route it. Skipping its tests silently is the one wrong answer. - const { flags, reason } = computePlan([pkg("@internal/brand-new", "brand-new")], []); - expect(Object.values(flags).every(Boolean)).toBe(true); - expect(reason).toContain("brand-new"); - }); - - test("server in the affected set lights the smoke; a scripts change lights it too", () => { - const server = computePlan([pkg("@internal/server", "apps/server/api")], ["apps/server/api/src/x.ts"]); - expect(server.flags.smoke).toBe(true); - // The smoke job runs scripts/smoke-mcp-refusal.sh; a change THERE must - // run the smoke even though no package imported it. - const script = computePlan([], ["scripts/smoke-mcp-refusal.sh"]); - expect(script.flags.smoke).toBe(true); - expect(script.flags.scripts).toBe(true); - expect(script.flags.serverNode).toBe(false); - }); - - test("a docs-only diff answers every slice false", () => { - const { flags } = computePlan([], ["docs/superpowers/specs/x.md", "README.md"]); - expect(Object.values(flags).every((v) => v === false)).toBe(true); - }); - - test("the empty affected set with real changes still routes scripts by files", () => { - // e.g. a .github/workflows change touches no workspace but IS a change; - // scripts must not run for it, docs-only rules above already exclude it. - const { flags } = computePlan([], [".github/workflows/lint.yml"]); - expect(flags.scripts).toBe(false); - expect(flags.web).toBe(false); - }); - - test("a crates/desktop-core change lights BOTH Rust slices", () => { - // Path dependency, measured in each src-tauri/Cargo.toml - // (`subshell-desktop-core = { path = ... }`): the apps compile the core, - // so its change must relight their legs. Nothing else moves — crates/ is - // no package's dir and no script reads it. - const { flags } = computePlan([], ["crates/desktop-core/src/lib.rs"]); - expect(flags.rustCore).toBe(true); - expect(flags.rustApps).toBe(true); - expect(Object.entries(flags).every(([k, v]) => (k === "rustCore" || k === "rustApps") === v)).toBe(true); - }); - - test("an app's src-tauri change lights its slice only", () => { - // NOT core: the three Cargo projects are independent (each its own - // Cargo.lock, no root workspace) and core depends on neither app. - // `scripts` DOES open here — the design-linter/license predicate covers - // every apps/ file; the Rust pair is the assertion, not the bystanders. - const { flags } = computePlan([], ["apps/server/desktop/src-tauri/src/trust.rs"]); - expect(flags.rustApps).toBe(true); - expect(flags.rustCore).toBe(false); - const appLock = computePlan([], ["apps/client/desktop/src-tauri/Cargo.lock"]); - expect(appLock.flags.rustApps).toBe(true); - expect(appLock.flags.rustCore).toBe(false); + expect(computePlan(graph, ["packages/new/index.ts"]).packages).toEqual(["consumer", "new"]); }); - test("the builder image lights both Rust slices", () => { - // The toolchain itself: rustup + Tauri system deps are IN the image - // (docker/desktop-builder.Dockerfile), so an image change can break any - // cargo run regardless of which tree it touches. - const { flags } = computePlan([], ["docker/desktop-builder.Dockerfile"]); - expect(flags.rustCore).toBe(true); - expect(flags.rustApps).toBe(true); - expect(flags.scripts).toBe(false); - }); - - test("a test.yml change itself runs EVERYTHING", () => { - // The router cannot route its own change: gating the Rust jobs created a - // class of PR (an edit HERE) whose own proof is exactly what a router - // could mis-grey, so the self-reference answers all-true. - const { flags, reason } = computePlan([], [".github/workflows/test.yml"]); - expect(Object.values(flags).every(Boolean)).toBe(true); - expect(reason).toContain("CI definition"); - }); -}); - -describe("rustTouched", () => { - test("native helper changes reach both desktop builds", () => { - expect(rustTouched(["native/display-helper/Consent.swift"])).toEqual({ core: true, apps: true }); - }); - test("fires per the Cargo dependency arrows and the toolchain image", () => { - expect(rustTouched(["crates/desktop-core/src/lib.rs"])).toEqual({ core: true, apps: true }); - // Core first, app later: the early `{core:true, apps:true}` must not - // depend on iteration order of the reverse spelling either. - expect(rustTouched(["apps/client/desktop/src-tauri/src/cmd.rs", "crates/desktop-core/build.rs"])).toEqual({ - core: true, - apps: true, - }); - expect(rustTouched(["docker/desktop-builder.Dockerfile"])).toEqual({ core: true, apps: true }); - expect(rustTouched(["apps/server/desktop/src-tauri/Cargo.toml"])).toEqual({ core: false, apps: true }); - }); - - test("stays quiet for everything else, desktop TS included", () => { - // The trap this half exists for: the TS desktop packages live in the SAME - // directories as the Rust crates. A React edit under apps/*/desktop must - // not light the Rust jobs any more than a Rust edit lights `Test: desktop`. - expect(rustTouched(["apps/server/desktop/ui/src/main.tsx"])).toEqual({ core: false, apps: false }); - expect(rustTouched(["docs/release-and-ci.md"])).toEqual({ core: false, apps: false }); - }); -}); - -describe("scriptsTouched", () => { - test("fires on the files the script suites actually read", () => { - for (const f of [ - "scripts/lockfile-workspace-versions.ts", + test("unknown/deleted inputs, unavailable history, and verification changes run everything", () => { + for (const file of [ + "unknown.config", + "packages/removed/index.ts", "bun.lock", - "package.json", - "turbo.json", - "biome.jsonc", - ".changeset/config.json", - "packages/plugins/netbird/package.json", - "packages/subshell-protocol/package.json", - "apps/server/api/package.json", - "e2e/package.json", - // the design linter reads the token files; their drift fails its suites - "apps/server/web/src/styles/tokens.css", - // license-fields walks Cargo.toml under apps/ — covered by the apps/ rule - "apps/server/desktop/src-tauri/Cargo.toml", - ]) { - expect(scriptsTouched([f])).toBe(true); - } - }); - - test("stays quiet for files no script reads", () => { - for (const f of [ - "docs/anything.md", - "README.md", - "AGENTS.md", ".github/workflows/test.yml", - "crates/desktop-core/src/lib.rs", + ".github/workflows/release.yml", + "scripts/ci-test-plan.ts", + ".github/actions/setup-ci/action.yml", ]) { - expect(scriptsTouched([f])).toBe(false); + expect(plan([file]).full).toBe(true); } + expect(planChanges("nonexistent-test-base-123456").full).toBe(true); + expect(planChanges("").full).toBe(true); + }); + + test("full verification reaches every registered test script once and every extra tier", () => { + const selected = fullPlan(workspaces, "release"); + const lanes = unitLanes(selected, workspaces); + const names = lanes.flatMap((l) => l.packages).sort(); + expect(names).toEqual( + workspaces + .filter((w) => w.scripts.test) + .map((w) => w.name) + .sort(), + ); + expect(new Set(names).size).toBe(names.length); + expect(lanes.filter((l) => l.scripts)).toHaveLength(1); + expect(selected.e2e && selected.cli && selected.smoke && selected.audit).toBe(true); + expect(selected.rust).toEqual(RUST_CRATES); + expect(unitLanes(plan([]), workspaces)).toEqual([]); }); }); + +const root = mkdtempSync(join(tmpdir(), "subshell-change-plan-")); +afterAll(() => rmSync(root, { recursive: true, force: true })); +test("git selection includes both rename paths, staged/unstaged work, and untracked files", () => { + const git = (...args: string[]) => { + const result = Bun.spawnSync(["git", ...args], { cwd: root }); + expect(result.exitCode, result.stderr.toString()).toBe(0); + }; + git("init", "-q"); + writeFileSync(join(root, "old.ts"), "original"); + git("add", "."); + git("-c", "user.name=Test", "-c", "user.email=test@example.test", "commit", "-qm", "base"); + git("mv", "old.ts", "moved.ts"); + writeFileSync(join(root, "moved.ts"), "edited"); + writeFileSync(join(root, "new.ts"), "new"); + expect(changedFiles("HEAD", true, root)).toEqual(["moved.ts", "new.ts", "old.ts"]); + expect(changedFiles("HEAD", false, root)).toEqual([]); +}); + +test("the CLI writes machine-readable outputs and plans without dependencies", () => { + const output = join(root, "outputs"); + const result = Bun.spawnSync([process.execPath, "scripts/ci-test-plan.ts", "--full"], { + cwd: ROOT, + env: { ...process.env, GITHUB_OUTPUT: output }, + }); + expect(result.exitCode).toBe(0); + const parsed = JSON.parse(result.stdout.toString()); + expect(parsed.full).toBe(true); + const lines = readFileSync(output, "utf8").trim().split("\n"); + expect(lines.map((line: string) => line.slice(0, line.indexOf("=")))).toEqual([ + "plan", + "units", + "integration", + "rust", + ]); +}); diff --git a/scripts/__tests__/release-verification.test.ts b/scripts/__tests__/release-verification.test.ts new file mode 100644 index 000000000..95816e810 --- /dev/null +++ b/scripts/__tests__/release-verification.test.ts @@ -0,0 +1,47 @@ +import { expect, test } from "bun:test"; +import { readFileSync } from "node:fs"; + +// YAML is executable configuration here. Test the publication dependency graph, +// not incidental indentation or duplicated strings in comments. +const release = Bun.YAML.parse( + readFileSync(new URL("../../.github/workflows/release.yml", import.meta.url), "utf8"), +) as { jobs: Record }; +const verification = Bun.YAML.parse( + readFileSync(new URL("../../.github/workflows/test.yml", import.meta.url), "utf8"), +) as { + jobs: Record; + on: { workflow_call: { inputs: { full: { type: string } } } }; +}; + +test("tag creation and npm publishing require fresh, full verification", () => { + expect(release.jobs.verify?.uses).toBe("./.github/workflows/test.yml"); + expect(release.jobs.verify?.with?.full).toBe(true); + for (const name of ["plan", "npm-publish"]) expect(release.jobs[name]?.needs).toContain("verify"); + expect(verification.on.workflow_call.inputs.full.type).toBe("boolean"); + // Only pushes (empty cut) and a manifest refresh may bypass a skipped gate. + expect(release.jobs.plan?.if).toContain("needs.verify.result == 'success'"); + expect(release.jobs.plan?.if).toContain("github.event_name == 'push' || inputs.app == 'refresh'"); +}); + +test("the aggregate result waits for every test tier even after a failure", () => { + expect(verification.jobs.result?.if).toBe("always()"); + expect(verification.jobs.result?.needs?.toSorted()).toEqual([ + "checks", + "integration", + "macos", + "plan", + "rust", + "unit", + ]); +}); + +test("site releases also verify before creating tags", () => { + for (const site of ["docs", "website"]) { + const workflow = Bun.YAML.parse( + readFileSync(new URL(`../../.github/workflows/${site}.yml`, import.meta.url), "utf8"), + ) as typeof release; + expect(workflow.jobs.verify?.uses).toBe("./.github/workflows/test.yml"); + expect(workflow.jobs.verify?.with?.full).toBe(true); + expect(workflow.jobs.plan?.needs).toBe("verify"); + } +}); diff --git a/scripts/__tests__/rust-check-selection.test.ts b/scripts/__tests__/rust-check-selection.test.ts new file mode 100644 index 000000000..c1dd9b93e --- /dev/null +++ b/scripts/__tests__/rust-check-selection.test.ts @@ -0,0 +1,91 @@ +import { afterEach, expect, test } from "bun:test"; +import { + chmodSync, + copyFileSync, + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +const roots: string[] = []; +afterEach(() => { + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); +}); + +function fixture(fail = false) { + const root = mkdtempSync(join(tmpdir(), "subshell-rust-plan-")); + roots.push(root); + for (const dir of [ + "scripts", + "bin", + "native/display-helper", + "crates/desktop-core", + "apps/server/desktop/src-tauri", + "apps/client/desktop/src-tauri", + ]) + mkdirSync(join(root, dir), { recursive: true }); + copyFileSync(new URL("../rust-check.sh", import.meta.url), join(root, "scripts/rust-check.sh")); + const executable = (path: string, body: string) => { + writeFileSync(join(root, path), `#!/usr/bin/env bash\n${body}\n`); + chmodSync(join(root, path), 0o755); + }; + executable("scripts/macos-toolchain-preflight.sh", "exit 0"); + executable("native/display-helper/test.sh", "exit 0"); + executable("bin/rustc", "echo test-host"); + executable("bin/cargo", `printf '%s %s\\n' "$PWD" "$*" >> "$CHECK_LOG"\n${fail ? "exit 9" : "exit 0"}`); + const log = join(root, "calls"); + return { + root, + run: (...args: string[]) => { + const env: Record = { + ...process.env, + PATH: `${root}/bin:${process.env.PATH}`, + CHECK_LOG: log, + }; + delete env.SHELLOPTS; + delete env.BASHOPTS; + const result = Bun.spawnSync(["bash", join(root, "scripts/rust-check.sh"), ...args], { env }); + const calls = existsSync(log) ? readFileSync(log, "utf8") : ""; + return { result, calls }; + }, + }; +} + +test("one app runs only its own checks and removes its temporary sidecar", () => { + const fx = fixture(); + const { result, calls } = fx.run("apps/client/desktop/src-tauri"); + expect(result.exitCode, result.stderr.toString()).toBe(0); + expect(calls.trim().split("\n")).toHaveLength(3); + expect(calls).toContain("apps/client/desktop/src-tauri fmt --check"); + expect(calls).not.toContain("crates/desktop-core"); + expect(calls).not.toContain("apps/server/desktop"); + expect(existsSync(join(fx.root, "apps/client/desktop/src-tauri/binaries/subshell-node-bundled-test-host"))).toBe( + false, + ); +}); + +test("an unfiltered run covers all crates and leaves existing sidecars intact", () => { + const fx = fixture(); + const dir = join(fx.root, "apps/client/desktop/src-tauri/binaries"); + mkdirSync(dir); + const sidecar = join(dir, "subshell-node-bundled-test-host"); + writeFileSync(sidecar, "real binary"); + const { result, calls } = fx.run(); + expect(result.exitCode).toBe(0); + expect(calls.trim().split("\n")).toHaveLength(9); + expect(readFileSync(sidecar, "utf8")).toBe("real binary"); +}); + +test("failures stop verification, clean stubs, and unknown crates are refused", () => { + const fx = fixture(true); + expect(fx.run("apps/server/desktop/src-tauri").result.exitCode).toBe(9); + expect(existsSync(join(fx.root, "apps/server/desktop/src-tauri/binaries/subshell-server-bundled-test-host"))).toBe( + false, + ); + expect(fx.run("misspelled-crate").result.exitCode).toBe(1); +}); diff --git a/scripts/__tests__/test-plan-execution.test.ts b/scripts/__tests__/test-plan-execution.test.ts new file mode 100644 index 000000000..ac4ffb60e --- /dev/null +++ b/scripts/__tests__/test-plan-execution.test.ts @@ -0,0 +1,48 @@ +import { afterEach, expect, test } from "bun:test"; +import { chmodSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { computePlan, ROOT, readWorkspaces } from "../ci-test-plan"; + +const roots: string[] = []; +afterEach(() => { + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); +}); + +function execute(packages: string[], failure = 0) { + const root = mkdtempSync(join(tmpdir(), "subshell-plan-execution-")); + roots.push(root); + const trace = join(root, "trace"); + writeFileSync(trace, ""); + const fake = join(root, "bunx"); + writeFileSync(fake, `#!/usr/bin/env bash\nprintf '%s\\n' "$@" >> "$COMMAND_TRACE"\nexit ${failure}\n`); + chmodSync(fake, 0o755); + const plan = { ...computePlan(readWorkspaces(), []), packages }; + const env: Record = { + ...process.env, + PATH: `${root}:${process.env.PATH}`, + TEST_PLAN: JSON.stringify(plan), + TEST_LANE: JSON.stringify({ packages, scripts: false }), + COMMAND_TRACE: trace, + }; + delete env.SHELLOPTS; + delete env.BASHOPTS; + const result = Bun.spawnSync([process.execPath, "scripts/run-test-plan.ts", "unit"], { cwd: ROOT, env }); + return { result, argv: readFileSync(trace, "utf8").trim().split("\n").filter(Boolean) }; +} + +test("an empty unit selection never becomes an unfiltered Turbo invocation", () => { + const { result, argv } = execute([]); + expect(result.exitCode).toBe(0); + expect(argv).toEqual([]); +}); + +test("unit execution passes only selected packages and disables retries", () => { + const { result, argv } = execute(["@internal/assistant"]); + expect(result.exitCode, result.stderr.toString()).toBe(0); + expect(argv).toEqual(["turbo", "run", "test", "--filter=@internal/assistant", "--", "--retry=0"]); +}); + +test("a failing test command fails the orchestration process", () => { + expect(execute(["@internal/assistant"], 7).result.exitCode).not.toBe(0); +}); diff --git a/scripts/__tests__/website-workflow.test.ts b/scripts/__tests__/website-workflow.test.ts index 130fe46e3..4695bc25b 100644 --- a/scripts/__tests__/website-workflow.test.ts +++ b/scripts/__tests__/website-workflow.test.ts @@ -9,11 +9,7 @@ describe("website.yml structure", () => { expect(yml).toContain("refs/heads/main"); expect(yml).toContain("docs deploys off main only".replace("docs", "website")); }); - test("packages three jobs, website-scoped, and no gate", () => { - // `ci-gate:` went out on the operator's ruling of 2026-09-25 (a flaky job - // in an unrelated package blocked both site deploys), so the pin is now - // that NO gate job comes back. - expect(yml).not.toContain("ci-gate:"); + test("builds and deploys the website artifact", () => { for (const job of ["plan:", "build:", "deploy:"]) expect(yml).toContain(job); expect(yml).toContain("--filter=@internal/website --force"); expect(yml).toContain('tag="website-v$version"'); diff --git a/scripts/check-changed.ts b/scripts/check-changed.ts new file mode 100644 index 000000000..839f65d6c --- /dev/null +++ b/scripts/check-changed.ts @@ -0,0 +1,18 @@ +#!/usr/bin/env bun +/* biome-ignore-all lint/suspicious/noUndeclaredEnvVars: This driver runs outside Turbo. */ +import { planChanges } from "./ci-test-plan"; +import { run, turbo } from "./run-test-plan"; + +const plan = planChanges(process.env.SUBSHELL_CHECK_BASE ?? "origin/main", true); +console.log(`Checking ${plan.packages.length} affected workspaces: ${plan.reason}`); +switch (process.argv[2]) { + case "types": + turbo(["verify-types"], plan.packages); + break; + case "lint": + turbo(["lint:check"], plan.packages); + run(["bunx", "biome", "check", "scripts", "package.json", "turbo.json"]); + break; + default: + throw new Error("Expected types or lint"); +} diff --git a/scripts/ci-test-plan.ts b/scripts/ci-test-plan.ts index 6b1d755b5..4f3e1a04e 100644 --- a/scripts/ci-test-plan.ts +++ b/scripts/ci-test-plan.ts @@ -1,321 +1,250 @@ #!/usr/bin/env bun -/** - * Decide which of `test.yml`'s test-slice jobs a run actually needs. - * - * The workflow split the suites so a red job names its own territory; this is - * the other half — a green PR that touched one package should not pay for the - * other nine. Routing uses TURBO's OWN change-impact, never hand-written - * globs: `turbo run test --filter='...[BASE]'` answers "which packages changed - * since BASE, plus every package that depends on a changed one" from the real - * build graph. A `packages/subshell-protocol` edit therefore lands in the - * mobile slice automatically — which is the defect class `verification.md` - * records (a protocol change that broke ONLY the mobile build and nothing - * else), and exactly what directory globs on job triggers would re-hide. - * - * The script emits one `flag=true|false` line per slice to $GITHUB_OUTPUT. - * - * It fails LOUD-AND-WIDE: every path that cannot answer with confidence — an - * unreadable base ref (force-push, branch sync), a turbo error, a changed - * package that maps to no slice (a workspace added without registering it), - * an edit to `test.yml` itself (the router cannot route its own change) — - * returns ALL flags true, with the reason printed. A router that silently - * under-runs is worse than no router; the worst outcome here is today's CI. - * - * bun scripts/ci-test-plan.ts # writes $GITHUB_OUTPUT, echoes the plan - */ -import { appendFileSync } from "node:fs"; +/* biome-ignore-all lint/suspicious/noUndeclaredEnvVars: This driver runs outside Turbo. */ +/** Shared change selection for PRs and local verification. No install or build is needed to plan. */ +import { appendFileSync, readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; -/** The ten slice flags `test.yml` gates on, in the order the workflow reads them. */ -export interface SliceFlags { - web: boolean; - mobile: boolean; - desktop: boolean; - serverNode: boolean; - packages: boolean; +export const ROOT = fileURLToPath(new URL("..", import.meta.url)); +export interface Workspace { + name: string; + dir: string; + dependencies: string[]; + scripts: Record; +} +export interface TestPlan { + full: boolean; + reason: string; + packages: string[]; scripts: boolean; - smoke: boolean; e2e: boolean; - /** `crates/desktop-core` — the Rust job for the shared desktop crate. */ - rustCore: boolean; - /** the two desktop apps' `src-tauri/` trees — the Rust matrix legs. */ - rustApps: boolean; + /** Empty means the complete browser suite; selected specs always include account setup. */ + e2eFiles: string[]; + smoke: boolean; + cli: boolean; + rust: string[]; + audit: boolean; } +export const RUST_CRATES = ["crates/desktop-core", "apps/server/desktop/src-tauri", "apps/client/desktop/src-tauri"]; -/** One package the change-impact filter selected. */ -export interface AffectedPackage { - /** Package name as turbo reports it, e.g. `@internal/server-web`. */ - name: string; - /** Repo-relative directory, e.g. `apps/server/web`. */ - dir: string; +export function readWorkspaces(root = ROOT): Workspace[] { + const manifest = JSON.parse(readFileSync(`${root}/package.json`, "utf8")); + const paths = new Set(); + for (const pattern of manifest.workspaces as string[]) { + for (const path of new Bun.Glob(`${pattern}/package.json`).scanSync({ cwd: root })) paths.add(path); + } + return [...paths].sort().map((path) => { + const pkg = JSON.parse(readFileSync(`${root}/${path}`, "utf8")); + return { + name: pkg.name, + dir: path.slice(0, -"/package.json".length), + dependencies: Object.keys({ + ...pkg.dependencies, + ...pkg.devDependencies, + ...pkg.peerDependencies, + ...pkg.optionalDependencies, + }), + scripts: pkg.scripts ?? {}, + }; + }); } -const FLAG_KEYS: readonly (keyof SliceFlags)[] = [ - "web", - "mobile", - "desktop", - "serverNode", - "packages", - "scripts", - "smoke", - "e2e", - "rustCore", - "rustApps", -]; - -/** - * Workspace → slice, for the packages the impact filter reports by NAME. - * Everything under `packages/**` folds into the `packages` slice by directory - * (the plugins nest two deep to enumerate and gain new ones; the AGENTS.md - * vocabulary says one edit site beats two that drift). `scripts` and `smoke` - * are decided by the predicates below on top of this. - */ -const SLICE_BY_PACKAGE: Readonly> = { - "@internal/server-web": "web", - "@internal/mobile": "mobile", - "@internal/desktop-server": "desktop", - "@internal/desktop-client": "desktop", - "@internal/server": "serverNode", - "@internal/node": "serverNode", - "@internal/e2e": "e2e", - // The website's suites run in the Scripts job (its `bun test` needs no - // browser and no build beyond what that job already runs). Unregistered, a - // website change would land in the unknown-package fail-wide — every slice. - "@internal/website": "scripts", -}; - -/** The all-runs baseline every degraded path returns. */ -export function allFlags(): SliceFlags { +export function fullPlan(workspaces: readonly Workspace[], reason: string): TestPlan { return { - web: true, - mobile: true, - desktop: true, - serverNode: true, - packages: true, + full: true, + reason, + packages: workspaces.map((p) => p.name).sort(), scripts: true, - smoke: true, e2e: true, - rustCore: true, - rustApps: true, + e2eFiles: [], + smoke: true, + cli: true, + rust: [...RUST_CRATES], + audit: true, }; } -/** No flags at all — returned ONLY when the change is provably doc/config-only. */ -function noFlags(): SliceFlags { - return { - web: false, - mobile: false, - desktop: false, - serverNode: false, - packages: false, - scripts: false, - smoke: false, - e2e: false, - rustCore: false, - rustApps: false, - }; +const GLOBAL_INPUTS = + /^(?:package\.json|bun\.lock|bunfig\.toml|turbo\.json|tsconfig[^/]*\.json|biome\.jsonc|patches\/|docker\/desktop-builder\.Dockerfile|scripts\/(?:ci-test-plan|test-changed|check-changed|run-test-plan)\.ts|scripts\/par-test\.sh|\.github\/(?:actions\/|workflows\/(?:test|lint|release|docs|website)\.yml))/; +const TEST_INPUT = + /(?:^|\/)(?:__tests__|__snapshots__|test|tests)\/|\.(?:test|spec)\.[cm]?[jt]sx?$|(?:^|\/)test-(?:setup|preload)\.[jt]s$|(?:^|\/)scripts\/test-run\.sh$|(?:^|\/)bunfig\.toml$|\.bun-test-timings\.json$/; + +function documentation(path: string): boolean { + return /\.md$/.test(path) || /^(?:docs\/|\.claude\/|\.agents\/)/.test(path); } -/** - * Which files could the `Test: scripts` suites NOTICE? - * - * Those tests are not self-contained the way a package suite is — they READ - * the repo: `package-releases` enumerates every `package.json` under - * `packages/` and the CHANGELOGs beside them (its staleness made main red for - * three pushes when four plugins became publishable without the expectation - * moving), `license-fields` walks - * every package.json and Cargo.toml, `lockfile-workspace-versions` reads - * bun.lock, the design-linter reads the token files under `apps/`. So the - * trigger is "anything a script could have been asserting about", and a - * change to ANY of those must run them even though the suites live nowhere - * near the change. - */ +/** Scripts read a few repository contracts outside their own directory. */ export function scriptsTouched(files: readonly string[]): boolean { - for (const f of files) { - if (f.startsWith("scripts/")) return true; - if (f === "bun.lock" || f === "package.json" || f === "turbo.json" || f === "biome.jsonc") return true; - if (f.startsWith(".changeset/")) return true; - if (/(^|\/)package\.json$/.test(f) && (f.startsWith("packages/") || f.startsWith("apps/") || f.startsWith("e2e/"))) - return true; - if (f.startsWith("apps/") || f.startsWith("packages/")) return true; - } - return false; + return files.some( + (f) => + /^packages\/subshell-protocol\/src\/__tests__\/fixtures\/release-signature\//.test(f) || + /^apps\/server\/api\/src\/services\/releases\.ts$/.test(f) || + /^apps\/(?:server|client)\/desktop\/src-tauri\/tauri\.conf\.json$/.test(f) || + ((f.startsWith("scripts/") || !TEST_INPUT.test(f)) && + (/^(?:scripts\/|\.github\/|\.changeset\/config\.json|install-[^/]+\.sh|proxmox-[^/]+\.sh|docker(?:\/|-entrypoint\.sh)|releases\.json)/.test( + f, + ) || + /(?:^|\/)(?:package\.json|Cargo\.toml|bun\.lock)$/.test(f) || + /(?:^|\/)(?:tokens\.css|theme\.ts)$/.test(f) || + /^(?:packages\/subshell-protocol\/|apps\/(?:server|client)\/desktop\/src\/scripts\/)/.test(f))), + ); } -/** - * Which files could the two Rust jobs NOTICE? - * - * The three Cargo projects are INDEPENDENT — `crates/desktop-core`, - * `apps/server/desktop/src-tauri` and `apps/client/desktop/src-tauri` each - * carry their own `Cargo.lock` and there is no root workspace — so an app's - * change cannot light the core job, and one app cannot light the other's. - * But BOTH app crates depend on the core by PATH (measured in each - * src-tauri/Cargo.toml: `subshell-desktop-core = { path = "../../../../crates/desktop-core" }`), - * so a core change must relight the app legs, never the reverse. And the - * Rust toolchain itself lives in the builder IMAGE - * (`docker/desktop-builder.Dockerfile` installs rustup + Tauri's system - * deps), so `docker/**` lights both slices. - * - * Registered as a predicate HERE, in the router with a unit test, precisely - * so the workflow needs no YAML globs of its own — the rot objection to - * gating the Rust jobs (their inputs are not in the turbo graph, so package - * routing cannot reach them) is answered by WHERE the predicates live, not - * by leaving the jobs unconditional. - */ -export function rustTouched(files: readonly string[]): { core: boolean; apps: boolean } { - let apps = false; - for (const f of files) { - // Core or the toolchain image: both slices, and the answer cannot change - // with later files — early-return, like scriptsTouched. - if (f.startsWith("crates/desktop-core/") || f.startsWith("native/display-helper/") || f.startsWith("docker/")) - return { core: true, apps: true }; - if (f.startsWith("apps/server/desktop/src-tauri/") || f.startsWith("apps/client/desktop/src-tauri/")) apps = true; - } - return { core: false, apps }; +export function rustTouched(files: readonly string[]): string[] { + if ( + files.some( + (f) => + /^(?:crates\/desktop-core\/|native\/display-helper\/|docker\/desktop-builder\.Dockerfile|scripts\/(?:rust-check|macos-toolchain-preflight)\.sh)/.test( + f, + ) && !documentation(f), + ) + ) + return [...RUST_CRATES]; + return RUST_CRATES.filter((dir) => files.some((f) => f.startsWith(`${dir}/`) && !documentation(f))); } -/** - * Pure routing: the affected set + the changed-file list in, the flags out. - * `reason` is what gets printed beside the flags — "why ten greys or six - * greens", so a mis-routed PR is debuggable from the Plan log alone. - */ -export function computePlan( - affected: readonly AffectedPackage[], - changedFiles: readonly string[], -): { flags: SliceFlags; reason: string } { - const names = new Set(affected.map((p) => p.name)); - - // A change to the CI definition itself: routing the jobs THAT FILE defines - // from rules THAT FILE changed is self-reference — the diff being routed - // includes the router's contract. Today it would still run the Rust jobs - // unconditionally; after the 2026-09-26 gating, running everything is the - // only answer that lets a workflow PR prove itself. Grey jobs that a - // test.yml edit should have lit are how a CI change merges on faith. - if (changedFiles.some((f) => f === ".github/workflows/test.yml")) { - return { flags: allFlags(), reason: "the CI definition itself changed — the router cannot route its own change" }; - } - - // The root pseudo-package in the affected set means a GLOBAL input changed - // (lockfile, root tsconfig/biome, turbo.json) — turbo invalidated everything - // it hashes; no slice may claim exemption. - if (names.has("//")) - return { flags: allFlags(), reason: "global inputs changed (root package appears in affected set)" }; - - // Unknown packages: a workspace added without registering here. Running all - // slices is the loud-and-wide failure; skipping it would be the silent one. - const unknown = affected.filter((p) => !(p.name in SLICE_BY_PACKAGE) && !p.dir.startsWith("packages/")); - if (unknown.length > 0) { - return { - flags: allFlags(), - reason: `unregistered affected packages (add them to SLICE_BY_PACKAGE): ${unknown.map((p) => p.name).join(", ")}`, - }; +export function computePlan(workspaces: readonly Workspace[], files: readonly string[]): TestPlan { + if (files.some((f) => GLOBAL_INPUTS.test(f))) + return fullPlan(workspaces, "shared toolchain or verification inputs changed"); + const direct = new Set(); + const changedCode = new Set(); + const owners = [...workspaces].sort((a, b) => b.dir.length - a.dir.length); + for (const file of files) { + if (documentation(file)) continue; + // These build inputs are copied/read at runtime rather than imported through a workspace edge. + if (/^(?:releases\.json|install-(?:server|client)\.sh)$/.test(file)) direct.add("@internal/website"); + if (/^scripts\/(?:cli-release-fetch|updater-manifest)\.ts$/.test(file)) { + direct.add("@internal/desktop-server"); + direct.add("@internal/desktop-client"); + } + if (/^(?:LICENSE|apps\/server\/LICENSE)$/.test(file)) direct.add("@internal/subshell-protocol"); + // Desktop JS tests also check Rust IPC/wire contracts and signing configuration. + if ( + /^crates\/desktop-core\//.test(file) || + /^scripts\/macos-entitlements\.plist$/.test(file) || + /^apps\/(?:server|client)\/desktop\/src-tauri\/(?:tauri\.conf\.json|entitlements\.plist)$/.test(file) + ) { + direct.add("@internal/desktop-server"); + direct.add("@internal/desktop-client"); + } + if (file === "apps/server/web/src/styles.css") direct.add("@internal/desktop-client"); + if (file === "apps/server/web/src/types/permissions.ts") direct.add("@internal/desktop-server"); + const owner = owners.find((p) => file.startsWith(`${p.dir}/`)); + if (owner) { + direct.add(owner.name); + // Rust runs the app's JS contract tests, but doesn't propagate as a JS dependency change. + if (!TEST_INPUT.test(file) && !file.includes("/src-tauri/")) changedCode.add(owner.name); + continue; + } + if ( + /^(?:scripts\/|\.github\/|\.changeset\/|crates\/|native\/|docker\/|brand\/)/.test(file) || + /^(?:[^/]+\.md|LICENSE|CLA\.md|\.gitignore|\.gitattributes|lefthook\.yml|\.bun-test-timings\.json|releases\.json|(?:install|proxmox)-[^/]+\.sh|docker-entrypoint\.sh)$/.test( + file, + ) + ) + continue; + // A deleted/new workspace or an unmodelled root input must never silently disappear. + return fullPlan(workspaces, `unclassified input: ${file}`); } - - const flags = noFlags(); - for (const p of affected) { - const slice = SLICE_BY_PACKAGE[p.name]; - if (slice) flags[slice] = true; - if (p.dir.startsWith("packages/")) flags.packages = true; + const affected = new Set(changedCode); + let growing = true; + while (growing) { + growing = false; + for (const p of workspaces) { + if (!affected.has(p.name) && p.dependencies.some((d) => affected.has(d))) { + affected.add(p.name); + growing = true; + } + } } - - const serverAffected = names.has("@internal/server"); - // `||=`: a package registered to this slice (website) must not be erased by - // the file predicate, which starts from the changed FILES and would answer - // false for an affected-set-only trigger. - flags.scripts ||= scriptsTouched(changedFiles); - // The compiled-binary smoke bundles the server AND everything it imports; - // `@internal/server` ∈ affected IS that closure (dependents-of-changed is - // how the filter is built). `scripts/**` covers the smoke's own shell script. - flags.smoke = serverAffected || changedFiles.some((f) => f.startsWith("scripts/")); - // Plain `=`, not `||=`: no package registers into these two — the Rust - // jobs' inputs are not in the turbo graph at all, so the file predicate is - // the ONLY trigger they can have (see {@link rustTouched}). - const rust = rustTouched(changedFiles); - flags.rustCore = rust.core; - flags.rustApps = rust.apps; - - const why: string[] = []; - for (const k of FLAG_KEYS) if (flags[k]) why.push(k); + const packages = [...new Set([...direct, ...affected])].sort(); + const e2eCode = affected.has("@internal/e2e"); + const e2eFiles = files.filter((f) => /^e2e\/tests\/.*\.spec\.ts$/.test(f)); + const e2e = e2eCode || e2eFiles.length > 0; + // A fixture or Playwright configuration change affects all scenarios, even without an import edge. + const wholeBrowserSuite = + e2eCode || files.some((f) => f.startsWith("e2e/") && !documentation(f) && !e2eFiles.includes(f)); + const cli = files.some( + (f) => + /^(?:scripts\/cli-e2e\/|apps\/node\/web\/|install-(?:server|client)\.sh|apps\/server\/api\/src\/commands\/|apps\/node\/agent\/src\/(?:cli|commands|configure|setup|enroll|service|update|backup))/.test( + f, + ) && !TEST_INPUT.test(f), + ); return { - flags, - reason: why.length ? `slices with work: ${why.join(", ")}` : "nothing a test slice reads has changed", + full: false, + reason: packages.length ? "changed workspaces and their dependents" : "repository checks only", + packages, + scripts: scriptsTouched(files), + e2e, + e2eFiles: e2e && !wholeBrowserSuite ? [...new Set(["e2e/tests/01-setup-wizard.spec.ts", ...e2eFiles])].sort() : [], + smoke: + affected.has("@internal/server") || + files.some((f) => /^scripts\/(?:smoke-mcp-refusal|test-compiled-smoke)\.sh$/.test(f)), + cli, + rust: rustTouched(files), + audit: files.some((f) => /(?:^|\/)(?:package\.json|bun\.lock)$/.test(f) || f.startsWith("scripts/dep-audit")), }; } -/** Run a command, returning stdout; null on any failure (the caller fails wide). */ -function run(args: string[]): string | null { - const proc = Bun.spawnSync({ cmd: args, cwd: new URL("..", import.meta.url).pathname }); - if (proc.exitCode !== 0) return null; - return proc.stdout.toString(); +function git(args: string[], root: string): string { + const result = Bun.spawnSync(["git", ...args], { cwd: root, stderr: "pipe" }); + if (result.exitCode !== 0) throw new Error(`git ${args[0]} failed: ${result.stderr.toString().trim()}`); + return result.stdout.toString(); } -function main(): void { - const out: Record = {}; - const failWide = (reason: string): never => { - console.log(`PLAN: ${reason} — running every slice`); - for (const k of FLAG_KEYS) out[k] = "true"; - emit(out); - process.exit(0); - }; - - const base = process.argv[2]; - if (!base) failWide("no base ref given (not a PR/push we can diff)"); - - // A ref the clone cannot see (force-push, shallow history, deleted head) is - // exactly the case a router must not guess about. - if (run(["git", "rev-parse", "--verify", "--quiet", `${base}^{commit}`]) === null) { - failWide(`base ref ${base} is not resolvable in this checkout`); +export function changedFiles(base: string, local = false, root = ROOT): string[] { + if (!base) throw new Error("No base revision was provided"); + git(["rev-parse", "--verify", "--quiet", `${base}^{commit}`], root); + // Include both sides of renames so moving a file between workspaces checks both consumers. + const files = git(["diff", "--name-only", "--no-renames", "-z", `${base}...HEAD`], root).split("\0"); + if (local) { + files.push(...git(["diff", "--name-only", "--no-renames", "-z", "HEAD"], root).split("\0")); + files.push(...git(["ls-files", "--others", "--exclude-standard", "-z"], root).split("\0")); } + return [...new Set(files.filter(Boolean))].sort(); +} - const diff = run(["git", "diff", "--name-only", `${base}...HEAD`]); - if (diff === null) failWide(`git diff against ${base} failed`); - const changedFiles = diff.split("\n").filter((f) => f.length > 0); - - if (changedFiles.length === 0) { - // Empty diff, legitimately: an empty push or a merge-base that IS HEAD. - console.log("PLAN: no changed files — nothing to run"); - for (const k of FLAG_KEYS) out[k] = "false"; - emit(out); - process.exit(0); - } - - const dry = run(["bunx", "turbo", "run", "test", `--filter=...[${base}]`, "--dry=json"]); - if (dry === null) failWide(`turbo --filter=...[${base}] failed`); - - let affected: AffectedPackage[]; +export function planChanges(base: string, local = false, full = false, root = ROOT): TestPlan { + const workspaces = readWorkspaces(root); + if (full) return fullPlan(workspaces, "full verification requested"); try { - const parsed = JSON.parse(dry) as { tasks?: { task?: string; package?: string; directory?: string }[] }; - const seen = new Map(); - for (const t of parsed.tasks ?? []) { - if (t.task !== "test" || typeof t.package !== "string") continue; - seen.set(t.package, { name: t.package, dir: t.directory ?? "" }); - } - affected = [...seen.values()]; - } catch { - failWide("turbo --dry output was not parseable JSON"); + return computePlan(workspaces, changedFiles(base, local, root)); + } catch (error) { + return fullPlan(workspaces, `cannot establish changes: ${String(error)}`); } - - const { flags, reason } = computePlan(affected, changedFiles); - console.log(`PLAN: ${reason}`); - for (const k of FLAG_KEYS) { - out[k] = flags[k] ? "true" : "false"; - console.log(` ${k}=${out[k]}`); - } - emit(out); - process.exit(0); } -/** - * Append the flags where Actions reads them. Synchronous on purpose — every - * caller follows with `process.exit`, and a pending `Bun.write` would die on - * the way out and leave the plan job emitting empty outputs. - */ -function emit(flags: Record): void { - const target = process.env.GITHUB_OUTPUT; - if (!target) return; - const body = Object.entries(flags) - .map(([k, v]) => `${k}=${v}`) - .join("\n"); - appendFileSync(target, `${body}\n`); +/** Bound job setup cost to three unit jobs, regardless of the number of workspaces. */ +export function unitLanes(plan: TestPlan, workspaces: readonly Workspace[]) { + const tested = plan.packages.filter((name) => workspaces.find((p) => p.name === name)?.scripts.test); + const groups = [ + { + name: "server", + packages: tested.filter((n) => n === "@internal/server" || n === "@internal/node"), + scripts: false, + }, + { name: "web", packages: tested.filter((n) => n === "@internal/server-web"), scripts: false }, + { + name: "other", + packages: tested.filter((n) => !["@internal/server", "@internal/node", "@internal/server-web"].includes(n)), + scripts: plan.scripts, + }, + ]; + return groups.filter((g) => g.packages.length || g.scripts); } -if (import.meta.main) main(); +if (import.meta.main) { + const full = process.argv.includes("--full"); + const base = process.argv[2] ?? "origin/main"; + const plan = planChanges(base, process.argv.includes("--local"), full); + console.log(JSON.stringify(plan, null, 2)); + if (process.env.GITHUB_OUTPUT) { + const output = { + plan, + units: unitLanes(plan, readWorkspaces()), + integration: plan.e2e || plan.cli || plan.smoke, + rust: plan.rust.length > 0, + }; + for (const [key, value] of Object.entries(output)) + appendFileSync(process.env.GITHUB_OUTPUT, `${key}=${JSON.stringify(value)}\n`); + } +} diff --git a/scripts/par-test.sh b/scripts/par-test.sh index ebf904bc5..9292efd68 100755 --- a/scripts/par-test.sh +++ b/scripts/par-test.sh @@ -3,13 +3,13 @@ # # Default is parallel N (first argument; per-package, measured). Two overrides: # SUBSHELL_TEST_PARALLEL changes the width, and SUBSHELL_TEST_SERIAL=1 omits -# the flag entirely - which CI does. CI's omission is not caution: bun's +# the flag entirely - which CI does. Bun's # --parallel=1 is NOT serial (it still implies --isolate, re-importing each # file's modules), and the hosted container runs on a fraction of its 4 labels # (0.4 CPU, measured in apps/server/web's 2026-09-17 budgets and again # 2026-09-30, where width 1 stretched a 2.5 s react-act chain past 25 s). The -# suite-level budgets starve at any worker width there; serial is the regime -# that passes. Developers get the width; CI gets determinism. +# suite-level budgets starved at any worker width there. Keep that serial +# regime until the new bare hosted jobs have representative parallel timings. # # The flag must be omittable, and neither bun's script shell (no ${VAR:+}) nor # a package.json string can express omission - hence a script, called as @@ -24,14 +24,5 @@ sub="$2" shift 2 if [ -z "${SUBSHELL_TEST_SERIAL:-}" ]; then set -- --parallel="$n" "$@" -else - # CI's serial regime gets one automatic retry, and it is for a measured - # reason: the hosted runner throttles the whole container below its 4 - # labels (a pure-logic 20 ms test took 5.6 s and died on bun's 5 s default, - # PR #278's web job, 2026-09-30, WITH width 1), so a failed test there - # cannot be told apart from a starved one by looking at it. A real - # assertion fails twice; a starved budget usually does not. Local runs - # deliberately keep no retry - a developer's flake should stay visible. - set -- --retry=1 "$@" fi exec "$prog" "$sub" "$@" diff --git a/scripts/run-test-plan.ts b/scripts/run-test-plan.ts new file mode 100644 index 000000000..dd68e090a --- /dev/null +++ b/scripts/run-test-plan.ts @@ -0,0 +1,80 @@ +#!/usr/bin/env bun +/* biome-ignore-all lint/suspicious/noUndeclaredEnvVars: This driver runs outside Turbo. */ +import { ROOT, readWorkspaces, type TestPlan } from "./ci-test-plan"; + +/** Spawn argv directly: package names and changed paths must never become shell code. */ +export function run(command: string[], cwd = ROOT): void { + console.log(`> ${command.join(" ")}`); + const env = { ...process.env }; + delete env.SHELLOPTS; + delete env.BASHOPTS; + const child = Bun.spawnSync(command, { cwd, env, stdin: "inherit", stdout: "inherit", stderr: "inherit" }); + if (child.exitCode !== 0) throw new Error(`${command[0]} exited ${child.exitCode}`); +} + +export function turbo(tasks: string[], packages: string[], extra: string[] = []): void { + // No filters means ALL packages to Turbo, so an empty selection must stop here. + if (!packages.length) return; + run(["bunx", "turbo", "run", ...tasks, ...packages.map((name) => `--filter=${name}`), ...extra]); +} + +export function runUnit(plan: TestPlan, packages = plan.packages, scripts = plan.scripts): void { + const workspaces = readWorkspaces(); + const selected = packages.filter((name) => workspaces.find((p) => p.name === name)?.scripts.test); + // Tests aren't cached. Full verification also rebuilds dependencies from this revision. + turbo(["test"], selected, [...(plan.full ? ["--force"] : []), "--", "--retry=0"]); + if (scripts) { + turbo(["build"], ["@internal/subshell-protocol"]); + run(["bun", "run", "test:scripts"]); + } +} + +export function runChecks(plan: TestPlan): void { + // Build the selected consumers too: a type check alone misses Metro/browser bundling failures. + turbo(["build", "verify-types", "lint:check"], plan.packages, plan.full ? ["--force"] : []); + if (plan.packages.includes("@internal/docs")) run(["bun", "run", "--cwd", "apps/docs", "verify:export"]); + run(["bunx", "biome", "check", "scripts", "package.json", "turbo.json"]); + for (const check of ["lint:packages", "lint:lockfile", "lint:licenses", "lint:design", "lint:prose"]) + run(["bun", "run", check]); + if (plan.audit) run(["bun", "run", "audit:deps"]); +} + +export function runIntegration(plan: TestPlan): void { + if (plan.smoke || plan.cli) { + turbo(["build"], ["@internal/server", "@internal/node", "@internal/server-web", "@internal/node-web"]); + if (plan.cli) run(["bun", "run", "test:cli"]); + if (plan.smoke) run(["bash", "scripts/test-compiled-smoke.sh"]); + } + if (plan.e2e) { + const args = plan.e2eFiles.map((f) => f.replace(/^e2e\//, "")); + turbo(["test:e2e"], ["@internal/e2e"], ["--", ...(plan.full ? ["--retries=0"] : []), ...args]); + } +} + +export function runRust(plan: TestPlan): void { + if (plan.rust.length) run(["bash", "scripts/rust-check.sh", ...plan.rust]); +} + +if (import.meta.main) { + const plan = JSON.parse(process.env.TEST_PLAN ?? "null") as TestPlan | null; + if (!plan) throw new Error("TEST_PLAN is required; use test:changed for local runs"); + switch (process.argv[2]) { + case "checks": + runChecks(plan); + break; + case "unit": { + const lane = JSON.parse(process.env.TEST_LANE ?? "null") as { packages: string[]; scripts: boolean } | null; + if (!lane) throw new Error("TEST_LANE is required"); + runUnit(plan, lane.packages, lane.scripts); + break; + } + case "integration": + runIntegration(plan); + break; + case "rust": + runRust(plan); + break; + default: + throw new Error("Expected checks, unit, integration, or rust"); + } +} diff --git a/scripts/rust-check.sh b/scripts/rust-check.sh index 16cd44776..f79c61cb4 100755 --- a/scripts/rust-check.sh +++ b/scripts/rust-check.sh @@ -1,15 +1,15 @@ #!/usr/bin/env bash # # Run exactly what CI's Rust jobs run — `cargo fmt --check`, then -# `cargo clippy --all-targets -- -D warnings`, then `cargo test` — across -# `crates/desktop-core` and both Tauri apps. +# `cargo clippy --all-targets -- -D warnings`, then `cargo test` — in the selected +# crates. No arguments selects desktop-core and both Tauri apps. # # This exists because the two app crates CANNOT be compiled from a clean # checkout. `tauri-build` refuses to build when an `externalBin` path is # missing, and the staged sidecar is a gitignored ~110 MB build input, so # `cargo clippy` in `apps/server/desktop/src-tauri` dies in the build script # rather than reporting a lint. CI works around it by staging a STUB -# (.github/workflows/test.yml, "Stage a stub sidecar"); nothing did so +# (.github/workflows/test.yml); nothing did so # locally, which is how a formatting failure reached CI instead of being # caught in two seconds — the shape of bug this script exists to prevent. # @@ -34,8 +34,7 @@ env -u SHELLOPTS -u BASHOPTS bash "$ROOT/native/display-helper/test.sh" TRIPLE="$(rustc --print host-tuple)" -# dir : sidecar stem. The same pairing lives in test.yml's matrix and in each -# app's tauri.conf.json; the stem names the binary the app WRAPS, not the app. +# dir : sidecar stem. Each app's tauri.conf.json carries the same stem; the stem names the binary the app WRAPS, not the app. APPS=( "apps/server/desktop:subshell-server-bundled" "apps/client/desktop:subshell-node-bundled" @@ -61,10 +60,27 @@ run_crate() { ) } -run_crate "$ROOT/crates/desktop-core" +selected() { + [ "$#" -eq 0 ] && return 0 + local wanted="$1" + shift + [ "$#" -eq 0 ] && return 0 + local candidate + for candidate in "$@"; do [ "$candidate" = "$wanted" ] && return 0; done + return 1 +} + +for requested in "$@"; do + case "$requested" in + crates/desktop-core|apps/server/desktop/src-tauri|apps/client/desktop/src-tauri) ;; + *) echo "Unknown Rust crate: $requested" >&2; exit 1 ;; + esac +done +if selected crates/desktop-core "$@"; then run_crate "$ROOT/crates/desktop-core"; fi for entry in "${APPS[@]}"; do dir="${entry%%:*}" + selected "$dir/src-tauri" "$@" || continue sidecar="${entry##*:}" bin_dir="$ROOT/$dir/src-tauri/binaries" stub="$bin_dir/$sidecar-$TRIPLE" @@ -80,4 +96,4 @@ for entry in "${APPS[@]}"; do done echo -echo "✓ fmt, clippy (-D warnings) and tests pass in all three Rust crates" +echo "✓ fmt, clippy (-D warnings) and tests pass in the selected Rust crates" diff --git a/scripts/test-changed.ts b/scripts/test-changed.ts new file mode 100644 index 000000000..d88bd453a --- /dev/null +++ b/scripts/test-changed.ts @@ -0,0 +1,27 @@ +#!/usr/bin/env bun +import { planChanges } from "./ci-test-plan"; +import { runChecks, runIntegration, runRust, runUnit } from "./run-test-plan"; + +const args = process.argv.slice(2); +const known = new Set(["--full", "--plan", "--checks", "--unit", "--base"]); +for (let i = 0; i < args.length; i++) { + if (!known.has(args[i] ?? "")) throw new Error(`Unknown argument: ${args[i]}`); + if (args[i] === "--base" && !args[++i]) throw new Error("--base needs a git ref"); +} +if (args.includes("--full") && args.includes("--unit")) + throw new Error("Full verification cannot omit integration or native tests"); +const baseIndex = args.indexOf("--base"); +const plan = planChanges( + baseIndex < 0 ? "origin/main" : (args[baseIndex + 1] ?? "origin/main"), + true, + args.includes("--full"), +); +console.log(JSON.stringify(plan, null, 2)); +if (!args.includes("--plan")) { + if (args.includes("--checks") || plan.full) runChecks(plan); + runUnit(plan); + if (!args.includes("--unit")) { + runIntegration(plan); + runRust(plan); + } +} diff --git a/scripts/test-compiled-smoke.sh b/scripts/test-compiled-smoke.sh new file mode 100644 index 000000000..33dc82ad7 --- /dev/null +++ b/scripts/test-compiled-smoke.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +# Exercise the bundle without touching the operator's config or service. +set -euo pipefail +root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$root/apps/server/api" +bun run compile +bin="$PWD/dist/subshell-server" +expected="subshell-server $(jq -r .version package.json)" +actual="$("$bin" version)" +[ "$actual" = "$expected" ] || { echo "version mismatch: got '$actual', want '$expected'"; exit 1; } +bash "$root/scripts/smoke-mcp-refusal.sh" "$bin" +work="$(mktemp -d)" +trap 'rm -rf "$work"' EXIT +cp "$bin" "$work/renamed-server" +status_out="$(SERVER_PORT=31997 SUBSHELL_SERVER_CONFIG_DIR="$work/config" SUBSHELL_SERVER_DATA_DIR="$work/data" "$work/renamed-server" status 2>&1 || true)" +if printf '%s' "$status_out" | grep -q 'via self'; then + echo 'renamed compiled binary resolved its MCP entrypoint from the build path' + exit 1 +fi +echo 'Compiled smoke passed: version, MCP refusal, renamed binary' diff --git a/turbo.json b/turbo.json index cdd4639f3..9bad5da4a 100644 --- a/turbo.json +++ b/turbo.json @@ -52,5 +52,8 @@ "persistent": true }, "generate": {} - } + }, + "globalPassThroughEnv": [ + "SUBSHELL_TEST_*" + ] } From c72ba7c0507f94c855b4f7ef5994ab85174861de Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Sun, 25 Oct 2026 22:33:50 -0700 Subject: [PATCH 2/5] test: remove low-value assertions and document behavior-focused testing --- .claude/rules/testing.md | 33 ++-- AGENTS.md | 4 +- .../ui/src/__tests__/choice-screen.test.tsx | 10 -- .../__tests__/node-assistant-state.test.ts | 53 ------ .../ui/src/__tests__/probe-facts.test.ts | 1 - .../ui/src/__tests__/startup-screen.test.tsx | 6 - .../ui/src/__tests__/unenroll-gate.test.ts | 13 +- .../ui/src/__tests__/welcome-screen.test.tsx | 11 -- apps/docs/src/__tests__/seo.test.ts | 12 -- .../api/src/__tests__/auth-lazy.test.ts | 6 - .../__tests__/auth-secret-boot-guard.test.ts | 16 +- apps/server/api/src/__tests__/banner.test.ts | 45 ----- .../api/__tests__/auth-guard-bearer.test.ts | 10 +- .../src/api/__tests__/auth-rate-limit.test.ts | 3 +- .../src/api/__tests__/emergency-login.test.ts | 27 ++- .../api/src/lib/__tests__/context.test.ts | 10 +- .../api/src/ws/__tests__/ws-token.test.ts | 11 +- .../__tests__/detail-back-header.test.tsx | 88 --------- .../__tests__/network-plugin-card.test.tsx | 32 ---- .../__tests__/qr-link-dialog.test.tsx | 11 -- .../__tests__/installed-plugins-card.test.tsx | 5 - .../__tests__/install-column.test.tsx | 9 - apps/website/lib/__tests__/install.test.ts | 32 +--- apps/website/lib/__tests__/seo.test.ts | 16 -- docs/testing.md | 169 ++++++++++++++++-- e2e/tests/02-auth-guard.spec.ts | 13 -- e2e/tests/09-mobile-navcrash.spec.ts | 54 ------ e2e/tests/18-server-networking.spec.ts | 10 -- .../assistant/src/__tests__/frame.test.tsx | 8 - .../mcp-core/src/__tests__/server.test.ts | 63 +------ .../__tests__/node-maintenance-card.test.tsx | 10 -- .../src/__tests__/node-maintenance.test.ts | 10 +- .../src/__tests__/ssh-trust-card.test.tsx | 6 - .../src/__tests__/cloudflare-tunnel.test.ts | 14 -- .../headscale/src/__tests__/headscale.test.ts | 12 -- scripts/cli-e2e/install-script.sh | 4 + 36 files changed, 205 insertions(+), 632 deletions(-) delete mode 100644 apps/docs/src/__tests__/seo.test.ts delete mode 100644 apps/server/web/src/components/__tests__/detail-back-header.test.tsx delete mode 100644 apps/website/lib/__tests__/seo.test.ts delete mode 100644 e2e/tests/09-mobile-navcrash.spec.ts diff --git a/.claude/rules/testing.md b/.claude/rules/testing.md index 43b35f8e2..bae5e485f 100644 --- a/.claude/rules/testing.md +++ b/.claude/rules/testing.md @@ -4,20 +4,29 @@ Read [docs/testing.md](../../docs/testing.md) for the shared policy and commands ## Test value -Add a test when it can catch a plausible regression in behavior we own. Name -that failure before writing the test. Prefer an existing suite at the narrowest -boundary that observes the result. Authorization, privacy, destructive actions, -migrations, protocol compatibility, and asynchronous ordering deserve focused -coverage, including the relevant refusal path. +Correctness is the goal; test count and coverage percentage are not. Add a test +when it catches a plausible future bug in behavior we own. Name that failure +and the assertion that would catch it before writing the test. A test is also +an executable record of expected behavior: make its scenario and observable +outcome clear, and assert state, data, requests, side effects, or refusal rather +than only that execution completed. Prioritize +failures involving authorization, privacy, data loss, protocol/artifact +compatibility, process lifecycle, cleanup, and asynchronous ordering. Use the +boundary where the regression becomes observable, and test the refusal path +when it can fail independently. -Do not require a test per function, component, hook, or file. Do not add tests -for types already checked by TypeScript, copied implementation logic, fixed -copy, CSS classes, trivial prop forwarding, or third-party behavior. Review -layout in a browser. Keep accessibility and interaction tests where they verify -our behavior. When one test already catches the regression, another layer needs -a distinct failure to justify its setup and maintenance cost. +Do not require a test per function, component, hook, branch, or file. Do not add +tests for types already checked by TypeScript, copied implementation logic, +fixed explanatory copy, CSS classes, trivial prop forwarding, or third-party +behavior. Test text only when it is itself a behavior contract, such as a wire +or CLI format, machine-readable error, or safety warning whose mismatch could +lead to an unsafe action. Review routine wording and visual layout as content. +Keep accessibility and interaction tests when they verify behavior we own. A +second layer must catch a distinct failure; if one test already catches the +regression, another copy of that assertion adds no confidence. -For a bug fix, reproduce the failure at the appropriate boundary when practical. +For a bug fix, reproduce the failure at the appropriate boundary when practical +and confirm the regression assertion would fail against the buggy behavior. Prefer adding a case to an existing suite over creating a new file and preload. Do not increase test count or coverage percentages as an end in itself. diff --git a/AGENTS.md b/AGENTS.md index a252054c9..f8b0f6975 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -499,7 +499,9 @@ turbo build # Build all packages ### Testing -Choose checks by the behavior the change can break. **Do not run the full +Tests should catch plausible regressions and record expected behavior through +clear scenarios and observable outcomes. Choose checks by the behavior the +change can break. **Do not run the full suite just because a task is ending, a PR is opening, or an agent is handing off.** Start with existing covering files; run affected packages once before handoff. Pure prose needs the relevant prose/content checks, not product tests. Copy diff --git a/apps/client/desktop/ui/src/__tests__/choice-screen.test.tsx b/apps/client/desktop/ui/src/__tests__/choice-screen.test.tsx index 147bf8e2c..d63bc3464 100644 --- a/apps/client/desktop/ui/src/__tests__/choice-screen.test.tsx +++ b/apps/client/desktop/ui/src/__tests__/choice-screen.test.tsx @@ -45,16 +45,6 @@ describe("the Choice screen", () => { expect(chosen).toEqual(["watch"]); }); - /** - * The consequence, not the mechanism: the whole reason to ask is that one - * of these two registers this machine and the other does not. - */ - it("says what each option does to this machine", () => { - renderChoice(); - expect(screen.getByText(/Registers this machine as a node/)).toBeTruthy(); - expect(screen.getByText(/without installing anything or registering this machine/)).toBeTruthy(); - }); - it("disables both options while an action is in flight", () => { const chosen = renderChoice(true); for (const button of screen.getAllByRole("button") as HTMLButtonElement[]) { diff --git a/apps/client/desktop/ui/src/__tests__/node-assistant-state.test.ts b/apps/client/desktop/ui/src/__tests__/node-assistant-state.test.ts index 75db81958..ecf56740f 100644 --- a/apps/client/desktop/ui/src/__tests__/node-assistant-state.test.ts +++ b/apps/client/desktop/ui/src/__tests__/node-assistant-state.test.ts @@ -27,35 +27,10 @@ describe("screenTitle", () => { } }); - it("walks the first run in the assistant's voice", () => { - expect(screenTitle("welcome")).toBe("Welcome to Subshell Client"); - expect(screenTitle("choice")).toBe("What Would You Like to Do?"); - expect(screenTitle("tmux")).toBe("Install tmux"); - expect(screenTitle("register")).toBe("Register This Machine"); - expect(screenTitle("startup")).toBe("How This Node Runs"); - expect(screenTitle("progress")).toBe("Setting Up…"); - expect(screenTitle("status")).toBe("Subshell Client"); - }); - - it("keeps the two registration words apart", () => { - // "Register" is this window's word; "Enroll" is the CLI's. The window's - // second use of "Enroll" — the destructive re-enrolment screen — retired - // with the Control Plane collapse (operator ruling 2026-09-22), so the - // one word per act is now the whole rule: no title here says "Enroll". - expect(screenTitle("register")).not.toContain("Enroll"); - for (const id of NODE_SCREEN_IDS) expect(screenTitle(id)).not.toContain("Enroll"); - }); - // The three service-state titles this used to pin went with the service // screen: a configured machine lands on `status`, whose heading is the // app's name and whose badge and problem line are what say which failure // it is looking at (`status-screen.test.tsx`). - it("speaks the assistant's voice on the screens a person asks for", () => { - expect(screenTitle("connect")).toBe("Connect to a Server"); - expect(screenTitle("about")).toBe("About Subshell Client"); - expect(screenTitle("update")).toBe("Update Subshell Client"); - }); - it("has ONE update screen, named for the product the person is looking at", () => { // This used to pin the opposite premise — that the APP's update and the // AGENT's must never share a word — because there were two screens with @@ -66,34 +41,6 @@ describe("screenTitle", () => { // and an id this build does not know is ignored as it always was. expect(NODE_SCREEN_IDS as readonly string[]).toContain("update"); expect(NODE_SCREEN_IDS as readonly string[]).not.toContain("app-update"); - // The title names the APP because the app is what the person opened and - // what relaunches. The node half is the tail of that same act and is - // stated on the screen — `update-act.test.ts` pins both rows — rather than - // in a heading that would then name two products. - expect(screenTitle("update")).toBe("Update Subshell Client"); - }); - - it("names the product rather than the computer, identically on both platforms", () => { - // Two regressions pinned at once, both reported from a screenshot on - // 2026-09-12. "Reset This Mac" reads as erasing the computer, which is - // not remotely what this does; and a label ending on "Mac" reads as a - // truncated "Machine", against a sibling string that really is "This - // Machine". This is the one title that names no machine on any platform. - // (Reset's title retired with the screen: the confirmation is a dialog - // now — "Reset everything?" — and a dialog is named by its Dialog role, - // operator ruling 2026-09-22.) - // And the rule it became: NO title names a Mac, because there is one word - // for where you are and it is "This Machine" (operator's call, - // 2026-09-12). A title is a label; a label that might have been cut off is - // a bug report waiting to happen. - // And the shape of the bug it fixed: no title may END on "Mac", which is - // what reads as a truncated "Machine" under a button's ellipsis. `endsWith` - // rather than `contains`, because "This Machine" contains "Mac" — that - // prefix relationship IS the misreading, so the check has to be about - // where the string stops. - for (const screen of NODE_SCREEN_IDS) { - expect(screenTitle(screen).endsWith("Mac"), screen).toBe(false); - } }); }); diff --git a/apps/client/desktop/ui/src/__tests__/probe-facts.test.ts b/apps/client/desktop/ui/src/__tests__/probe-facts.test.ts index 226dae384..68d0b4484 100644 --- a/apps/client/desktop/ui/src/__tests__/probe-facts.test.ts +++ b/apps/client/desktop/ui/src/__tests__/probe-facts.test.ts @@ -96,7 +96,6 @@ describe("probeFacts", () => { enrolledNode: null, }); expect(value(corrupt, "config")?.value).toBe("The node's configuration could not be read."); - expect(value(corrupt, "config")?.value).not.toContain("config corrupt"); }); it("names the teardown cost, which no service manager will state", () => { diff --git a/apps/client/desktop/ui/src/__tests__/startup-screen.test.tsx b/apps/client/desktop/ui/src/__tests__/startup-screen.test.tsx index d2ac753ce..3088d7a4d 100644 --- a/apps/client/desktop/ui/src/__tests__/startup-screen.test.tsx +++ b/apps/client/desktop/ui/src/__tests__/startup-screen.test.tsx @@ -66,12 +66,6 @@ describe("the start-up screen", () => { expect(continues).toEqual([1]); }); - it("says what start at login buys, and what its absence costs", () => { - renderStartup(); - expect(screen.getByText(/Starts the node again the next time you log in/)).toBeTruthy(); - expect(screen.getByText(/nothing brings it back after you log out or restart/)).toBeTruthy(); - }); - /** * An enabled `--user` unit comes back at LOGIN and dies at LOGOUT unless the * user lingers — a different question from "is it enabled", and on a diff --git a/apps/client/desktop/ui/src/__tests__/unenroll-gate.test.ts b/apps/client/desktop/ui/src/__tests__/unenroll-gate.test.ts index 11b10c376..c5b5c2079 100644 --- a/apps/client/desktop/ui/src/__tests__/unenroll-gate.test.ts +++ b/apps/client/desktop/ui/src/__tests__/unenroll-gate.test.ts @@ -15,20 +15,9 @@ const withVersion = (version: string | null) => { }; describe("unenrollSupported", () => { - it("names the first agent whose CLI carries `unenroll`", () => { + it("uses the first version that carries the command", () => { expect(MIN_UNENROLL_NODE_VERSION).toBe("0.15.0"); expect(unenrollSupported(withVersion(MIN_UNENROLL_NODE_VERSION))).toBe(true); - expect(unenrollSupported(withVersion("0.15.1"))).toBe(true); expect(unenrollSupported(withVersion("0.14.3"))).toBe(false); - expect(unenrollSupported(withVersion("1.9.0"))).toBe(true); - }); - - it("compares NUMBERS, so 0.9.0 is older than 0.15.0", () => { - expect(unenrollSupported(withVersion("0.9.0"))).toBe(false); - }); - - it("assumes an unknown version capable, as the autostart gate argues", () => { - expect(unenrollSupported(withVersion(null))).toBe(true); - expect(unenrollSupported(undefined)).toBe(true); }); }); diff --git a/apps/client/desktop/ui/src/__tests__/welcome-screen.test.tsx b/apps/client/desktop/ui/src/__tests__/welcome-screen.test.tsx index ed3f7c365..3f8af258b 100644 --- a/apps/client/desktop/ui/src/__tests__/welcome-screen.test.tsx +++ b/apps/client/desktop/ui/src/__tests__/welcome-screen.test.tsx @@ -31,17 +31,6 @@ describe("the Welcome screen", () => { expect(advanced).toBe(1); }); - /** - * This app is a client that can also make its machine a node, and the very - * next screen asks which of the two you want. A welcome naming one half - * would have taught the wrong shape a screen before the question. - */ - it("names both halves of the app in one sentence", () => { - renderApp( {}} busy={false} />); - const sentence = screen.getByText(/connects you to a Subshell server/); - expect(sentence.textContent).toMatch(/run subshells on this machine/); - }); - it("disables Continue while an action is in flight", () => { let advanced = 0; renderApp( advanced++} busy={true} />); diff --git a/apps/docs/src/__tests__/seo.test.ts b/apps/docs/src/__tests__/seo.test.ts deleted file mode 100644 index 67ff8f50b..000000000 --- a/apps/docs/src/__tests__/seo.test.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import robots from "../../app/robots"; - -// Build-output checks run through verify:export after a fresh build, never -// conditionally against an absent or stale export during the unit suite. -describe("robots", () => { - test("allows crawling and advertises the canonical sitemap", () => { - const r = robots(); - expect(r.rules).toEqual([{ userAgent: "*", allow: "/" }]); - expect(r.sitemap).toBe("https://docs.subshell.sh/sitemap.xml"); - }); -}); diff --git a/apps/server/api/src/__tests__/auth-lazy.test.ts b/apps/server/api/src/__tests__/auth-lazy.test.ts index d81b7b9da..1a2425109 100644 --- a/apps/server/api/src/__tests__/auth-lazy.test.ts +++ b/apps/server/api/src/__tests__/auth-lazy.test.ts @@ -13,12 +13,6 @@ describe("getAuth", () => { expect(a).toBe(getAuth()); }); - test("resetAuthForTests drops the memo (test isolation seam)", () => { - const a = getAuth(); - resetAuthForTests(); - expect(getAuth()).not.toBe(a); - }); - test("a zero-provider table still builds: no genericOAuth plugin over an email-only auth_providers", () => { // Task 4's regression: AUTH_OPTIONS must keep working untouched by the // provider wiring, and the lone EMAIL row (kind "email") is a provider for POLICY diff --git a/apps/server/api/src/__tests__/auth-secret-boot-guard.test.ts b/apps/server/api/src/__tests__/auth-secret-boot-guard.test.ts index 7fde7137f..c4e215025 100644 --- a/apps/server/api/src/__tests__/auth-secret-boot-guard.test.ts +++ b/apps/server/api/src/__tests__/auth-secret-boot-guard.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "bun:test"; -import { assertProdAuthSecret, IS_PROD, PLACEHOLDER_AUTH_SECRET } from "@/constants.js"; +import { assertProdAuthSecret, PLACEHOLDER_AUTH_SECRET } from "@/constants.js"; /** * F1 (security audit 2026-08): better-auth's own prod guard only rejects its @@ -17,21 +17,7 @@ describe("production placeholder-secret boot guard", () => { expect(() => assertProdAuthSecret(true, "a-real-secret-that-is-long-enough-0123456789")).not.toThrow(); }); - it("production + a merely-different secret boots fine (only the placeholder is fatal)", () => { - expect(() => assertProdAuthSecret(true, "some-other-secret-value-0123456789")).not.toThrow(); - }); - it("dev/test + placeholder boots fine (the default stays usable outside prod)", () => { expect(() => assertProdAuthSecret(false, PLACEHOLDER_AUTH_SECRET)).not.toThrow(); }); - - it("the test suite itself never trips the guard (IS_PROD is false)", () => { - // If this ever fails, `bun test` would be exercising the prod path and the - // guard would abort every developer's suite. Deliberately does NOT assert - // AUTH_SECRET === placeholder: a developer's `.env` may set a real secret. - expect(IS_PROD).toBe(false); - expect(() => assertProdAuthSecret()).not.toThrow(); - // …and even a placeholder secret is only fatal under isProd=true. - expect(() => assertProdAuthSecret(IS_PROD, PLACEHOLDER_AUTH_SECRET)).not.toThrow(); - }); }); diff --git a/apps/server/api/src/__tests__/banner.test.ts b/apps/server/api/src/__tests__/banner.test.ts index 56c692391..52db903b1 100644 --- a/apps/server/api/src/__tests__/banner.test.ts +++ b/apps/server/api/src/__tests__/banner.test.ts @@ -4,11 +4,6 @@ import { banner } from "@/banner.js"; /** Built from a string so the ESC byte never sits inside a regex literal. */ const ESC = "\u001b"; const ANSI = new RegExp(`${ESC}\\[[0-9;]*m`); -/** The narrowest terminal anyone still uses. */ -const MIN_TERMINAL_COLS = 80; - -const lines = () => banner(false).split("\n"); - describe("boot banner", () => { test("the plain form carries NO escape codes", () => { // The load-bearing property, not a style preference: the plain form is @@ -16,44 +11,4 @@ describe("boot banner", () => { // a journal are something an operator has to read around forever. expect(banner(false)).not.toMatch(ANSI); }); - - test("colour changes the ink, never the drawing", () => { - expect(banner(true)).toMatch(ANSI); - // Strip the escapes and the two forms are identical, so a palette edit can - // never silently reflow the art. - expect(banner(true).replace(new RegExp(`${ESC}\\[[0-9;]*m`, "g"), "")).toBe(banner(false)); - }); - - test("fits an 80-column terminal", () => { - // A banner that WRAPS is worse than no banner — the letterforms shear and - // the boot log opens looking corrupted. Measured on the plain form; the - // colour form's escape codes occupy no cells. - expect(Math.max(...lines().map((l) => l.length))).toBeLessThanOrEqual(MIN_TERMINAL_COLS); - }); - - test("is drawn only from #, + and spaces", () => { - // Deliberately plain ASCII: no block elements, no box drawing. Those - // depend on the font rendering them at exactly the cell box, and the seams - // show in a lot of terminals. - expect(banner(false).replace(/[#+ \n]/g, "")).toBe(""); - }); - - test("the character split agrees with the COLOUR split", () => { - // Two independent constants describe the same boundary: `#` vs `+` in the - // art, and SUB_END in the painter. If they drift, the wordmark gets a - // `sub`-coloured `+` — legible, subtly wrong, and invisible to every other - // test here. `#` must live entirely left of the boundary and `+` right. - const SUB_END = 38; - for (const line of lines()) { - expect(line.slice(0, SUB_END)).not.toContain("+"); - expect(line.slice(SUB_END)).not.toContain("#"); - } - }); - - test("has no blank first or last line, and no trailing newline", () => { - const rows = lines(); - expect(rows[0]?.trim()).not.toBe(""); - expect(rows[rows.length - 1]?.trim()).not.toBe(""); - expect(banner(false).endsWith("\n")).toBe(false); - }); }); diff --git a/apps/server/api/src/api/__tests__/auth-guard-bearer.test.ts b/apps/server/api/src/api/__tests__/auth-guard-bearer.test.ts index 63f4001d6..eaeb2a95e 100644 --- a/apps/server/api/src/api/__tests__/auth-guard-bearer.test.ts +++ b/apps/server/api/src/api/__tests__/auth-guard-bearer.test.ts @@ -251,15 +251,7 @@ describe("authGuard bearer path", () => { it("requireAdmin rejects bearer actors (cookie-admin only)", async () => { const adminToken = await signIn(adminEmail, password); expect((await adminProbe.fetch(authedRequest("/admin-probe", adminToken))).status).toBe(200); - // a plain user's cookie is 403, a system-key bearer is 403 too (not cookie) + // A plain user's cookie is authenticated, but does not grant admin access. expect((await adminProbe.fetch(authedRequest("/admin-probe", plainToken))).status).toBe(403); - const created = (await getAuth().api.createApiKey({ - body: { name: "sys-admin-test", userId: await ensureSystemUser(), metadata: { kind: "system" } }, - })) as unknown as { id: string; key: string }; - createdKeyIds.push(created.id); - const res = await adminProbe.fetch( - new Request("http://localhost:3080/admin-probe", { headers: { authorization: `Bearer ${created.key}` } }), - ); - expect(res.status).toBe(403); }); }); diff --git a/apps/server/api/src/api/__tests__/auth-rate-limit.test.ts b/apps/server/api/src/api/__tests__/auth-rate-limit.test.ts index 771e6cc45..01b165660 100644 --- a/apps/server/api/src/api/__tests__/auth-rate-limit.test.ts +++ b/apps/server/api/src/api/__tests__/auth-rate-limit.test.ts @@ -7,15 +7,14 @@ describe("auth-rate-limit math", () => { expect(authDelayForAttempts(undefined)).toBe(0); }); it("doubles the delay per recorded attempt (2^n seconds, n = attempts)", () => { - expect(authDelayForAttempts(0)).toBe(0); expect(authDelayForAttempts(1)).toBe(2_000); expect(authDelayForAttempts(2)).toBe(4_000); expect(authDelayForAttempts(3)).toBe(8_000); }); it("caps the delay at 30 seconds", () => { expect(authDelayForAttempts(5)).toBe(30_000); - expect(authDelayForAttempts(5)).toBe(maxAuthBackoffMs); expect(authDelayForAttempts(10)).toBe(30_000); + expect(maxAuthBackoffMs).toBe(30_000); }); it("normalizes email to lowercase", () => { expect(normalizeAuthEmail(" Admin@Subshell.Local ")).toBe("admin@subshell.local"); diff --git a/apps/server/api/src/api/__tests__/emergency-login.test.ts b/apps/server/api/src/api/__tests__/emergency-login.test.ts index 9df2dbbaf..2d504c49d 100644 --- a/apps/server/api/src/api/__tests__/emergency-login.test.ts +++ b/apps/server/api/src/api/__tests__/emergency-login.test.ts @@ -67,6 +67,16 @@ describe("emergency admin login (SUBSHELL_EMERGENCY_PASSWORD)", () => { await db.deleteFrom("authAttempts").where("email", "=", email).execute(); } + /** Rewrite audit rows attributable to this fixture email. */ + async function rewriteAuditCount(email: string): Promise { + const rows = await db + .selectFrom("auditEvents") + .select("metadataJson") + .where("action", "=", "emergency_login.rewrite_credential") + .execute(); + return rows.filter((row) => row.metadataJson?.includes(email)).length; + } + it("armed: admin signs in with the env value and gets a session cookie", async () => { process.env[ENV] = ENV_VALUE; const email = await makeUser("admin", OLD_ADMIN_PASS); @@ -103,23 +113,6 @@ describe("emergency admin login (SUBSHELL_EMERGENCY_PASSWORD)", () => { expect(res.status).toBe(401); }); - /** Approved-rewrite audit rows this suite produced for `email` (metadata carries it). */ - async function rewriteAuditCount(email: string): Promise { - const rows = await db - .selectFrom("auditEvents") - .select("metadataJson") - .where("action", "=", "emergency_login.rewrite_credential") - .execute(); - return rows.filter((r) => r.metadataJson?.includes(email)).length; - } - - it("armed: an approved rewrite leaves an audit row (code-review 2026-08-31)", async () => { - process.env[ENV] = ENV_VALUE; - const email = await makeUser("admin", OLD_ADMIN_PASS); - expect((await signInRaw(email, ENV_VALUE)).status).toBe(200); - expect(await rewriteAuditCount(email)).toBe(1); - }); - it("the declined paths leave no audit row", async () => { process.env[ENV] = ENV_VALUE; const email = await makeUser("user", USER_PASS); diff --git a/apps/server/api/src/lib/__tests__/context.test.ts b/apps/server/api/src/lib/__tests__/context.test.ts index 0182fd9ca..eedc598af 100644 --- a/apps/server/api/src/lib/__tests__/context.test.ts +++ b/apps/server/api/src/lib/__tests__/context.test.ts @@ -1,7 +1,7 @@ import { beforeAll, describe, expect, it } from "bun:test"; import { ensureMigratedTestDb } from "@/__tests__/helpers/test-database.js"; import { db } from "@/db/index.js"; -import { ApiContext, getRequestlessContext, resetRequestlessContext } from "@/lib/context.js"; +import { ApiContext, getRequestlessContext } from "@/lib/context.js"; import { getLogger } from "@/utils/logger.js"; /** @@ -18,14 +18,6 @@ describe("getRequestlessContext", () => { it("returns the same instance on every call", () => { expect(getRequestlessContext()).toBe(getRequestlessContext()); }); - - it("resetRequestlessContext drops the cached instance (@internal test hook)", () => { - const first = getRequestlessContext(); - resetRequestlessContext(); - // After a reset the next call rebuilds rather than returning the old one — - // this is the isolation hook Task 6's ws/mcp tests will rely on. - expect(getRequestlessContext()).not.toBe(first); - }); }); describe("ApiContext", () => { diff --git a/apps/server/api/src/ws/__tests__/ws-token.test.ts b/apps/server/api/src/ws/__tests__/ws-token.test.ts index ca90fca00..f12100f93 100644 --- a/apps/server/api/src/ws/__tests__/ws-token.test.ts +++ b/apps/server/api/src/ws/__tests__/ws-token.test.ts @@ -37,15 +37,16 @@ describe("ws-token mint cap", () => { issueWsToken("u1"); issueWsToken("u1"); issueWsToken("u1"); - expect(() => issueWsToken("u1")).toThrow(WsTokenCapacityError); - // The refusal is the 503 the mint route surfaces, carried the same way - // every other status-bearing error class in this codebase carries it. + let refusal: unknown; try { issueWsToken("u1"); } catch (err) { - expect((err as { status?: number }).status).toBe(503); - expect((err as Error).message).toContain("too many outstanding attach tokens"); + refusal = err; } + // One refused mint proves the cap and its route-facing error contract. + expect(refusal).toBeInstanceOf(WsTokenCapacityError); + expect((refusal as { status?: number }).status).toBe(503); + expect((refusal as Error).message).toContain("too many outstanding attach tokens"); } finally { setWsTokenCapForTests(previous); } diff --git a/apps/server/web/src/components/__tests__/detail-back-header.test.tsx b/apps/server/web/src/components/__tests__/detail-back-header.test.tsx deleted file mode 100644 index 5de03e01e..000000000 --- a/apps/server/web/src/components/__tests__/detail-back-header.test.tsx +++ /dev/null @@ -1,88 +0,0 @@ -import { afterEach, describe, expect, it } from "bun:test"; -import { - createMemoryHistory, - createRootRoute, - createRoute, - createRouter, - RouterProvider, -} from "@tanstack/react-router"; -import { cleanup, render, screen } from "@testing-library/react"; -import { DetailBackHeader } from "@/components/detail-back-header"; -import { MobileNavProvider } from "@/components/mobile-nav-provider"; - -/** The header needs router context (Link + MobileNav's useLocation). */ -async function renderHeader(extra: { subtitle?: string; hideBack?: boolean } = {}) { - const rootRoute = createRootRoute(); - const indexRoute = createRoute({ - getParentRoute: () => rootRoute, - path: "/", - component: () => ( - - Alpha} - subtitle={extra.subtitle} - hideBack={extra.hideBack} - /> - - ), - }); - const router = createRouter({ - routeTree: rootRoute.addChildren([indexRoute]), - history: createMemoryHistory({ initialEntries: ["/"] }), - defaultPreload: false, - }); - await router.load(); - render(); - return document.querySelector("header"); -} - -/** - * matchMedia simulator answering the two queries the header reads: - * `wide` for the tiling-width query, `coarse` for `(pointer: coarse)`. - */ -function forceViewport({ wide, coarse }: { wide: boolean; coarse: boolean }) { - const original = globalThis.matchMedia; - globalThis.matchMedia = ((query: string) => ({ - matches: query.includes("pointer") ? coarse : wide, - media: query, - onchange: null, - addListener: () => {}, - removeListener: () => {}, - addEventListener: () => {}, - removeEventListener: () => {}, - dispatchEvent: () => false, - })) as typeof matchMedia; - return () => (globalThis.matchMedia = original); -} - -describe("DetailBackHeader", () => { - afterEach(cleanup); - - // NOTE: no relying on the ambient matchMedia — happy-dom's own answers - // width queries against a 1024px window (i.e. "wide"), so every test - // forces its viewport and pointer explicitly. - - it("hideBack drops the back control from the chrome row", async () => { - const restore = forceViewport({ wide: false, coarse: true }); - try { - await renderHeader({ hideBack: true }); - expect(screen.queryByRole("link", { name: "Back to subshells" })).toBeNull(); - // The title still renders — the control left, not the whole header. - expect(screen.getByText("Alpha")).toBeDefined(); - } finally { - restore(); - } - }); - - it("by default the back control is there", async () => { - const restore = forceViewport({ wide: false, coarse: true }); - try { - await renderHeader(); - expect(screen.queryByRole("link", { name: "Back to subshells" })).not.toBeNull(); - } finally { - restore(); - } - }); -}); diff --git a/apps/server/web/src/components/__tests__/network-plugin-card.test.tsx b/apps/server/web/src/components/__tests__/network-plugin-card.test.tsx index 6134ec39e..192f66e25 100644 --- a/apps/server/web/src/components/__tests__/network-plugin-card.test.tsx +++ b/apps/server/web/src/components/__tests__/network-plugin-card.test.tsx @@ -818,38 +818,6 @@ describe("NetworkPluginCard: the state matrix", () => { expect(within(card).queryByRole("heading", { name: "Addresses" })).toBeNull(); }); - it("says what joined means, above the button that changes it", async () => { - // Both halves of this state now answer the standing question in the same - // slot. Joined had no sentence at all — a person met a Publish button and - // had to already know that joining put the MACHINE on the network while - // publishing is what puts the DASHBOARD on it. The act is named in the - // button's own words, so this row — whose label is Tailscale's — reads - // “Publish with Tailscale Serve”, not a word the plugin never used. - await renderCard(row({ state: "joined", status: { state: "joined", addresses: ADDRESSES, hints: [] } })); - expect( - screen.getByText( - "Subshell is not published on Tailscale yet. “Publish with Tailscale Serve” is what lets your other devices open this dashboard over the network.", - ), - ).toBeTruthy(); - expect(screen.getByRole("button", { name: /^Publish/ })).toBeTruthy(); - expect(screen.queryByText("Subshell is published on Tailscale.")).toBeNull(); - }); - - it("gives publishing its own section, and answers whether it is needed", async () => { - // The operator read: a joined card flowed facts, sentence, hints and the - // publish button as one column, and nothing said whether pressing it was - // required. The act now sits under its own heading, and the section states - // the case where skipping is honest — this machine only, or addresses the - // server already allows. - await renderCard(row({ state: "joined", status: { state: "joined", addresses: ADDRESSES, hints: [] } })); - expect(screen.getByRole("heading", { name: "Publish" })).toBeTruthy(); - expect( - screen.getByText( - "You can skip this while you only use Subshell on this machine, or at an address you have already allowed.", - ), - ).toBeTruthy(); - }); - it("an implicit-publish network's joined row is a gap: one line and the button, no section", async () => { // JOIN IS THE PUBLISH (spec §5.3, amended 2026-09-16): the join route // records the publish itself, so a row STILL saying `joined` is only ever diff --git a/apps/server/web/src/components/__tests__/qr-link-dialog.test.tsx b/apps/server/web/src/components/__tests__/qr-link-dialog.test.tsx index 2d8406705..44017df8e 100644 --- a/apps/server/web/src/components/__tests__/qr-link-dialog.test.tsx +++ b/apps/server/web/src/components/__tests__/qr-link-dialog.test.tsx @@ -79,17 +79,6 @@ describe("QrLinkDialog", () => { } }); - it("says what scanning does, and that it grants nothing", async () => { - const restore = mockFetch([HERE, LAN]); - try { - renderDialog("/subshells/abc-123"); - expect(screen.getByText("Open this subshell on another device")).toBeTruthy(); - expect(screen.getByText("Scan to open this subshell on another device.")).toBeTruthy(); - } finally { - restore(); - } - }); - it("shows the refusal where the QR would be when no address can be dialled", async () => { // A loopback-only instance: `installAddresses` filters it, so there is // nothing to encode — and a code for `localhost` would scan perfectly and diff --git a/apps/server/web/src/components/plugins/__tests__/installed-plugins-card.test.tsx b/apps/server/web/src/components/plugins/__tests__/installed-plugins-card.test.tsx index d934c99d8..09715e295 100644 --- a/apps/server/web/src/components/plugins/__tests__/installed-plugins-card.test.tsx +++ b/apps/server/web/src/components/plugins/__tests__/installed-plugins-card.test.tsx @@ -43,9 +43,4 @@ describe("InstalledPluginsCard", () => { expect(screen.queryByRole("button", { name: /uninstall/i })).toBeNull(); expect(screen.getByText("disabled")).toBeDefined(); }); - - it("says so when nothing is installed", () => { - renderCard([]); - expect(screen.getByText(/nothing installed yet/i)).toBeDefined(); - }); }); diff --git a/apps/website/components/__tests__/install-column.test.tsx b/apps/website/components/__tests__/install-column.test.tsx index 04026040d..3e3fe1a44 100644 --- a/apps/website/components/__tests__/install-column.test.tsx +++ b/apps/website/components/__tests__/install-column.test.tsx @@ -45,15 +45,6 @@ async function mounted() { await act(async () => {}); } -test("picking the client chip says what the client is, node enrollment included", async () => { - await mounted(); - fireEvent.click(screen.getByRole("button", { name: "Client / Node" })); - expect(screen.getByText("Manage subshells and run agents on this machine.")).toBeDefined(); - expect(screen.getByText("curl -fsSL https://subshell.sh/install-client.sh | bash")).toBeDefined(); - // Exactly one role sentence stands: both rendering at once would re-ask the fork's own question. - expect(screen.queryByText("Host your dashboard and manage your agents.")).toBeNull(); -}); - test("the non-download third path points at the node setup documentation", async () => { await mounted(); const link = screen.getByRole("link", { name: "Add another machine" }); diff --git a/apps/website/lib/__tests__/install.test.ts b/apps/website/lib/__tests__/install.test.ts index c9d43da08..e1916e6c1 100644 --- a/apps/website/lib/__tests__/install.test.ts +++ b/apps/website/lib/__tests__/install.test.ts @@ -23,7 +23,6 @@ const m = { test("server+mac: dmg label, direct asset href, versioned filename, server curl", () => { const c = installCopy(m, "server", true); - expect(c.downloadLabel).toBe("Download for macOS (.dmg)"); // The button downloads the file, it does not tour the release page: tag and // filename are both known, so the release-page hop was never required. expect(c.downloadHref).toBe( @@ -41,11 +40,9 @@ test("server+mac: dmg label, direct asset href, versioned filename, server curl" test("client+linux: direct deb href, alt dmg href, client curl", () => { const c = installCopy(m, "client", false); - expect(c.downloadLabel).toBe("Download for Linux (.deb)"); expect(c.downloadHref).toBe( "https://github.com/subshell-ai/subshell/releases/download/desktop-client-v0.6.0/subshell-client-desktop_0.6.0_amd64.deb", ); - expect(c.altLabel).toBe("macOS (.dmg)"); expect(c.altHref).toBe( "https://github.com/subshell-ai/subshell/releases/download/desktop-client-v0.6.0/Subshell-Client-Desktop-0.6.0-darwin-arm64.dmg", ); @@ -62,14 +59,6 @@ test("client without installScript: download only, curl row gone (Review Focus 3 expect(installCopy(stripped, "server", true).curlCommand).not.toBeNull(); }); -test("headings name what each path delivers (operator note, 2026-09-25)", () => { - expect(installCopy(m, "server", true).appHeading).toBe("Subshell Server desktop app"); - // Both choices expose a terminal install command. - expect(installCopy(m, "server", true).curlHeading).toBe("or the CLI"); - expect(installCopy(m, "client", true).appHeading).toBe("Subshell Client desktop app"); - expect(installCopy(m, "client", true).curlHeading).toBe("or the CLI"); -}); - test("missing desktop entry: generic releases href, no filename, no crash (Review Focus 2)", () => { // The cli-server entry inlined as a literal, not `m.components[...]!`: the // non-null assertion is what this file's lint check flags. @@ -127,31 +116,14 @@ test("macIntelAvailable reads the release's verified asset list, never the field expect(macIntelAvailable(bothMacs, "client")).toBe(false); }); -test("with Intel published, the selection drives label, href and the filename line", () => { +test("with Intel published, each selection resolves to its own artifact", () => { const intel = installCopy(bothMacs, "server", true, "darwin-x64"); - expect(intel.downloadLabel).toBe("Download for Intel (.dmg)"); expect(intel.downloadHref).toContain("Subshell-Server-Desktop-0.16.0-darwin-x64.dmg"); expect(intel.artifactFile).toBe("Subshell-Server-Desktop-0.16.0-darwin-x64.dmg"); const arm = installCopy(bothMacs, "server", true, "darwin-arm64"); - expect(arm.downloadLabel).toBe("Download for Apple silicon (.dmg)"); + expect(arm.downloadHref).toContain("Subshell-Server-Desktop-0.16.0-darwin-arm64.dmg"); expect(arm.artifactFile).toBe("Subshell-Server-Desktop-0.16.0-darwin-arm64.dmg"); // The Linux alternative is untouched by the choice: expect(intel.altLabel).toBe("Linux (.deb)"); expect(intel.altHref).toContain("subshell-server-desktop_0.16.0_amd64.deb"); }); - -test("default selection is Apple silicon — the arch no browser can detect", () => { - expect(installCopy(bothMacs, "server", true).downloadLabel).toBe("Download for Apple silicon (.dmg)"); -}); - -// The label idiom (operator choice, 2026-09-25): the button matches the menu -// rows and the alt link, `Name (.ext)`, never `Name · .ext`. -test("without Intel, macOS copy names no arch: the plain button speaks the same way", () => { - for (const manifest of [m, armOnly]) { - expect(installCopy(manifest, "server", true).downloadLabel).toBe("Download for macOS (.dmg)"); - } -}); - -test("the Linux path ignores the arch parameter entirely", () => { - expect(installCopy(bothMacs, "server", false, "darwin-x64").downloadLabel).toBe("Download for Linux (.deb)"); -}); diff --git a/apps/website/lib/__tests__/seo.test.ts b/apps/website/lib/__tests__/seo.test.ts deleted file mode 100644 index 7d20b3f96..000000000 --- a/apps/website/lib/__tests__/seo.test.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import robots from "../../app/robots"; -import sitemap from "../../app/sitemap"; - -/** The marketing site's discovery layer: one page, found on purpose. */ -describe("seo routes", () => { - test("robots allows all and points at the sitemap", () => { - const r = robots(); - expect(r.rules).toEqual([{ userAgent: "*", allow: "/" }]); - expect(r.sitemap).toBe("https://subshell.sh/sitemap.xml"); - }); - - test("the sitemap carries exactly the landing page, absolute", () => { - expect(sitemap()).toEqual([{ url: "https://subshell.sh/" }]); - }); -}); diff --git a/docs/testing.md b/docs/testing.md index 8d2d11f30..3989b7f7b 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -1,23 +1,46 @@ # Testing -Tests earn their cost by catching a plausible failure in behavior we own. -Prefer a small regression test at the boundary that can observe the failure. -A new function or component doesn't automatically need a new test file. +Tests earn their cost by catching a plausible regression in behavior we own. +Correctness is the goal; coverage percentage and test count are not. For each +test, be able to name the future change that could introduce a bug and the +assertion that would catch it. Prefer a small regression test at the boundary +where that bug becomes observable. A new function or component doesn't +automatically need another test file. ## What to keep and what to remove -Keep coverage for permissions and privacy, data loss, migrations, wire -compatibility, process lifecycle, cleanup, and asynchronous state transitions. -Keep browser tests for behavior that depends on real browser layout, input, or -networking, and compiled CLI tests for behavior source imports can't establish. -Test both the allowed action and the relevant refusal when a boundary needs it. +Prioritize failures that could cause unauthorized access, expose private data, +lose or corrupt user data, break a published protocol or artifact, leave a +process or resource in the wrong state, or report success after an operation +failed. Cover important flows at the boundary that can observe those failures: +permissions and privacy, persistence and migrations, wire compatibility, +release and installer behavior, process lifecycle, cleanup, and asynchronous +state changes. Use a browser or compiled CLI when that is where the regression +would surface. Test both the allowed action and its relevant refusal when each +can fail independently. This is a priority list, not a requirement to exercise +every branch or add a test file for every item. Before adding a test, name the regression it would catch and check whether an -existing test already catches it. Extend that suite when possible. A second -layer needs a different failure to justify its setup and maintenance cost. - -Remove tests that only assert fixed copy, CSS classes, wrapper markup, trivial -prop forwarding, TypeScript guarantees, or third-party implementation details. +existing test already catches it. A useful test exercises production behavior +and would fail if that regression were reintroduced. It should also record the +expected behavior: make the relevant setup, action, and observable result clear +enough that a maintainer can tell what the system promises. Prefer assertions +about returned state, persisted data, requests, side effects, or a refusal over +assertions that execution merely completed. Extend the existing suite when +possible. A second layer needs a distinct failure mode to justify its setup and +maintenance cost; a second assertion of the same outcome doesn't add confidence +just because it runs through another component. + +Remove tests that only assert fixed explanatory copy, CSS classes, wrapper +markup, trivial prop forwarding, TypeScript guarantees, or third-party +implementation details. Review routine copy as content, not as a product +regression suite. Keep a text assertion only when the output itself is a +behavioral contract: for example, a CLI or wire format another program reads, +a refusal or error that must preserve a machine-readable code, or a warning +whose mismatch could lead someone to take an unsafe or irreversible action. +When possible, assert the state, refusal, command, or action that makes the +output correct instead of freezing the whole sentence. Accessible names merit +assertions when they let someone find and operate a control. Don't test a copy of production logic or a helper that exists only in the test. Don't turn every input combination into another test when it exercises the same branch. Keep meaningful boundaries, accessibility, and input validation. @@ -30,10 +53,11 @@ a test-local copy of an algorithm proves nothing about the production function; exercise the real function or remove the redundant experiment. Runtime parsing tests still matter for untrusted inputs even when TypeScript rejects their shape. -When culling, name the check that retains the important behavior. Remove obsolete -UI-absence checks and literal internal-default assertions when they add no signal; -retain wire compatibility, permission boundaries, and anti-vacuity guards. Don't -hide the same workload in loops merely to lower the reported test count. +When culling, identify the important regression and point to the remaining +test that would catch it. If no test catches a critical failure, keep or add the +smallest test that does. Remove obsolete absence checks and literal internal +defaults when they add no signal. Don't preserve redundant tests for a coverage +number, and don't hide the same workload in loops to lower the reported count. Reuse shared boundary tests where they execute the same production path. For example, `api-surface-auth.test.ts` checks every mounted guarded API route with @@ -202,6 +226,117 @@ ten-second fallback; the shared delivery suite retains timeout coverage. These are individual test durations, not an equivalent reduction in parallel suite wall time: the complete workspace run took 44.07 seconds, versus 46.29 previously. +A fourth presentation-focused pass removed seven more cases that only pinned +static explanatory sentences: setup and welcome copy, descriptions beneath +first-run choices, a QR dialog's fixed title/description, an empty plugin-list +message, an empty SSH peer-list message, and the maintenance card's fixed +“declared from this page” wording. The choice callbacks, QR address/path and +no-address behavior, maintenance state/action and refusal behavior, SSH trust +gates/fingerprints, and plugin control permissions remain covered. Runtime +state distinctions and actionable instructions in the node runtime card also +remain; those represent changing machine facts or commands a reader may need, +not merely editorial wording. This targeted pass has not established that every +remaining assertion in the repository is useful; future candidates should be +judged by the same behavior-versus-copy boundary above. + +A fifth pass removed 15 more low-signal cases, bringing the targeted removal +count to 225. It removed prose-only assertions from plugin hints, install +labels/headings and the client setup-column description, the MCP connection +briefing, and an SEO file that only restated two literal route objects. It also +removed an empty-subtitle markup check from the assistant frame. The HTTP/API +auth boundary, install URLs and verified artifact selection, MCP tool +registration, disabled-control touch and keyboard behavior, and live server +settings behavior remain covered. + +A sixth pass removed two more e2e cases: a login-form smoke already exercised +by the setup and signed-out authentication journeys, and an assertion that +only pinned the absence of a redundant address-summary sentence. It also +deleted the parked `09-mobile-navcrash.spec.ts` file, whose sole `test.fixme` +was documented as blocked before it could begin and whose crash was already +fixed. The targeted removal count is now 227. The audit remains in progress; +the current inventory has 1,082 TypeScript test files, and package-level +candidate screening is not a file-by-file disposition. + +A seventh pass removed the two assertions in `detail-back-header.test.tsx` and +the file itself. They only checked that a boolean prop hid or showed a link in +a small wrapper; they didn't verify where the link navigates or any responsive +header behavior. It also removed two network-card cases that only pinned +explanatory sentences about a joined network. The joined/published state, +address, and publish/unpublish actions remain covered elsewhere in that suite. +An eighth pass removed five banner-layout/style assertions while retaining the +plain-output check that prevents ANSI escapes in service logs. It also removed +the test-environment self-check and duplicate non-placeholder case from the +production auth-secret guard suite; production placeholder rejection and real +secret acceptance remain. The targeted case removal count is now 238 and the +current TypeScript test-file inventory is 1,081. The audit remains in progress. + +A ninth pass removed two tests of internal cache-reset hooks (`resetAuthForTests` +and `resetRequestlessContext`). They checked test isolation helpers rather +than application behavior. The memoized production getters and fresh-context +service boundaries remain tested. A tenth pass also removed the documentation +site's one-case SEO test, which only restated literal robots metadata. The +targeted removal count is now 241, with 1,080 TypeScript test files in the +current inventory. + +An eleventh pass reviewed the client desktop UI test directory file by file +(31 files, including its shared app-flow suite). It removed four assistant +heading/copy cases from `node-assistant-state.test.ts`, retaining the non-empty +heading, update-screen identity and service-action behavior checks. It removed +two repeated version-order/unknown-version cases from `unenroll-gate.test.ts`; +the same semantic-version boundary behavior remains in `autostart-gate.test.ts`, +and the unenroll-specific threshold remains. It removed one redundant negative +assertion from `probe-facts.test.ts`, retaining the expected safe message for +an unreadable node configuration. This pass removed six test cases and several +duplicate assertions, for 248 targeted test-case removals total. A follow-up +auth review removed a duplicate system-key refusal assertion already exercised +across mounted admin routes, and duplicate inputs in the rate-limit math test; +it retained the real-admin/user-cookie boundary and cap checks. The other client UI files in this directory were +reviewed and retained because they exercise distinct flows or contracts such +as enrollment and recovery, service lifecycle and pane safety, settings +persistence, IPC authorization, Rust/TypeScript wire compatibility, or +production CSP behavior. This is one directory's disposition, not completion +of the repository-wide file review. The inventory now finds 1,080 TypeScript +test files and 11,678 direct test calls. + +A twelfth pass reviewed the API credential and setup/enrollment slice: +`api-surface-auth.test.ts`, the bearer and cookie auth-guard suites, +`auth-rate-limit.test.ts`, `setup-route.test.ts`, `setup-progress.route.test.ts`, +`setup-agent-install.route.test.ts`, `setup-tmux-install.route.test.ts`, and +`nodes/enroll-route.test.ts`. These files were retained after review. Their +cases cover different security principals, first-run versus installed +authorization, installer refusal before streaming, caller-owned setup state, +single-use setup-key consumption, recovery transaction rollback and key +rotation, and encryption/signing key compatibility. The overlapping WebSocket +URL cases remain because one exercises the route under a configured base path +and the other checks helper normalization across schemes and path shapes. In +the auth guard and rate-limit files, a repeated system-key refusal and repeated +backoff inputs were removed because the mounted admin-route inventory and +neighboring boundary assertions already cover those outcomes. This pass added +no new test-case removals. The repository-wide review remains in progress. + +A thirteenth pass reviewed backup-recovery credentials, emergency login, +WebSocket token minting, and the in-memory token store. It removed the +successful emergency-rewrite audit case because `auth-audit.test.ts` already +asserts the rewrite row and suppression of the ordinary sign-in row; the +negative audit check for a declined emergency attempt remains here. The token +store cap test now makes one refused mint and asserts its error type, status, +and message, instead of repeating the same refused mint for separate checks. +Recovery gating, scoped pane ownership, stale-session rejection, emergency +credential destruction, WebSocket token binding, single-use redemption, +capacity recovery, expiry cleanup, and disable-time revocation remain covered. +This pass removed one test case. The inventory now finds 1,080 TypeScript test +files and 11,678 direct test calls; the audit is still in progress. + +A fourteenth pass completed the review of `downloads-route.test.ts` and +`downloads-shelf-serve.test.ts`; both were retained. The generated-installer +assertions are paired with shell execution cases for install branches, +stdin/TTY handling, checksum tools, failed downloads and file replacement. +The release-shelf tests exercise separate selection and failure cells for +operator-owned versus fetched bytes, protocol/version compatibility, source +availability, digest agreement and concurrent responses. These determine +which executable a node receives, so they protect correctness at the artifact +boundary. No cases were removed from these two files. The audit continues. + The larger measured cost was orchestration. In [PR run 40253886630](https://github.com/subshell-ai/subshell/actions/runs/40253886630), planning took 71 seconds including installation. Mobile tests took less than a diff --git a/e2e/tests/02-auth-guard.spec.ts b/e2e/tests/02-auth-guard.spec.ts index 00bc1a76e..14de49cfe 100644 --- a/e2e/tests/02-auth-guard.spec.ts +++ b/e2e/tests/02-auth-guard.spec.ts @@ -15,16 +15,3 @@ test("protected APIs reject anonymous callers", async ({ request }) => { expect(res.status(), path).toBe(401); } }); - -test("login page renders with its form", async ({ page }) => { - await page.goto("/login"); - // The title is `Sign in to {instanceName ?? "Subshell"}` (login.tsx:89): - // the instance name rides an anonymous async query and defaults to the - // HOST'S NAME (spec 2026-09-08), so the word after "to" is either the - // wordmark fallback or this machine — whichever one wins the render race. - // Match the prefix; the identity of the plane is not this test's claim, - // the form below it is. - await expect(page.getByText(/^Sign in to /)).toBeVisible(); - await expect(page.locator("#email")).toBeVisible(); - await expect(page.locator("#password")).toBeVisible(); -}); diff --git a/e2e/tests/09-mobile-navcrash.spec.ts b/e2e/tests/09-mobile-navcrash.spec.ts deleted file mode 100644 index acbaf1ea5..000000000 --- a/e2e/tests/09-mobile-navcrash.spec.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { expect, test } from "@playwright/test"; -import { ADMIN_STATE, dismissDirectoryPanel, pickAgent, renameSubshell } from "./helpers"; - -test.use({ storageState: ADMIN_STATE }); -test.setTimeout(180_000); - -/** - * Scratch probe for the 2026-09-04 iPhone crash: navigating between live - * subshells from the drawer hit the route error screen with - * "undefined is not an object (evaluating 'this._linkifier2.onShowLinkUnderline')" - * — an xterm AccessibilityManager/Linkifier lifecycle throw surfacing through - * React. Repeated drawer round-trips between two attached terminals, pinning - * that neither the error screen nor any pageerror appears. - */ -// FIXME (2026-09-04): times out at its FIRST drawer open, and the reason is a -// real gap rather than a flake. On a subshell DETAIL page at phone width the -// drawer trigger has no accessible name — the failure snapshot shows a bare -// `button [expanded]` next to Find, where the list shell renders the same -// trigger as "Open navigation" (the name 08-mobile-shell relies on). So either -// that header button needs the list shell's aria-label, or this probe must -// reach the drawer another way. Parked instead of left red: the crash it was -// written to chase is fixed in 3db4a70 (the v5-only canvas addon), and the -// same run proves the pane still streams (the snapshot caught `tick 21..35`). -test.fixme("drawer navigation between live subshells never hits the error screen", async ({ page }) => { - const errors: string[] = []; - page.on("pageerror", (e) => errors.push(String(e?.stack ?? e))); - - const stamp = Date.now(); - // The launch form asks for no name; each subshell is renamed once it exists, - // because the drawer rows below are addressed by name. - const names = [`probe-a-${stamp}`, `probe-b-${stamp}`]; - for (const name of names) { - await page.goto("/new"); - await pickAgent(page.getByPlaceholder("Choose an agent"), "pi"); - await page.fill("#picker-working-dir", "/tmp"); - await dismissDirectoryPanel(page); - await page.getByRole("button", { name: "Start subshell" }).click(); - await expect(page).toHaveURL(/\/workspaces\/.+/, { timeout: 60_000 }); - await renameSubshell(page, name); - } - - for (let i = 0; i < 6; i++) { - await page.getByRole("button", { name: "Open navigation" }).click(); - await page - .getByRole("dialog") - .getByText(names[(i + 1) % 2]) - .first() - .click(); - // A rail row is a tab now: each press lands on that pane's workspace. - await expect(page).toHaveURL(/\/workspaces\/[0-9a-f-]+/); - await expect(page.getByText("This page hit an error")).toHaveCount(0); - } - expect(errors).toEqual([]); -}); diff --git a/e2e/tests/18-server-networking.spec.ts b/e2e/tests/18-server-networking.spec.ts index dec85683b..74f422dc2 100644 --- a/e2e/tests/18-server-networking.spec.ts +++ b/e2e/tests/18-server-networking.spec.ts @@ -69,14 +69,4 @@ test.describe("server networking page", () => { // belongs to the mocked unit test.) await expect(page.getByRole("button", { name: "Save", exact: true })).toBeVisible(); }); - - test("states the addresses in the cards, with no page-level summary line", async ({ page }) => { - // The 'This server's address' line was removed on 2026-09-17 — the card - // states the value in its field, so the line only restated it. Pinning - // the absence, because the value and the line are easy to want back. - await page.goto("/settings/networking"); - await expect(page.getByText("Addresses", { exact: true })).toBeVisible(); - await expect(page.getByText(/This server's address/)).toHaveCount(0); - await expect(page.getByText("Networks", { exact: true })).toBeVisible(); - }); }); diff --git a/packages/assistant/src/__tests__/frame.test.tsx b/packages/assistant/src/__tests__/frame.test.tsx index 95d98a2c9..d3822f13f 100644 --- a/packages/assistant/src/__tests__/frame.test.tsx +++ b/packages/assistant/src/__tests__/frame.test.tsx @@ -25,14 +25,6 @@ describe("Frame", () => { expect(screen.getByText("content")).toBeDefined(); }); - it("hides the subtitle and problem when they are empty", () => { - render(); - expect(screen.queryByText("Set Up Subshell Server")).toBeDefined(); - // Only the heading carries the strings that were given; the two hidden - // lines would otherwise render as empty paragraphs. - expect(document.querySelectorAll("p")).toHaveLength(0); - }); - it("renders a non-empty subtitle and the problem as a status line", () => { render( (): Promise { return {} as T; @@ -17,61 +11,6 @@ const api: ToolApi = { }; const own: IdentityKeyPair = { principalId: "sess:test", publicJwk: "{}", privateJwk: "{}" }; -describe("subshell mcp instructions", () => { - it("the initialize handshake carries the instructions verbatim", async () => { - const server = createSubshellMcpServer({ api, own }); - const [clientSide, serverSide] = InMemoryTransport.createLinkedPair(); - const answered = new Promise>((resolve) => { - clientSide.onmessage = (msg: any) => { - if (msg.id === 1) resolve(msg); - }; - }); - await server.connect(serverSide); - await clientSide.start(); - await clientSide.send({ - jsonrpc: "2.0", - id: 1, - method: "initialize", - params: { - protocolVersion: LATEST_PROTOCOL_VERSION, - capabilities: {}, - clientInfo: { name: "test-client", version: "0.0.0" }, - }, - }); - const res = await answered; - expect(res.result?.instructions).toBe(SUBSHELL_MCP_INSTRUCTIONS); - }); - - it("the briefing names the three moves an agent needs: status, channels, pull-delivery", () => { - expect(SUBSHELL_MCP_INSTRUCTIONS).toContain("list_subshells"); - expect(SUBSHELL_MCP_INSTRUCTIONS).toContain("get_subshell"); - expect(SUBSHELL_MCP_INSTRUCTIONS).toContain("read_channel"); - // Pull delivery is the fact agents get wrong without being told: a post - // is invisible until the peer reads. - expect(SUBSHELL_MCP_INSTRUCTIONS.toLowerCase()).toContain("reads and decrypts the message through read_channel"); - // Brevity is part of the contract — this string enters every pane's - // context on connect; a wall of instructions teaches nothing. - expect(SUBSHELL_MCP_INSTRUCTIONS.length).toBeLessThan(1200); - }); - - it("the briefing gained the machines line (spec 2026-09-25) and is still short", () => { - expect(SUBSHELL_MCP_INSTRUCTIONS).toContain("list_nodes"); - expect(SUBSHELL_MCP_INSTRUCTIONS).toContain("working_dir"); - expect(SUBSHELL_MCP_INSTRUCTIONS.length).toBeLessThan(1200); - }); - - it("the briefing owns the spawned-pane cleanup rule (2026-09-25: comms panes the human never hears about)", () => { - expect(SUBSHELL_MCP_INSTRUCTIONS).toContain("yours to close"); - expect(SUBSHELL_MCP_INSTRUCTIONS).toContain("delete_subshell temporary helpers and their history"); - expect(SUBSHELL_MCP_INSTRUCTIONS).toContain("it leaves a sidebar entry"); - expect(SUBSHELL_MCP_INSTRUCTIONS).toContain("beneath its creator"); - // The create tool REPEATS the rule for harnesses that read descriptions - // without the briefing; that half is asserted on the wire in the - // tools/list case below. - expect(SUBSHELL_MCP_INSTRUCTIONS.length).toBeLessThan(1200); - }); -}); - /** * Drives a real McpServer over the in-memory transport through the handshake * and one `tools/list`, returning the declared tool surface: the registration diff --git a/packages/node-admin/src/__tests__/node-maintenance-card.test.tsx b/packages/node-admin/src/__tests__/node-maintenance-card.test.tsx index 01d6c1f79..cc4b4fd8b 100644 --- a/packages/node-admin/src/__tests__/node-maintenance-card.test.tsx +++ b/packages/node-admin/src/__tests__/node-maintenance-card.test.tsx @@ -156,16 +156,6 @@ describe("NodeMaintenanceCard", () => { } }); - it("says 'from this page' for a flip the plane made", () => { - const { restore } = mockFetch(); - try { - renderCard(node({ maintenance: true, maintenanceAt: new Date().toISOString(), maintenanceSource: "plane" })); - expect(screen.getByText(/declared from this page/)).toBeDefined(); - } finally { - restore(); - } - }); - it("omits the since clause when nothing stamped it", () => { const { restore } = mockFetch(); try { diff --git a/packages/node-admin/src/__tests__/node-maintenance.test.ts b/packages/node-admin/src/__tests__/node-maintenance.test.ts index 07670f8c6..4b4569712 100644 --- a/packages/node-admin/src/__tests__/node-maintenance.test.ts +++ b/packages/node-admin/src/__tests__/node-maintenance.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "bun:test"; -import { maintenanceRefusalNotice, subshellCount } from "../lib/node-maintenance"; +import { maintenanceRefusalNotice } from "../lib/node-maintenance"; /** * The sentence a partly-applied maintenance flip says. @@ -27,11 +27,3 @@ describe("maintenanceRefusalNotice", () => { expect(maintenanceRefusalNotice("shop", ["s1"])).toContain("1 subshell could not be stopped"); }); }); - -describe("subshellCount", () => { - it("is the one spelling every maintenance surface shares", () => { - expect(subshellCount(0)).toBe("0 subshells"); - expect(subshellCount(1)).toBe("1 subshell"); - expect(subshellCount(4)).toBe("4 subshells"); - }); -}); diff --git a/packages/node-admin/src/__tests__/ssh-trust-card.test.tsx b/packages/node-admin/src/__tests__/ssh-trust-card.test.tsx index eaec6357b..e3db1ad5f 100644 --- a/packages/node-admin/src/__tests__/ssh-trust-card.test.tsx +++ b/packages/node-admin/src/__tests__/ssh-trust-card.test.tsx @@ -116,10 +116,4 @@ describe("SshTrustCard", () => { const { container } = render(); expect(container.firstChild).toBeNull(); }); - - it("an empty peer set says so plainly instead of rendering a lone header", () => { - render(); - expect(screen.getByText(FP_OWN_SIGN)).toBeDefined(); - expect(screen.getByText(/no saved machine identities yet/i)).toBeDefined(); - }); }); diff --git a/packages/plugins/cloudflare-tunnel/src/__tests__/cloudflare-tunnel.test.ts b/packages/plugins/cloudflare-tunnel/src/__tests__/cloudflare-tunnel.test.ts index 56d8981f5..d7522d7c0 100644 --- a/packages/plugins/cloudflare-tunnel/src/__tests__/cloudflare-tunnel.test.ts +++ b/packages/plugins/cloudflare-tunnel/src/__tests__/cloudflare-tunnel.test.ts @@ -211,20 +211,6 @@ describe("settingsFields", () => { ); }); - it("says how the token reaches the tunnel, and nothing about the backup", () => { - // The plugin owns the ONE sentence only it can say — the token travels in - // the connector's environment, never argv. The backup caveat is the - // PAGE's: the settings form renders it under every secret, because - // `subshell-server backup` is the server's fact (§ 9), and a manifest that - // restated it would be the copy that goes stale. (Review, 2026-09-16.) - const plugin = createPlugin(createTestHost()) as NetworkPlugin; - const token = plugin.settingsFields?.().find((f) => f.key === "tunnel-token"); - expect(token?.description).toBe( - "Subshell passes the token to the tunnel through its environment, never as a command-line argument.", - ); - expect(token?.description).not.toContain("subshell-server backup"); - }); - it("keys the secret a dashed name, because the store says so", () => { // The host stores each secret under the field's KEY, and secret names // become file names: lowercase letters, digits and hyphens only. A diff --git a/packages/plugins/headscale/src/__tests__/headscale.test.ts b/packages/plugins/headscale/src/__tests__/headscale.test.ts index 771bb4049..c34a14f6e 100644 --- a/packages/plugins/headscale/src/__tests__/headscale.test.ts +++ b/packages/plugins/headscale/src/__tests__/headscale.test.ts @@ -12,7 +12,6 @@ import { } from "@subshell-ai/plugin-api"; import { createScriptedHost, createTestHost } from "@subshell-ai/plugin-api/testing"; import pkg from "../../package.json"; -import { httpOnlyHint } from "../hints.js"; import createPlugin, { manifest } from "../index.js"; /** @@ -228,17 +227,6 @@ describe("headscale manifest", () => { ]); }); - it("ships the § 11 copy verbatim, pinned — the rule the drift would have broken", () => { - // § 11 declares every user-visible string verbatim; a reworded hint is - // invisible to every check except one that quotes the spec. So this - // quotes the spec. (The browser-consequence half is not this line's to - // say — the per-address secure-context line already states it.) - expect(httpOnlyHint().text).toBe( - "Headscale does not issue certificates, so this address is plain http over WireGuard.", - ); - expect(httpOnlyHint().docsUrl).toBe("https://github.com/juanfont/headscale/issues/2527"); - }); - it("flags a malformed controlUrl on the form, not minutes later in a join frame", () => { // The netbird twin's behavior, for a structurally identical field: an // obviously-wrong URL gets a named field error where the form can point, diff --git a/scripts/cli-e2e/install-script.sh b/scripts/cli-e2e/install-script.sh index cd06debdb..c2b104214 100755 --- a/scripts/cli-e2e/install-script.sh +++ b/scripts/cli-e2e/install-script.sh @@ -33,6 +33,10 @@ export SUBSHELL_SERVER_CONFIG_DIR="$W/srv-config" export SUBSHELL_SERVER_DATA_DIR="$W/srv-data" export SUBSHELL_NODE_ARTIFACTS_DIR="$W/artifacts" export SUBSHELL_RELEASE_URL="" # no internet fallback: prove OUR artifact is used +# The installer test expects its default destination under the temporary HOME. +# An inherited override would direct the compiled CLI into a developer's real +# data directory and make the scenario fail outside its sandbox. +unset SUBSHELL_DATA_DIR mkdir -p "$SUBSHELL_SERVER_CONFIG_DIR" "$SUBSHELL_SERVER_DATA_DIR" "$SUBSHELL_NODE_ARTIFACTS_DIR" echo "== publish the locally compiled agent as this instance's artifact" From 3a86926194bb08af2888cfffd59b27f5547feb8f Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Sun, 25 Oct 2026 23:17:36 -0700 Subject: [PATCH 3/5] test: fix flaky SSH preset and mobile scroll e2e cases --- e2e/tests/09-mobile-scroll.spec.ts | 30 ++++++++++++++++++++---------- e2e/tests/24-ssh-ux.spec.ts | 4 +++- 2 files changed, 23 insertions(+), 11 deletions(-) diff --git a/e2e/tests/09-mobile-scroll.spec.ts b/e2e/tests/09-mobile-scroll.spec.ts index e97dc1afc..6d73718c2 100644 --- a/e2e/tests/09-mobile-scroll.spec.ts +++ b/e2e/tests/09-mobile-scroll.spec.ts @@ -103,12 +103,20 @@ async function exerciseScrollKeys(page: Page, id: string): Promise { const bar = page.getByRole("toolbar", { name: "Terminal special keys" }); await expect(bar).toBeVisible(); await expect(bar).toBeInViewport(); + // The WebSocket upgrade can arrive before the pane publishes its terminal + // handles. Wait for the connected state so a scroll tap can't race the + // terminal's onReady effect. + await expect(bar.getByRole("button", { name: "Send Enter" })).toBeEnabled({ timeout: SPAWN_TIMEOUT }); // xterm moves the thumb asynchronously after any scroll: every read goes // through `expect.poll`, never a bare synchronous read (that false-negatived // in development of this spec). - const sliderTop = () => - page + const sliderTop = async () => { + const terminal = bar.locator("xpath=../div[1]"); + const screen = terminal.locator(".xterm-screen"); + const box = await screen.boundingBox(); + if (box) await page.mouse.move(box.x + box.width - 2, box.y + box.height / 2); + return terminal .locator(".xterm-scrollbar.xterm-vertical .xterm-slider") .first() .evaluate((el) => { @@ -118,11 +126,12 @@ async function exerciseScrollKeys(page: Page, id: string): Promise { visible: thumb.parentElement?.classList.contains("xterm-visible") ?? false, }; }); + }; - // Resting state: the scrollbar is visible (output overflowed the viewport) - // and two consecutive reads agree, so the stream has stopped. That stable - // value is the bottom anchor; xterm measures the top anchor at exactly - // `0px`, so no scroll-direction convention needs to be guessed. + // Two consecutive reads agree, so the stream has stopped. The thumb's + // inline position is the bottom anchor; its visibility class is transient + // on touch browsers and isn't part of the scroll behavior. xterm measures + // the top anchor at exactly `0px`, so no direction convention is guessed. let resting = -1; await expect .poll( @@ -144,9 +153,10 @@ async function exerciseScrollKeys(page: Page, id: string): Promise { // must stick for the buttons to be reachable at all. await button.scrollIntoViewIfNeeded(); await expect(button).toBeInViewport(); - const box = await button.boundingBox(); - if (!box) throw new Error(`key bar button "${label}" has no bounding box`); - await page.touchscreen.tap(box.x + box.width / 2, box.y + box.height / 2); + // Locator.tap keeps using a touch input in this project and re-checks the + // target after scrolling, so a late bar shift can't turn the tap into a + // touch on the surrounding terminal. + await button.tap(); }; // Known positions, not mere "it moved": the thumb sits at 0px at the top, @@ -154,7 +164,7 @@ async function exerciseScrollKeys(page: Page, id: string): Promise { // strictly between (the 100-line scrollback overflows a page on any // viewport this spec runs on). Only scrolling moves the thumb. const bottom = resting; - const at = () => sliderTop().then((s) => s.top); + const at = async () => (await sliderTop()).top; // The settle loop's `top >= 0` rule keeps reading here too: the sentinel // (-1, thumb with no inline top, e.g. a terminal mid-remount) must not diff --git a/e2e/tests/24-ssh-ux.spec.ts b/e2e/tests/24-ssh-ux.spec.ts index 8f6839ac6..caa217cb1 100644 --- a/e2e/tests/24-ssh-ux.spec.ts +++ b/e2e/tests/24-ssh-ux.spec.ts @@ -76,7 +76,7 @@ async function wire(page: Page, ready = true) { if (route.request().method() !== "POST") return route.continue(); const payload = route.request().postDataJSON(); saves.push(payload); - const preset = { ...payload, id: `ssh-${saves.length}` }; + const preset = { ...payload, id: `ssh-${saves.length}`, enabled: 1 }; presets.push(preset); return route.fulfill({ json: preset }); }); @@ -196,6 +196,7 @@ test("SSH preset selection restores remote key choices without connecting", asyn authentication: "remote", keyHome: "local", fingerprints: [fingerprint], + enabled: 1, }); await page.goto("/new?kind=ssh&preset=remote"); await expect(page.getByLabel("SSH destination", { exact: true })).toHaveValue(destination); @@ -217,6 +218,7 @@ test("local-key SSH presets restore the authentication choice and require a fres authentication: "local-key", keyHome: null, fingerprints: [], + enabled: 1, }); await page.goto("/new?kind=ssh&preset=local-key"); await expect(page.getByLabel("SSH destination", { exact: true })).toHaveValue(destination); From c64dbbf644fee574fc98279bef0760153fb31b9b Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Sun, 25 Oct 2026 23:29:23 -0700 Subject: [PATCH 4/5] fix(ci): isolate server fixtures and prepare required test inputs --- .claude/rules/testing.md | 5 +++-- .github/workflows/test.yml | 6 +++++- apps/website/package.json | 2 +- docs/testing.md | 4 +++- scripts/__tests__/test-plan-execution.test.ts | 17 +++++++++++++++++ scripts/cli-e2e/server-update.sh | 5 +++-- scripts/run-test-plan.ts | 2 ++ 7 files changed, 34 insertions(+), 7 deletions(-) diff --git a/.claude/rules/testing.md b/.claude/rules/testing.md index bae5e485f..bc109ce2c 100644 --- a/.claude/rules/testing.md +++ b/.claude/rules/testing.md @@ -51,8 +51,9 @@ and inspect failures from that capture. Confirm the expected file count ran. Bun's `--parallel` isolates each file. DB suites migrate their own database; runner-wide cleanup belongs in the package runner, not preload `afterAll`. -`SUBSHELL_TEST_PARALLEL` overrides local width. CI keeps serial package execution -while routing packages across at most three jobs. Tests in release verification +`SUBSHELL_TEST_PARALLEL` overrides local width. CI routes packages across at most three jobs. The server lane uses +`--parallel=1` for file isolation: its fixtures mutate process-global state. +Other lanes retain serial execution. Tests in release verification run without retries; a flaky failure needs fixing, not a green retry. ## Server-backed search coverage diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 18feb8060..8735a605e 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -86,6 +86,8 @@ jobs: env: TEST_PLAN: ${{ needs.plan.outputs.plan }} TEST_LANE: ${{ toJSON(matrix) }} + # Server fixtures mutate process-global state; isolate files even at width one. + SUBSHELL_TEST_PARALLEL: "1" steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - uses: ./.github/actions/setup-ci @@ -97,7 +99,9 @@ jobs: sudo apt-get update sudo apt-get install -y tmux jq openssh-client fi - - run: bun scripts/run-test-plan.ts unit + - run: | + if [ "${{ matrix.name }}" = server ]; then unset SUBSHELL_TEST_SERIAL; fi + bun scripts/run-test-plan.ts unit integration: name: Browser and compiled CLI tests diff --git a/apps/website/package.json b/apps/website/package.json index 5ff8f38ca..7807413af 100644 --- a/apps/website/package.json +++ b/apps/website/package.json @@ -16,7 +16,7 @@ "lint:check": "biome check --no-errors-on-unmatched app lib components scripts", "lint:staged": "biome check --no-errors-on-unmatched --write --unsafe --staged app lib components scripts", "test": "bash \"$(git rev-parse --show-toplevel)/scripts/par-test.sh\" 4 bun test", - "verify-types": "tsc --noEmit" + "verify-types": "bun run copy-releases && tsc --noEmit" }, "dependencies": { "@internal/subshell-protocol": "workspace:*", diff --git a/docs/testing.md b/docs/testing.md index 3989b7f7b..560e232f6 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -383,7 +383,9 @@ claim about a whole-repo migration speedup. It does show that adding workers to very short suites can cost more than it saves. Don't raise worker counts by instinct, enable in-file concurrency on shared DB/DOM state, or disable isolation without measuring the representative suite. Keep the existing local package -widths and CI's measured serial regime until hosted measurements justify a change. +widths. CI keeps other lanes serial, but the server lane uses `--parallel=1` +for isolation: a shared process leaks singleton state between server fixtures. +Increasing that width still needs representative hosted measurements. After the third cull, workspace unit verification on Bun 1.4.4 reports 12,251 passed, two skipped, and none failed across 1,000 files. All 60 Turbo tasks diff --git a/scripts/__tests__/test-plan-execution.test.ts b/scripts/__tests__/test-plan-execution.test.ts index ac4ffb60e..afc4a4d03 100644 --- a/scripts/__tests__/test-plan-execution.test.ts +++ b/scripts/__tests__/test-plan-execution.test.ts @@ -46,3 +46,20 @@ test("unit execution passes only selected packages and disables retries", () => test("a failing test command fails the orchestration process", () => { expect(execute(["@internal/assistant"], 7).result.exitCode).not.toBe(0); }); + +test("server tests build their frontend before booting a real server", () => { + const { result, argv } = execute(["@internal/server"]); + expect(result.exitCode, result.stderr.toString()).toBe(0); + expect(argv).toEqual([ + "turbo", + "run", + "build", + "--filter=@internal/server-web", + "turbo", + "run", + "test", + "--filter=@internal/server", + "--", + "--retry=0", + ]); +}); diff --git a/scripts/cli-e2e/server-update.sh b/scripts/cli-e2e/server-update.sh index bcf615a1f..8807012a5 100755 --- a/scripts/cli-e2e/server-update.sh +++ b/scripts/cli-e2e/server-update.sh @@ -185,13 +185,14 @@ echo "== 5. update --from, --no-restart (the swap, not the restart)" OUT=$("$INSTALLED" update --from "$W/next-subshell-server" --yes --no-restart 2>&1) || { echo "$OUT"; fail "update exited non-zero"; } -echo "$OUT" | grep -q "Backed up the database to" || { echo "$OUT"; fail "no backup was taken"; } echo "$OUT" | grep -q "Installed $NEXT" || { echo "$OUT"; fail "the swap was not reported"; } "$INSTALLED" version | grep -q "subshell-server $NEXT" || fail "the installed path still runs the old binary" [ -f "$INSTALLED.previous" ] || fail ".previous was not kept" [ -f "$SUBSHELL_SERVER_DATA_DIR/update/pending.json" ] || fail "no pending marker was written" grep -q "\"to\": \"$NEXT\"" "$SUBSHELL_SERVER_DATA_DIR/update/pending.json" || fail "the marker names the wrong version" -ls "$SUBSHELL_SERVER_DATA_DIR/backups/"*.db >/dev/null 2>&1 || fail "no backup file on disk" +ARCHIVE=$(bun -e 'console.log(JSON.parse(require("fs").readFileSync(process.argv[1], "utf8")).archiveBackup ?? "")' "$SUBSHELL_SERVER_DATA_DIR/update/pending.json") +[ -f "$ARCHIVE" ] || fail "no pre-upgrade archive on disk" +tar -tzf "$ARCHIVE" >/dev/null || fail "pre-upgrade archive is unreadable" ok "swapped to $NEXT, marker open, backup on disk" echo "== 6. the NEW binary's boot completes the transaction" diff --git a/scripts/run-test-plan.ts b/scripts/run-test-plan.ts index dd68e090a..10c6a547a 100644 --- a/scripts/run-test-plan.ts +++ b/scripts/run-test-plan.ts @@ -21,6 +21,8 @@ export function turbo(tasks: string[], packages: string[], extra: string[] = []) export function runUnit(plan: TestPlan, packages = plan.packages, scripts = plan.scripts): void { const workspaces = readWorkspaces(); const selected = packages.filter((name) => workspaces.find((p) => p.name === name)?.scripts.test); + // The cross-subshell tests boot a real server, which serves the built SPA. + if (selected.includes("@internal/server")) turbo(["build"], ["@internal/server-web"]); // Tests aren't cached. Full verification also rebuilds dependencies from this revision. turbo(["test"], selected, [...(plan.full ? ["--force"] : []), "--", "--retry=0"]); if (scripts) { From 5f15febb0322a27869e027e5301327b3ab4c6877 Mon Sep 17 00:00:00 2001 From: Theo Gravity Date: Sun, 25 Oct 2026 23:41:03 -0700 Subject: [PATCH 5/5] test(e2e): target the pane attached by each launch --- e2e/tests/10-attach-geometry-paste.spec.ts | 13 +++++++++---- e2e/tests/27-wrapped-links.spec.ts | 9 ++++++--- 2 files changed, 15 insertions(+), 7 deletions(-) diff --git a/e2e/tests/10-attach-geometry-paste.spec.ts b/e2e/tests/10-attach-geometry-paste.spec.ts index 46637c259..3d03fdf43 100644 --- a/e2e/tests/10-attach-geometry-paste.spec.ts +++ b/e2e/tests/10-attach-geometry-paste.spec.ts @@ -1,7 +1,7 @@ import { tmpdir } from "node:os"; import { decodeFrame } from "@internal/subshell-protocol/wire"; import { expect, test } from "@playwright/test"; -import { ADMIN_STATE, dismissDirectoryPanel, pickAgent, renameSubshell, solePaneId } from "./helpers"; +import { ADMIN_STATE, dismissDirectoryPanel, pickAgent, renameSubshell } from "./helpers"; test.use({ storageState: ADMIN_STATE }); @@ -100,11 +100,16 @@ test("wide → narrow reopen paints within the client's cols; image paste upload await pickAgent(p1.getByPlaceholder("Choose an agent"), "pi"); await p1.fill("#picker-working-dir", tmpdir()); await dismissDirectoryPanel(p1); + const attached = p1.waitForEvent("websocket", { + predicate: (socket) => socket.url().includes("/ws?subshell="), + timeout: 60_000, + }); await p1.getByRole("button", { name: "Start subshell" }).click(); await expect(p1).toHaveURL(/\/workspaces\/.+/, { timeout: 60_000 }); - // The landing is the workspace the launch opened; the pane id comes from - // its detail (spec 2026-10-17). - const subshellId = await solePaneId(p1); + // Read the pane actually attached by this launch; a workspace can contain + // earlier panes, and its navigation can still be settling. + const subshellId = new URL((await attached).url()).searchParams.get("subshell"); + expect(subshellId).toBeTruthy(); await renameSubshell(p1, name); // The replay wait is NODE-SIDE now: the negotiated wire carries every diff --git a/e2e/tests/27-wrapped-links.spec.ts b/e2e/tests/27-wrapped-links.spec.ts index 97c5a6226..781b5b997 100644 --- a/e2e/tests/27-wrapped-links.spec.ts +++ b/e2e/tests/27-wrapped-links.spec.ts @@ -1,5 +1,5 @@ import { expect, test } from "@playwright/test"; -import { ADMIN_STATE, dismissDirectoryPanel, pickAgent, solePaneId } from "./helpers"; +import { ADMIN_STATE, dismissDirectoryPanel, pickAgent } from "./helpers"; test.use({ storageState: ADMIN_STATE, permissions: ["clipboard-read", "clipboard-write"] }); @@ -88,10 +88,13 @@ test("a soft-wrapped URL links and copies as one line, live and across a reload" }); await page.getByRole("button", { name: "Start subshell" }).click(); await expect(page).toHaveURL(/\/workspaces\/.+/, { timeout: SPAWN_TIMEOUT }); - await socket; + const attached = await socket; await expect(page.getByText("reconnecting…")).toHaveCount(0, { timeout: SPAWN_TIMEOUT }); - const id = await solePaneId(page); + // The active socket identifies the new pane even when this workspace + // already contains another pane from an earlier scenario. + const id = new URL(attached.url()).searchParams.get("subshell"); + expect(id).toBeTruthy(); const logText = async () => { const res = await page.request.get(`/api/subshells/${id}/log`); if (!res.ok()) return "";