From 1baece5fd81280c752bfada53d14aec9eb1ba887 Mon Sep 17 00:00:00 2001 From: Egor Kraev Date: Sun, 18 Oct 2026 15:41:28 +0200 Subject: [PATCH 1/6] Plan DEV-2108: cut CI time with parallel jobs and split process tests --- .../.openspec.yaml | 3 ++ .../design.md | 30 ++++++++++++++++++ .../proposal.md | 31 +++++++++++++++++++ .../tasks.md | 25 +++++++++++++++ 4 files changed, 89 insertions(+) create mode 100644 openspec/changes/dev-2108-worktree-term-cut-ci-time-from-65-min-to-3-min-with-parallel/.openspec.yaml create mode 100644 openspec/changes/dev-2108-worktree-term-cut-ci-time-from-65-min-to-3-min-with-parallel/design.md create mode 100644 openspec/changes/dev-2108-worktree-term-cut-ci-time-from-65-min-to-3-min-with-parallel/proposal.md create mode 100644 openspec/changes/dev-2108-worktree-term-cut-ci-time-from-65-min-to-3-min-with-parallel/tasks.md diff --git a/openspec/changes/dev-2108-worktree-term-cut-ci-time-from-65-min-to-3-min-with-parallel/.openspec.yaml b/openspec/changes/dev-2108-worktree-term-cut-ci-time-from-65-min-to-3-min-with-parallel/.openspec.yaml new file mode 100644 index 0000000..e9682b0 --- /dev/null +++ b/openspec/changes/dev-2108-worktree-term-cut-ci-time-from-65-min-to-3-min-with-parallel/.openspec.yaml @@ -0,0 +1,3 @@ +schema: spec-driven +created: 2026-10-18 +skip_specs: true diff --git a/openspec/changes/dev-2108-worktree-term-cut-ci-time-from-65-min-to-3-min-with-parallel/design.md b/openspec/changes/dev-2108-worktree-term-cut-ci-time-from-65-min-to-3-min-with-parallel/design.md new file mode 100644 index 0000000..e0a9f32 --- /dev/null +++ b/openspec/changes/dev-2108-worktree-term-cut-ci-time-from-65-min-to-3-min-with-parallel/design.md @@ -0,0 +1,30 @@ +## Context + +Baseline (green run 39123723382): setup ~15–25 s, `pnpm lint` 36 s, vitest 148 s, Playwright 3.5 min at 2 workers (156 tests). Vitest file wall times: `hub-router` 99 s, `daemon-terminals` 81 s, `hub-server` 58 s (31 s of it one test sleeping past the 30 s code TTL), `static` 40 s (eslint), `daemon-server` 28 s; everything else ≤ 15 s. Process tests mostly wait on real timers. `main` has no branch protection, so job names are free to change. Architecture: system principle 4 names `test/architecture/static.test.ts` as its enforcement; principle 8 keeps split tests in `test/process` and shared helpers in `test/support`. No node principle is touched and no `index.yaml` change is needed. + +## Goals / Non-Goals + +**Goals:** total CI wall ≤ ~3 min (expected ~2); each e2e shard no slower than the slower vitest shard; zero failures over 3 consecutive full runs; local scripts unchanged. + +**Non-Goals:** cancelling superseded runs (concurrency groups); sharing `dist` across jobs; speeding up individual timer-bound tests (the 31 s code-expiry test stays — `src/hub/server/codes.test.ts` covers the logic with an injected clock, the process test covers the wiring). + +## Decisions + +**D1 — `architecture` job replaces lint.** CI drops the `pnpm lint` step; the `architecture` job runs `pnpm exec vitest run --project architecture`, which runs tsc, eslint, `la-arch-check`, likec4, structure and dependency rules. One definition of lint, the one principle 4 cites. Alternative rejected: a `lint` job plus excluding `static.test.ts` in CI — that leaves principle 4's enforcement unrun in CI and two lint definitions to keep in sync. + +**D2 — Composite setup action.** `.github/actions/setup/action.yml`: pnpm, Node 22 with pnpm cache, git identity (`ci` / `ci@example.com`), `pnpm install --frozen-lockfile`. Boolean input `arch-tools` (default `false`) adds uv, `living-architecture==0.3.1` and the TypeScript twin at the pinned commit `15ed4263…` (existing script, moved verbatim), on `PATH`. Checkout stays in each job (`persist-credentials: false`) since a local action needs the repo checked out first. + +**D3 — Job matrix.** `architecture`; `vitest` matrix `shard: [1, 2]` running `pnpm exec vitest run --project unit --project process --shard=${shard}/2`; `e2e` matrix `shard: [1, 2]` running `pnpm exec playwright test --project e2e --shard=${shard}/2`. `fail-fast: false`, `timeout-minutes: 10`, `ubuntu-latest`. Each vitest and Playwright invocation runs its project's `globalSetup` (`pnpm build`) once. Playwright with `fullyParallel: true` shards at test level, so e2e shards balance; perf stays local-only. + +**D4 — Split process files; balance shards.** Split the four files along `describe` blocks, splitting an oversized block across files where needed (carrying its hooks and timeouts), into `test/process/-.test.ts` of ≤ ~35 s each on the CI runner. Helpers shared by several new files go to `test/support/.ts`. Pure moves: total test count is unchanged (check vitest's `Tests N passed` before and after). Vitest assigns files to shards before ordering by duration, so small files alone do not guarantee balance: record each shard's files and wall time; if imbalance misses the target, add a custom `sequence.sequencer` whose `shard` step balances by recorded durations. + +**D5 — Playwright workers.** With `CI` set: `min(8, availableParallelism())`; otherwise unchanged (`min(8, max(1, floor(cpus / 2)))`). The comment on the 8 cap (inotify instances) stays and says CI uses every CPU. If e2e flakes at 4 workers in the measurement runs, drop to 3 and record why here. + +**D6 — Reinstall flake is a test bug.** `DaemonClient.waitOutput()` searches only output received since the latest snapshot (`applySnapshot()` clears it) — by design. The reinstall test creates its terminal with `echo after-reinstall; exec sleep 60` and attaches afterwards, so the marker can land only in the snapshot (failure trace: `working` → `done(attach)` → `idle`, no output after). Fix: gate the command until attach completes, following the existing `started()` helper pattern. `waitOutput` semantics stay; production code changes only if a deterministic regression shows snapshot content missing. + +## Risks / Trade-offs + +- [Four CPUs × four Playwright workers (each Chrome + hub + daemon) contend for CPU] → measure 3 runs; fall back to 3 workers (D5). +- [Split files reshuffle timing, surfacing latent races] → 3 consecutive full CI runs plus a local unsharded `pnpm test:ci`; any flake found is fixed at its cause, not retried. +- [Each job repeats setup and `pnpm build`] → ~25 s per job in parallel; accepted (dist sharing is a non-goal). +- [Lint failures appear as a failed `static.test.ts` assertion] → the assertion message carries eslint/tsc stdout + stderr. diff --git a/openspec/changes/dev-2108-worktree-term-cut-ci-time-from-65-min-to-3-min-with-parallel/proposal.md b/openspec/changes/dev-2108-worktree-term-cut-ci-time-from-65-min-to-3-min-with-parallel/proposal.md new file mode 100644 index 0000000..30ea4fc --- /dev/null +++ b/openspec/changes/dev-2108-worktree-term-cut-ci-time-from-65-min-to-3-min-with-parallel/proposal.md @@ -0,0 +1,31 @@ +## Why + +A CI run takes 6.5–9 min: one job on a 4-CPU runner runs lint, vitest and Playwright in sequence. Profiling (runs 39123723382, 38746061575) shows three causes beyond the serial layout: `pnpm lint` repeats exactly what `test/architecture/static.test.ts` runs (`tsc -b` + eslint, ~40 s); vitest's 148 s wall is bounded by four huge, timer-bound process-test files (`hub-router` 99 s, `daemon-terminals` 81 s, `hub-server` 58 s, `daemon-server` 28 s) whose tests run sequentially; and Playwright runs only 2 workers on CI. Main is also intermittently red from an `install-local` test that misses output printed before it attaches. + +## What Changes + +- Split `.github/workflows/ci.yml` into parallel jobs: `architecture` (the architecture vitest project, which includes tsc + eslint and replaces the `pnpm lint` step), `vitest` × 2 shards (unit + process), `e2e` × 2 shards (Playwright `--shard`). +- Move the shared setup into a composite action `.github/actions/setup`, with the living-architecture tools behind an input so only the `architecture` job installs them. +- Run Playwright with every CPU (capped at 8) on CI; local runs keep half the CPUs. +- Split the four big process-test files into smaller files (pure moves, no test renamed, rewritten or dropped) so vitest shards balance and no file bounds the critical path. +- Fix the `install-local` reinstall flake in the test: gate its command until attach completes. +- `pnpm test`, `test:ci`, `test:vitest` and `lint` stay unchanged and keep working locally. + +## Capabilities + +### New Capabilities + +None. + +### Modified Capabilities + +None — CI layout and test organisation only; no specified behaviour changes (`skip_specs: true`). + +## Impact + +- `.github/workflows/ci.yml`, new `.github/actions/setup/action.yml`. +- `playwright.config.ts` (CI worker count). +- `test/process/{hub-router,daemon-terminals,hub-server,daemon-server}.test.ts` split into `test/process/-.test.ts`; shared helpers into `test/support/`. +- `test/process/install-local.test.ts` (reinstall test). +- Possibly a vitest sequencer file if shard imbalance requires it (design D4). +- README, if it describes the single CI job. diff --git a/openspec/changes/dev-2108-worktree-term-cut-ci-time-from-65-min-to-3-min-with-parallel/tasks.md b/openspec/changes/dev-2108-worktree-term-cut-ci-time-from-65-min-to-3-min-with-parallel/tasks.md new file mode 100644 index 0000000..c28aa51 --- /dev/null +++ b/openspec/changes/dev-2108-worktree-term-cut-ci-time-from-65-min-to-3-min-with-parallel/tasks.md @@ -0,0 +1,25 @@ +## 1. Tests + +- [ ] 1.1 Record the baseline: run `pnpm test:vitest` and note the `Tests N passed` total; verify by writing N into this task line when done +- [ ] 1.2 `test/process/install-local.test.ts` reinstall test: gate `echo after-reinstall` until attach completes, following the `started()` helper pattern (design D6); verify by running that test 20× in a loop (`vitest run --project process -t 'two reinstalls'`) with zero failures, and by showing it fails reliably when the gate is released before attach + +## 2. Split the process-test files + +- [ ] 2.1 Split `test/process/hub-router.test.ts` into `hub-router-.test.ts` files (design D4); shared helpers into `test/support/`; verify every moved test passes and the total from 1.1 is unchanged +- [ ] 2.2 Split `test/process/daemon-terminals.test.ts` the same way (its `activity` block likely needs splitting across files); verify as 2.1 +- [ ] 2.3 Split `test/process/hub-server.test.ts` the same way (the 31 s code-expiry test moves unchanged); verify as 2.1 +- [ ] 2.4 Split `test/process/daemon-server.test.ts` the same way; verify as 2.1 +- [ ] 2.5 Verify no old file name remains referenced (README, comments, specs) and `pnpm lint` passes + +## 3. CI + +- [ ] 3.1 Add `.github/actions/setup/action.yml` with input `arch-tools` (design D2); verify by the workflow run in 3.3 +- [ ] 3.2 Rewrite `.github/workflows/ci.yml` as jobs `architecture`, `vitest` (shard 1/2, 2/2), `e2e` (shard 1/2, 2/2) per design D1/D3, keeping the hardware-WebGL comment; verify with `actionlint` if available, else by the run in 3.3 +- [ ] 3.3 `playwright.config.ts`: CI workers = `min(8, availableParallelism())` (design D5); verify locally that `CI=1 pnpm exec playwright test --list` and an unset `CI` both load, and on CI that each e2e shard logs 4 workers +- [ ] 3.4 Update README if it describes the single CI job; verify by grep for `pnpm lint` / CI wording + +## 4. Measure and final gates + +- [ ] 4.1 Local: unsharded `pnpm test:ci` passes, and `pnpm test` (incl. perf) passes; `openspec validate dev-2108-worktree-term-cut-ci-time-from-65-min-to-3-min-with-parallel --strict` passes +- [ ] 4.2 After the PR is open: 3 consecutive full CI runs (`gh run rerun`) all green; record per-job wall times, each vitest shard's file list, and the total in the PR description +- [ ] 4.3 Check targets: total ≤ ~3 min, each e2e shard ≤ the slower vitest shard, no failures; if vitest shards are imbalanced enough to miss the target, add the duration-balancing sequencer (design D4) and re-measure; if e2e flakes at 4 workers, drop to 3 (design D5) From d75d478046f587566ec65b4c6277e4e060a286e2 Mon Sep 17 00:00:00 2001 From: Egor Kraev Date: Sun, 18 Oct 2026 16:08:40 +0200 Subject: [PATCH 2/6] Fix the reinstall and bounded-queue test flakes; record the vitest baseline Gate the reinstall test's command until attach completes (design D6), and make the bounded-queue throughput check wait for 8 MiB instead of timing a fixed window, which a loaded machine misses. --- .../tasks.md | 4 ++-- test/process/daemon-server.test.ts | 10 +++++++++- test/process/install-local.test.ts | 4 +++- 3 files changed, 14 insertions(+), 4 deletions(-) diff --git a/openspec/changes/dev-2108-worktree-term-cut-ci-time-from-65-min-to-3-min-with-parallel/tasks.md b/openspec/changes/dev-2108-worktree-term-cut-ci-time-from-65-min-to-3-min-with-parallel/tasks.md index c28aa51..2b5dfac 100644 --- a/openspec/changes/dev-2108-worktree-term-cut-ci-time-from-65-min-to-3-min-with-parallel/tasks.md +++ b/openspec/changes/dev-2108-worktree-term-cut-ci-time-from-65-min-to-3-min-with-parallel/tasks.md @@ -1,7 +1,7 @@ ## 1. Tests -- [ ] 1.1 Record the baseline: run `pnpm test:vitest` and note the `Tests N passed` total; verify by writing N into this task line when done -- [ ] 1.2 `test/process/install-local.test.ts` reinstall test: gate `echo after-reinstall` until attach completes, following the `started()` helper pattern (design D6); verify by running that test 20× in a loop (`vitest run --project process -t 'two reinstalls'`) with zero failures, and by showing it fails reliably when the gate is released before attach +- [x] 1.1 Record the baseline: run `pnpm test:vitest` and note the `Tests N passed` total; verify by writing N into this task line when done — N = 2051 +- [x] 1.2 `test/process/install-local.test.ts` reinstall test: gate `echo after-reinstall` until attach completes, following the `started()` helper pattern (design D6); verify by running that test 20× in a loop (`vitest run --project process -t 'two reinstalls'`) with zero failures, and by showing it fails reliably when the gate is released before attach ## 2. Split the process-test files diff --git a/test/process/daemon-server.test.ts b/test/process/daemon-server.test.ts index 0d8995b..4f80fa7 100644 --- a/test/process/daemon-server.test.ts +++ b/test/process/daemon-server.test.ts @@ -276,8 +276,16 @@ describe('bounded connection queue', () => { await sleep(250); peak = Math.max(peak, residentBytes(daemon.pid)); } + // Waits for the volume rather than timing it: a loaded machine slows `yes`, not only a blocked PTY. + await waitUntil( + () => { + peak = Math.max(peak, residentBytes(daemon.pid)); + return view.position - start > 8 * MiB; + }, + 'the reader to receive 8 MiB', + 30_000, + ); expect(peak - baseline).toBeLessThan(128 * MiB); - expect(view.position - start).toBeGreaterThan(8 * MiB); expect(view.gaps).toEqual([]); stalled.resumeReading(); expect(await stalled.waitFor('detached')).toEqual({ t: 'detached', termId: term.termId, reason: 'lagging' }); diff --git a/test/process/install-local.test.ts b/test/process/install-local.test.ts index 84be34d..64a09b2 100644 --- a/test/process/install-local.test.ts +++ b/test/process/install-local.test.ts @@ -311,8 +311,10 @@ describe('reinstalling', () => { const after = await client(); expect((await after.waitFor('hello')).instance).toBe(instance); await after.watch(repo); - const term = await after.create(repo, { command: 'echo after-reinstall; exec sleep 60' }); + // Held until attached: waitOutput sees only output that follows the snapshot. + const term = await after.create(repo, { command: 'read -r _; echo after-reinstall; exec sleep 60' }); await after.attach(term.termId); + after.sendInput(term.termId, '\r'); await after.waitOutput(term.termId, 'after-reinstall'); expect(shim(host.home, ['--version']).stdout).toBe(`wtd ${packageVersion()} (daemon protocol 6, browser protocol 9)\n`); }); From 80e51148786a18de0d9c75c995d05daf8aa5aba3 Mon Sep 17 00:00:00 2001 From: Egor Kraev Date: Sun, 18 Oct 2026 16:50:27 +0200 Subject: [PATCH 3/6] Run CI as parallel jobs and split the four big process-test files CI now runs an architecture job (tsc, ESLint and the architecture tests, replacing the separate lint step), two vitest shards and two Playwright shards, sharing setup through a composite action. Playwright uses every CPU on CI. The hub-router, daemon-terminals, hub-server and daemon-server process tests move unchanged into smaller files along their describe blocks, with each file's fixture and shared helpers in test/support, so no single file bounds a shard. The arc42 enforcement tags point at the new files. Two timeouts the extra concurrency exposed are fixed at their cause: the process project defaults to the 60 s test timeout every other process file already set, and the large-payload framing test no longer deep-compares a 200 000-element typed array. --- .github/actions/setup/action.yml | 40 + .github/workflows/ci.yml | 56 +- architecture/daemon.arc42.md | 8 +- architecture/hub.arc42.md | 6 +- architecture/system.arc42.md | 2 +- .../design.md | 2 +- .../tasks.md | 14 +- playwright.config.ts | 3 +- src/protocol/frames.test.ts | 4 +- .../process/daemon-server-connections.test.ts | 161 +++ ....test.ts => daemon-server-startup.test.ts} | 169 +-- .../daemon-terminals-attention.test.ts | 135 ++ .../daemon-terminals-lifecycle.test.ts | 369 +++++ test/process/daemon-terminals-output.test.ts | 231 ++++ test/process/daemon-terminals-unseen.test.ts | 155 +++ test/process/daemon-terminals.test.ts | 905 ------------ test/process/hub-router-config.test.ts | 404 ++++++ test/process/hub-router-links.test.ts | 200 +++ test/process/hub-router-reinstall.test.ts | 138 ++ test/process/hub-router-remote.test.ts | 274 ++++ test/process/hub-router-routing.test.ts | 176 +++ test/process/hub-router.test.ts | 1211 ----------------- test/process/hub-server-codes.test.ts | 61 + test/process/hub-server-http.test.ts | 276 ++++ test/process/hub-server-websocket.test.ts | 339 +++++ test/process/hub-server.test.ts | 716 ---------- test/support/daemon-server.ts | 15 + test/support/daemon-terminals.ts | 66 + test/support/hub-router.ts | 116 ++ test/support/hub-server.ts | 59 + vitest.config.ts | 10 +- 31 files changed, 3282 insertions(+), 3039 deletions(-) create mode 100644 .github/actions/setup/action.yml create mode 100644 test/process/daemon-server-connections.test.ts rename test/process/{daemon-server.test.ts => daemon-server-startup.test.ts} (60%) create mode 100644 test/process/daemon-terminals-attention.test.ts create mode 100644 test/process/daemon-terminals-lifecycle.test.ts create mode 100644 test/process/daemon-terminals-output.test.ts create mode 100644 test/process/daemon-terminals-unseen.test.ts delete mode 100644 test/process/daemon-terminals.test.ts create mode 100644 test/process/hub-router-config.test.ts create mode 100644 test/process/hub-router-links.test.ts create mode 100644 test/process/hub-router-reinstall.test.ts create mode 100644 test/process/hub-router-remote.test.ts create mode 100644 test/process/hub-router-routing.test.ts delete mode 100644 test/process/hub-router.test.ts create mode 100644 test/process/hub-server-codes.test.ts create mode 100644 test/process/hub-server-http.test.ts create mode 100644 test/process/hub-server-websocket.test.ts delete mode 100644 test/process/hub-server.test.ts create mode 100644 test/support/daemon-server.ts create mode 100644 test/support/daemon-terminals.ts create mode 100644 test/support/hub-router.ts create mode 100644 test/support/hub-server.ts diff --git a/.github/actions/setup/action.yml b/.github/actions/setup/action.yml new file mode 100644 index 0000000..41ef84c --- /dev/null +++ b/.github/actions/setup/action.yml @@ -0,0 +1,40 @@ +name: Setup +description: pnpm, Node, git identity and dependencies; optionally the living-architecture tools. + +inputs: + arch-tools: + description: Install la-arch-check and its TypeScript twin, which the architecture tests run. + required: false + default: 'false' + +runs: + using: composite + steps: + - uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + - if: inputs.arch-tools == 'true' + uses: astral-sh/setup-uv@d0cc045d04ccac9d8b7881df0226f9e82c39688e # v6 + # la-arch-check and its TypeScript twin, which is not on npm. + - if: inputs.arch-tools == 'true' + shell: bash + run: uv tool install --no-build living-architecture==0.3.1 + - if: inputs.arch-tools == 'true' + shell: bash + run: | + twin="$RUNNER_TEMP/living-architecture" + git init -q "$twin" + git -C "$twin" fetch -q --depth 1 https://github.com/MotleyAI/living-architecture.git 15ed4263ccce08513535aae077c8e7ba7363571d # v0.3.1 + git -C "$twin" checkout -q FETCH_HEAD + cd "$twin/node" + npm ci --ignore-scripts + npm run build + mkdir "$twin/bin" + for file in dist/bin/*.js; do ln -s "$PWD/$file" "$twin/bin/$(basename "$file" .js)"; done + echo "$twin/bin" >> "$GITHUB_PATH" + - shell: bash + run: git config --global user.name ci && git config --global user.email ci@example.com + - shell: bash + run: pnpm install --frozen-lockfile diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b7f7081..33dbe71 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,34 +9,44 @@ permissions: contents: read jobs: - test: + # tsc, ESLint, la-arch-check, LikeC4 and the structure and dependency rules. + architecture: runs-on: ubuntu-latest timeout-minutes: 10 steps: - uses: actions/checkout@v4 with: persist-credentials: false - - uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4 - - uses: actions/setup-node@v4 + - uses: ./.github/actions/setup with: - node-version: 22 - cache: pnpm - - uses: astral-sh/setup-uv@d0cc045d04ccac9d8b7881df0226f9e82c39688e # v6 - # la-arch-check (run by the architecture tests) and its TypeScript twin, which is not on npm. - - run: uv tool install --no-build living-architecture==0.3.1 - - run: | - twin="$RUNNER_TEMP/living-architecture" - git init -q "$twin" - git -C "$twin" fetch -q --depth 1 https://github.com/MotleyAI/living-architecture.git 15ed4263ccce08513535aae077c8e7ba7363571d # v0.3.1 - git -C "$twin" checkout -q FETCH_HEAD - cd "$twin/node" - npm ci --ignore-scripts - npm run build - mkdir "$twin/bin" - for file in dist/bin/*.js; do ln -s "$PWD/$file" "$twin/bin/$(basename "$file" .js)"; done - echo "$twin/bin" >> "$GITHUB_PATH" - - run: git config --global user.name ci && git config --global user.email ci@example.com - - run: pnpm install --frozen-lockfile - - run: pnpm lint + arch-tools: 'true' + - run: pnpm exec vitest run --project architecture + + vitest: + runs-on: ubuntu-latest + timeout-minutes: 10 + strategy: + fail-fast: false + matrix: + shard: [1, 2] + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + - uses: ./.github/actions/setup + - run: pnpm exec vitest run --project unit --project process --shard=${{ matrix.shard }}/2 + + e2e: + runs-on: ubuntu-latest + timeout-minutes: 10 + strategy: + fail-fast: false + matrix: + shard: [1, 2] + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + - uses: ./.github/actions/setup # The perf budgets need hardware WebGL, which hosted runners lack; `pnpm test` runs them locally. - - run: pnpm test:ci + - run: pnpm exec playwright test --project e2e --shard=${{ matrix.shard }}/2 diff --git a/architecture/daemon.arc42.md b/architecture/daemon.arc42.md index 16624b0..920a220 100644 --- a/architecture/daemon.arc42.md +++ b/architecture/daemon.arc42.md @@ -42,9 +42,9 @@ flowchart TD ## Principles 1. `worktrees`, `terminals` and `state` never import each other; only `server` and `main` compose them. [enforced: arch_check:model-truth] -2. Its only listener is its owner-only unix socket. [enforced: test:test/process/daemon-server.test.ts] -3. A PTY ends only by `closeTerm`, `removeWorktree` of its worktree, process exit or `shutdown`, never by a client leaving. [enforced: test:test/process/daemon-terminals.test.ts] [enforced: test:test/process/daemon-worktrees.test.ts] -4. PTY output is never dropped; backpressure pauses the PTY. [enforced: test:src/daemon/terminals/flow.test.ts] [enforced: test:test/process/daemon-terminals.test.ts] -5. An attach yields a snapshot and then live output with no gap and no duplicate byte. [enforced: test:test/process/daemon-terminals.test.ts] +2. Its only listener is its owner-only unix socket. [enforced: test:test/process/daemon-server-startup.test.ts] +3. A PTY ends only by `closeTerm`, `removeWorktree` of its worktree, process exit or `shutdown`, never by a client leaving. [enforced: test:test/process/daemon-terminals-lifecycle.test.ts] [enforced: test:test/process/daemon-worktrees.test.ts] +4. PTY output is never dropped; backpressure pauses the PTY. [enforced: test:src/daemon/terminals/flow.test.ts] [enforced: test:test/process/daemon-terminals-output.test.ts] +5. An attach yields a snapshot and then live output with no gap and no duplicate byte. [enforced: test:test/process/daemon-terminals-output.test.ts] 6. Worktree changes are observed by events, never by polling. [review] 7. Persisted state references no worktree that is gone and has no terminals. [enforced: test:src/daemon/state/store.test.ts] diff --git a/architecture/hub.arc42.md b/architecture/hub.arc42.md index c89d0b4..cf0cb1e 100644 --- a/architecture/hub.arc42.md +++ b/architecture/hub.arc42.md @@ -48,9 +48,9 @@ flowchart TD 1. Reaches every daemon, local or remote, only through a link; never imports daemon code. [enforced: arch_check:model-truth] 2. Only `server` faces the browser; only `links` opens daemon connections or spawns processes. [enforced: arch_check:model-truth] [enforced: test:test/architecture/dependencies.test.ts] -3. Binds `127.0.0.1` only; rejects any WebSocket upgrade without the token and matching `Origin` and `Host`. [enforced: test:test/process/hub-server.test.ts] -4. Addresses daemon entities as (host, id) and never rewrites daemon ids. [enforced: test:test/process/hub-router.test.ts] -5. Relays terminal bytes without buffering beyond frames in flight; state other than config lives in daemons. [enforced: test:test/process/hub-router.test.ts] +3. Binds `127.0.0.1` only; rejects any WebSocket upgrade without the token and matching `Origin` and `Host`. [enforced: test:test/process/hub-server-http.test.ts] [enforced: test:test/process/hub-server-websocket.test.ts] +4. Addresses daemon entities as (host, id) and never rewrites daemon ids. [enforced: test:test/process/hub-router-routing.test.ts] +5. Relays terminal bytes without buffering beyond frames in flight; state other than config lives in daemons. [enforced: test:test/process/hub-router-routing.test.ts] ## Rationale diff --git a/architecture/system.arc42.md b/architecture/system.arc42.md index 4de6eca..daaa432 100644 --- a/architecture/system.arc42.md +++ b/architecture/system.arc42.md @@ -91,7 +91,7 @@ flowchart TD 4. Code passes the strict compiler config and the strict lint config with zero findings; suppressions state a reason. [enforced: test:test/architecture/static.test.ts] 5. Input from outside the process is schema-parsed at the boundary; unparsed data never travels inward. [review] 6. Errors are never swallowed: a catch rethrows, reports, or returns a typed failure. [review] -7. Nothing listens beyond loopback or an owner-only unix socket. [review] [enforced: test:test/process/hub-server.test.ts] +7. Nothing listens beyond loopback or an owner-only unix socket. [review] [enforced: test:test/process/hub-server-http.test.ts] 8. Every production change ships with tests in its tier: unit beside the code, `test/process`, `test/e2e`; `test/integration` holds only opt-in real-SSH tests. Mocks are typed against the real module. [review] ## Rationale diff --git a/openspec/changes/dev-2108-worktree-term-cut-ci-time-from-65-min-to-3-min-with-parallel/design.md b/openspec/changes/dev-2108-worktree-term-cut-ci-time-from-65-min-to-3-min-with-parallel/design.md index e0a9f32..0ce4c2b 100644 --- a/openspec/changes/dev-2108-worktree-term-cut-ci-time-from-65-min-to-3-min-with-parallel/design.md +++ b/openspec/changes/dev-2108-worktree-term-cut-ci-time-from-65-min-to-3-min-with-parallel/design.md @@ -1,6 +1,6 @@ ## Context -Baseline (green run 39123723382): setup ~15–25 s, `pnpm lint` 36 s, vitest 148 s, Playwright 3.5 min at 2 workers (156 tests). Vitest file wall times: `hub-router` 99 s, `daemon-terminals` 81 s, `hub-server` 58 s (31 s of it one test sleeping past the 30 s code TTL), `static` 40 s (eslint), `daemon-server` 28 s; everything else ≤ 15 s. Process tests mostly wait on real timers. `main` has no branch protection, so job names are free to change. Architecture: system principle 4 names `test/architecture/static.test.ts` as its enforcement; principle 8 keeps split tests in `test/process` and shared helpers in `test/support`. No node principle is touched and no `index.yaml` change is needed. +Baseline (green run 39123723382): setup ~15–25 s, `pnpm lint` 36 s, vitest 148 s, Playwright 3.5 min at 2 workers (156 tests). Vitest file wall times: `hub-router` 99 s, `daemon-terminals` 81 s, `hub-server` 58 s (31 s of it one test sleeping past the 30 s code TTL), `static` 40 s (eslint), `daemon-server` 28 s; everything else ≤ 15 s. Process tests mostly wait on real timers. `main` has no branch protection, so job names are free to change. Architecture: system principle 4 names `test/architecture/static.test.ts` as its enforcement; principle 8 keeps split tests in `test/process` and shared helpers in `test/support`. No principle's wording changes and no `index.yaml` change is needed; the arc42 `[enforced: test:…]` tags naming the four split files are repointed to the files that now hold those tests (`la-arch-check` does not check that tagged paths exist). ## Goals / Non-Goals diff --git a/openspec/changes/dev-2108-worktree-term-cut-ci-time-from-65-min-to-3-min-with-parallel/tasks.md b/openspec/changes/dev-2108-worktree-term-cut-ci-time-from-65-min-to-3-min-with-parallel/tasks.md index 2b5dfac..c698196 100644 --- a/openspec/changes/dev-2108-worktree-term-cut-ci-time-from-65-min-to-3-min-with-parallel/tasks.md +++ b/openspec/changes/dev-2108-worktree-term-cut-ci-time-from-65-min-to-3-min-with-parallel/tasks.md @@ -5,21 +5,21 @@ ## 2. Split the process-test files -- [ ] 2.1 Split `test/process/hub-router.test.ts` into `hub-router-.test.ts` files (design D4); shared helpers into `test/support/`; verify every moved test passes and the total from 1.1 is unchanged -- [ ] 2.2 Split `test/process/daemon-terminals.test.ts` the same way (its `activity` block likely needs splitting across files); verify as 2.1 -- [ ] 2.3 Split `test/process/hub-server.test.ts` the same way (the 31 s code-expiry test moves unchanged); verify as 2.1 -- [ ] 2.4 Split `test/process/daemon-server.test.ts` the same way; verify as 2.1 -- [ ] 2.5 Verify no old file name remains referenced (README, comments, specs) and `pnpm lint` passes +- [x] 2.1 Split `test/process/hub-router.test.ts` into `hub-router-.test.ts` files (design D4); shared helpers into `test/support/`; verify every moved test passes and the total from 1.1 is unchanged +- [x] 2.2 Split `test/process/daemon-terminals.test.ts` the same way (its `activity` block likely needs splitting across files); verify as 2.1 +- [x] 2.3 Split `test/process/hub-server.test.ts` the same way (the 31 s code-expiry test moves unchanged); verify as 2.1 +- [x] 2.4 Split `test/process/daemon-server.test.ts` the same way; verify as 2.1 +- [x] 2.5 Verify no old file name remains referenced (README, comments, specs) and `pnpm lint` passes; repoint the arc42 `[enforced: test:…]` tags to the new files (design Context) ## 3. CI - [ ] 3.1 Add `.github/actions/setup/action.yml` with input `arch-tools` (design D2); verify by the workflow run in 3.3 - [ ] 3.2 Rewrite `.github/workflows/ci.yml` as jobs `architecture`, `vitest` (shard 1/2, 2/2), `e2e` (shard 1/2, 2/2) per design D1/D3, keeping the hardware-WebGL comment; verify with `actionlint` if available, else by the run in 3.3 - [ ] 3.3 `playwright.config.ts`: CI workers = `min(8, availableParallelism())` (design D5); verify locally that `CI=1 pnpm exec playwright test --list` and an unset `CI` both load, and on CI that each e2e shard logs 4 workers -- [ ] 3.4 Update README if it describes the single CI job; verify by grep for `pnpm lint` / CI wording +- [x] 3.4 Update README if it describes the single CI job; verify by grep for `pnpm lint` / CI wording ## 4. Measure and final gates -- [ ] 4.1 Local: unsharded `pnpm test:ci` passes, and `pnpm test` (incl. perf) passes; `openspec validate dev-2108-worktree-term-cut-ci-time-from-65-min-to-3-min-with-parallel --strict` passes +- [x] 4.1 Local: unsharded `pnpm test:ci` passes, and `pnpm test` (incl. perf) passes; `openspec validate dev-2108-worktree-term-cut-ci-time-from-65-min-to-3-min-with-parallel --strict` passes - [ ] 4.2 After the PR is open: 3 consecutive full CI runs (`gh run rerun`) all green; record per-job wall times, each vitest shard's file list, and the total in the PR description - [ ] 4.3 Check targets: total ≤ ~3 min, each e2e shard ≤ the slower vitest shard, no failures; if vitest shards are imbalanced enough to miss the target, add the duration-balancing sequencer (design D4) and re-measure; if e2e flakes at 4 workers, drop to 3 (design D5) diff --git a/playwright.config.ts b/playwright.config.ts index 43dfb8b..8daa51f 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -5,7 +5,8 @@ export default defineConfig({ testDir: 'test/e2e', globalSetup: './test/e2e/global-setup.ts', // Each worker runs a Chrome, a hub and a daemon; more than 8 exhausts the default 128 inotify instances per user. - workers: Math.min(8, Math.max(1, Math.floor(availableParallelism() / 2))), + // CI uses every CPU; local runs leave half for the rest of the machine. + workers: Math.min(8, process.env['CI'] ? availableParallelism() : Math.max(1, Math.floor(availableParallelism() / 2))), timeout: 60_000, use: { channel: 'chrome', diff --git a/src/protocol/frames.test.ts b/src/protocol/frames.test.ts index 3368191..0d0bd93 100644 --- a/src/protocol/frames.test.ts +++ b/src/protocol/frames.test.ts @@ -149,7 +149,9 @@ describe('stream framing', () => { const got: Frame[] = []; for (let at = 0; at < bytes.length; at += 1000) got.push(...decoder.push(bytes.subarray(at, at + 1000))); decoder.end(); - expect(got).toEqual([{ kind: 2, payload }]); + // toEqual walks a typed array element by element, which takes seconds on a loaded machine. + expect(got.map((frame) => [frame.kind, frame.payload.length])).toEqual([[2, payload.length]]); + expect(got[0]?.payload.findIndex((byte, i) => byte !== payload[i])).toBe(-1); }); it('fails on an unknown kind', () => { diff --git a/test/process/daemon-server-connections.test.ts b/test/process/daemon-server-connections.test.ts new file mode 100644 index 0000000..b4e7237 --- /dev/null +++ b/test/process/daemon-server-connections.test.ts @@ -0,0 +1,161 @@ +import { existsSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { describe, expect, it, vi } from 'vitest'; +import type { DaemonClient } from '../support/daemon-client.js'; +import { + addWorktree, + alive, + canConnect, + fakeWorktrees, + makeRepo, + residentBytes, + sleep, + waitUntil, + type WtdProcess, +} from '../support/daemon-host.js'; +import { host, useDaemonServerHost } from '../support/daemon-server.js'; + +vi.setConfig({ testTimeout: 60_000, hookTimeout: 30_000 }); +useDaemonServerHost(); + +const MiB = 1024 * 1024; + +describe('multiple clients', () => { + it('serves concurrent clients independently', async () => { + const repo = makeRepo(join(host.dir, 'repo')); + await host.start(); + const clients = await Promise.all([host.client(), host.client(), host.client()]); + const states = await Promise.all(clients.map((c) => c.watch(repo))); + for (const state of states) expect(state.worktrees.map((w) => w.path)).toEqual([repo]); + const [a, b] = clients; + const from = b.mark(); + expect((await a.request({ t: 'detach', termId: 1 })).t).toBe('error'); + await sleep(200); + expect(b.messages.slice(from)).toEqual([]); + }); +}); + +describe('bounded connection queue', () => { + it('keeps memory bounded and the PTY running for others when a client stops reading', async () => { + const repo = makeRepo(join(host.dir, 'repo')); + const daemon = await host.start(); + const reader = await host.client(); + await reader.watch(repo); + const term = await reader.create(repo, { command: 'yes 0123456789abcdefghijklmnopqrstuvwxyz' }); + const stalled = await host.client(); + await stalled.watch(repo); + stalled.autoAck = false; + await stalled.attach(term.termId); + stalled.pauseReading(); + const view = await reader.attach(term.termId); + const baseline = residentBytes(daemon.pid); + let peak = baseline; + const start = view.position; + for (let i = 0; i < 20; i++) { + await sleep(250); + peak = Math.max(peak, residentBytes(daemon.pid)); + } + // Waits for the volume rather than timing it: a loaded machine slows `yes`, not only a blocked PTY. + await waitUntil( + () => { + peak = Math.max(peak, residentBytes(daemon.pid)); + return view.position - start > 8 * MiB; + }, + 'the reader to receive 8 MiB', + 30_000, + ); + expect(peak - baseline).toBeLessThan(128 * MiB); + expect(view.gaps).toEqual([]); + stalled.resumeReading(); + expect(await stalled.waitFor('detached')).toEqual({ t: 'detached', termId: term.termId, reason: 'lagging' }); + }); + + it('closes a connection whose unread replies exceed 64 MiB', async () => { + const repo = makeRepo(join(host.dir, 'repo')); + fakeWorktrees(repo, 1000, 200); + const daemon = await host.start(); + const flooder = await host.client(); + const first = await flooder.watch(repo); + expect(first.worktrees).toHaveLength(1001); + const replySize = JSON.stringify(first).length; + flooder.pauseReading(); + const baseline = residentBytes(daemon.pid); + const requests = Math.ceil((160 * MiB) / replySize); + for (let req = 100; req < 100 + requests; req++) flooder.sendJson({ t: 'watchRepo', req, repo }); + let peak = baseline; + for (let i = 0; i < 40; i++) { + await sleep(250); + peak = Math.max(peak, residentBytes(daemon.pid)); + } + flooder.resumeReading(); + await flooder.waitClosed(30_000); + expect(flooder.messages.filter((m) => m.t === 'repoState').length).toBeLessThan(requests); + expect(peak - baseline).toBeLessThan(160 * MiB); + const other = await host.client(); + expect((await other.watch(repo)).worktrees).toHaveLength(1001); + }); +}); + +describe('shutdown', () => { + const stopBy: readonly [string, (daemon: WtdProcess, client: DaemonClient) => void][] = [ + [ + 'shutdown', + (_daemon, client) => { + client.send({ t: 'shutdown' }); + }, + ], + [ + 'SIGTERM', + (daemon) => { + daemon.kill('SIGTERM'); + }, + ], + [ + 'SIGINT', + (daemon) => { + daemon.kill('SIGINT'); + }, + ], + ]; + + it.each(stopBy)('%s ends every terminal, removes the socket and exits 0', async (_name, stop) => { + const repo = makeRepo(join(host.dir, 'repo')); + const daemon = await host.start(); + const client = await host.client(); + await client.watch(repo); + for (const name of ['a', 'b']) { + await client.create(repo, { command: `echo $$ > ${name}.pid; exec sleep 600` }); + } + const pids = await waitUntil(() => { + const files = ['a', 'b'].map((name) => join(repo, `${name}.pid`)); + return ( + files.every((f) => existsSync(f) && readFileSync(f, 'utf8').endsWith('\n')) && files.map((f) => Number(readFileSync(f, 'utf8'))) + ); + }, 'terminal pids'); + stop(daemon, client); + expect(await daemon.exited).toEqual({ code: 0, signal: null }); + expect(existsSync(host.socket)).toBe(false); + await waitUntil(() => pids.every((pid) => !alive(pid)), 'terminal processes to end', 8000); + }); + + it('completes a state write requested just before shutdown', async () => { + const repo = makeRepo(join(host.dir, 'repo')); + const wt = addWorktree(repo, join(host.dir, 'wt'), 'wt'); + const daemon = await host.start(); + const client = await host.client(); + await client.watch(repo); + client.sendJson({ t: 'setChecked', req: 50, worktree: wt, checked: true }); + client.send({ t: 'shutdown' }); + expect((await daemon.exited).code).toBe(0); + await host.start(); + expect((await (await host.client()).watch(repo)).checked).toEqual([wt]); + }); + + it('ignores SIGHUP', async () => { + const daemon = await host.start(); + daemon.kill('SIGHUP'); + await sleep(300); + expect(alive(daemon.pid)).toBe(true); + expect(await canConnect(host.socket)).toBe(true); + }); +}); diff --git a/test/process/daemon-server.test.ts b/test/process/daemon-server-startup.test.ts similarity index 60% rename from test/process/daemon-server.test.ts rename to test/process/daemon-server-startup.test.ts index 4f80fa7..a462cdd 100644 --- a/test/process/daemon-server.test.ts +++ b/test/process/daemon-server-startup.test.ts @@ -1,39 +1,18 @@ import { spawn, spawnSync } from 'node:child_process'; import { existsSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import { encodeStreamData, DAEMON_PROTOCOL_VERSION } from '../../src/protocol/index.js'; import type { DaemonClient } from '../support/daemon-client.js'; import { REPO_ROOT } from '../support/exec.js'; -import { - addWorktree, - alive, - canConnect, - DaemonHost, - fakeWorktrees, - makeRepo, - residentBytes, - sleep, - waitUntil, - type WtdProcess, -} from '../support/daemon-host.js'; +import { alive, canConnect, makeRepo, sleep, waitUntil } from '../support/daemon-host.js'; +import { host, useDaemonServerHost } from '../support/daemon-server.js'; vi.setConfig({ testTimeout: 60_000, hookTimeout: 30_000 }); +useDaemonServerHost(); const ANY_TEXT: unknown = expect.any(String); -const MiB = 1024 * 1024; - -let host: DaemonHost; - -beforeEach(() => { - host = DaemonHost.create(); -}); - -afterEach(async () => { - await host.cleanup(); -}); - const packageVersion = (): string => { const pkg: unknown = JSON.parse(readFileSync(join(REPO_ROOT, 'package.json'), 'utf8')); if (typeof pkg !== 'object' || pkg === null || !('version' in pkg) || typeof pkg.version !== 'string') throw new Error('no version'); @@ -240,143 +219,3 @@ describe('malformed traffic', () => { expect(state.terminals.find((t) => t.termId === term.termId)?.exit).toBeNull(); }); }); - -describe('multiple clients', () => { - it('serves concurrent clients independently', async () => { - const repo = makeRepo(join(host.dir, 'repo')); - await host.start(); - const clients = await Promise.all([host.client(), host.client(), host.client()]); - const states = await Promise.all(clients.map((c) => c.watch(repo))); - for (const state of states) expect(state.worktrees.map((w) => w.path)).toEqual([repo]); - const [a, b] = clients; - const from = b.mark(); - expect((await a.request({ t: 'detach', termId: 1 })).t).toBe('error'); - await sleep(200); - expect(b.messages.slice(from)).toEqual([]); - }); -}); - -describe('bounded connection queue', () => { - it('keeps memory bounded and the PTY running for others when a client stops reading', async () => { - const repo = makeRepo(join(host.dir, 'repo')); - const daemon = await host.start(); - const reader = await host.client(); - await reader.watch(repo); - const term = await reader.create(repo, { command: 'yes 0123456789abcdefghijklmnopqrstuvwxyz' }); - const stalled = await host.client(); - await stalled.watch(repo); - stalled.autoAck = false; - await stalled.attach(term.termId); - stalled.pauseReading(); - const view = await reader.attach(term.termId); - const baseline = residentBytes(daemon.pid); - let peak = baseline; - const start = view.position; - for (let i = 0; i < 20; i++) { - await sleep(250); - peak = Math.max(peak, residentBytes(daemon.pid)); - } - // Waits for the volume rather than timing it: a loaded machine slows `yes`, not only a blocked PTY. - await waitUntil( - () => { - peak = Math.max(peak, residentBytes(daemon.pid)); - return view.position - start > 8 * MiB; - }, - 'the reader to receive 8 MiB', - 30_000, - ); - expect(peak - baseline).toBeLessThan(128 * MiB); - expect(view.gaps).toEqual([]); - stalled.resumeReading(); - expect(await stalled.waitFor('detached')).toEqual({ t: 'detached', termId: term.termId, reason: 'lagging' }); - }); - - it('closes a connection whose unread replies exceed 64 MiB', async () => { - const repo = makeRepo(join(host.dir, 'repo')); - fakeWorktrees(repo, 1000, 200); - const daemon = await host.start(); - const flooder = await host.client(); - const first = await flooder.watch(repo); - expect(first.worktrees).toHaveLength(1001); - const replySize = JSON.stringify(first).length; - flooder.pauseReading(); - const baseline = residentBytes(daemon.pid); - const requests = Math.ceil((160 * MiB) / replySize); - for (let req = 100; req < 100 + requests; req++) flooder.sendJson({ t: 'watchRepo', req, repo }); - let peak = baseline; - for (let i = 0; i < 40; i++) { - await sleep(250); - peak = Math.max(peak, residentBytes(daemon.pid)); - } - flooder.resumeReading(); - await flooder.waitClosed(30_000); - expect(flooder.messages.filter((m) => m.t === 'repoState').length).toBeLessThan(requests); - expect(peak - baseline).toBeLessThan(160 * MiB); - const other = await host.client(); - expect((await other.watch(repo)).worktrees).toHaveLength(1001); - }); -}); - -describe('shutdown', () => { - const stopBy: readonly [string, (daemon: WtdProcess, client: DaemonClient) => void][] = [ - [ - 'shutdown', - (_daemon, client) => { - client.send({ t: 'shutdown' }); - }, - ], - [ - 'SIGTERM', - (daemon) => { - daemon.kill('SIGTERM'); - }, - ], - [ - 'SIGINT', - (daemon) => { - daemon.kill('SIGINT'); - }, - ], - ]; - - it.each(stopBy)('%s ends every terminal, removes the socket and exits 0', async (_name, stop) => { - const repo = makeRepo(join(host.dir, 'repo')); - const daemon = await host.start(); - const client = await host.client(); - await client.watch(repo); - for (const name of ['a', 'b']) { - await client.create(repo, { command: `echo $$ > ${name}.pid; exec sleep 600` }); - } - const pids = await waitUntil(() => { - const files = ['a', 'b'].map((name) => join(repo, `${name}.pid`)); - return ( - files.every((f) => existsSync(f) && readFileSync(f, 'utf8').endsWith('\n')) && files.map((f) => Number(readFileSync(f, 'utf8'))) - ); - }, 'terminal pids'); - stop(daemon, client); - expect(await daemon.exited).toEqual({ code: 0, signal: null }); - expect(existsSync(host.socket)).toBe(false); - await waitUntil(() => pids.every((pid) => !alive(pid)), 'terminal processes to end', 8000); - }); - - it('completes a state write requested just before shutdown', async () => { - const repo = makeRepo(join(host.dir, 'repo')); - const wt = addWorktree(repo, join(host.dir, 'wt'), 'wt'); - const daemon = await host.start(); - const client = await host.client(); - await client.watch(repo); - client.sendJson({ t: 'setChecked', req: 50, worktree: wt, checked: true }); - client.send({ t: 'shutdown' }); - expect((await daemon.exited).code).toBe(0); - await host.start(); - expect((await (await host.client()).watch(repo)).checked).toEqual([wt]); - }); - - it('ignores SIGHUP', async () => { - const daemon = await host.start(); - daemon.kill('SIGHUP'); - await sleep(300); - expect(alive(daemon.pid)).toBe(true); - expect(await canConnect(host.socket)).toBe(true); - }); -}); diff --git a/test/process/daemon-terminals-attention.test.ts b/test/process/daemon-terminals-attention.test.ts new file mode 100644 index 0000000..fd48e51 --- /dev/null +++ b/test/process/daemon-terminals-attention.test.ts @@ -0,0 +1,135 @@ +import { describe, expect, it, vi } from 'vitest'; +import type { Terminal } from '../../src/protocol/index.js'; +import type { DaemonClient, EventOf } from '../support/daemon-client.js'; +import { sleep, waitUntil } from '../support/daemon-host.js'; +import { a, b, host, repo, started, useDaemonTerminalsHost, wt } from '../support/daemon-terminals.js'; + +vi.setConfig({ testTimeout: 60_000, hookTimeout: 30_000 }); +useDaemonTerminalsHost(); + +describe('activity', () => { + /** Activity messages about `termId` that `client` received from index `from`. */ + const activityOf = (client: DaemonClient, termId: number, from: number): EventOf<'activity'>[] => + client.messages.slice(from).filter((m): m is EventOf<'activity'> => m.t === 'activity' && m.termId === termId); + + const isInput = + (termId: number): ((m: EventOf<'activity'>) => boolean) => + (m) => + m.termId === termId && m.state === 'input'; + + /** The terminal entry a fresh watcher of the repo receives. */ + const entryOf = async (termId: number): Promise => + (await (await host.client()).watch(repo)).terminals.find((t) => t.termId === termId); + + it('sets input on a bell, also in the terminal entry', async () => { + const term = await a.create(wt, { command: String.raw`sleep 0.3; printf '\a'; exec sleep 60` }); + expect(await a.waitFor('activity', isInput(term.termId))).toEqual({ t: 'activity', termId: term.termId, unseen: true, state: 'input' }); + expect(await entryOf(term.termId)).toMatchObject({ unseen: true, state: 'input' }); + }); + + it.each([ + ['OSC 9', String.raw`printf '\033]9;done\a'`], + ['OSC 777', String.raw`printf '\033]777;notify;t;b\a'`], + ['OSC 99', String.raw`printf '\033]99;;hi\033\\'`], + ])('sets input on an %s notification', async (_name, print) => { + const term = await a.create(wt, { command: `sleep 0.3; ${print}; exec sleep 60` }); + expect(await b.waitFor('activity', isInput(term.termId))).toMatchObject({ state: 'input' }); + }); + + it('does not set input on a ConEmu progress report', async () => { + const from = b.mark(); + const term = await started(a, String.raw`printf '\033]9;4;1;50\a'; sleep 0.3; printf progress-done; exec sleep 60`); + await a.waitOutput(term.termId, 'progress-done'); + await sleep(700); + expect(activityOf(b, term.termId, from).filter((m) => m.state === 'input')).toEqual([]); + }); + + it('sets input once for an OSC 777 notification split across output chunks', async () => { + const from = b.mark(); + const term = await started( + a, + String.raw`printf '\033]777;noti'; sleep 0.3; printf 'fy;t;b\a'; sleep 0.3; printf split-done; exec sleep 60`, + ); + await a.waitOutput(term.termId, 'split-done'); + await sleep(500); + expect(a.view(term.termId).chunks.some((c) => Buffer.from(c).toString().endsWith('\x1b]777;noti'))).toBe(true); + expect(activityOf(b, term.termId, from).filter((m) => m.state === 'input')).toHaveLength(1); + }); + + it('ignores a bell answering a tab within 1 s and honours one after', async () => { + const from = b.mark(); + const term = await started( + a, + String.raw`printf 'ready\n'; IFS= read -r -n1 _; printf '\a'; printf rang; sleep 1.5; printf '\a'; exec sleep 60`, + ); + await a.waitOutput(term.termId, 'ready'); + await sleep(1200); + a.sendInput(term.termId, '\t'); + await a.waitOutput(term.termId, 'rang'); + await sleep(500); + expect(activityOf(b, term.termId, from).filter((m) => m.state === 'input')).toEqual([]); + await b.waitFor('activity', isInput(term.termId), { from }); + }); + + it('clears input when a key is typed', async () => { + const term = await a.create(wt, { command: String.raw`stty -echo; sleep 0.3; printf '\a'; exec sleep 60` }); + await b.waitFor('activity', isInput(term.termId)); + const from = b.mark(); + a.sendInput(term.termId, 'y'); + expect(await b.waitFor('activity', (m) => m.termId === term.termId, { from })).toEqual({ + t: 'activity', + termId: term.termId, + unseen: true, + state: 'working', + }); + }); + + it('keeps input on focus reports and passes them to the program', async () => { + const term = await a.create(wt, { command: String.raw`stty -echo -icanon; sleep 0.3; printf '\a'; exec cat -v` }); + await a.attach(term.termId); + await b.waitFor('activity', isInput(term.termId)); + const from = b.mark(); + a.sendInput(term.termId, '\x1b[O\x1b[I'); + await a.waitOutput(term.termId, '^[[O^[[I'); + await sleep(500); + expect(activityOf(b, term.termId, from)).toEqual([]); + expect(await entryOf(term.termId)).toMatchObject({ state: 'input' }); + }); + + it('keeps input when the program answers a focus report 5 s after the signal at once', async () => { + const term = await a.create(wt, { + command: String.raw`stty -echo -icanon; sleep 0.3; printf '\a'; head -c 3 >/dev/null; printf '\033(B\017'; exec sleep 60`, + }); + await a.attach(term.termId); + await b.waitFor('activity', isInput(term.termId)); + await sleep(5000); + const from = b.mark(); + a.sendInput(term.termId, '\x1b[I'); + await waitUntil(() => a.view(term.termId).bytes().includes('\x1b(B\x0f'), 'the reply to the focus report'); + await sleep(500); + expect(activityOf(b, term.termId, from)).toEqual([]); + expect(await entryOf(term.termId)).toMatchObject({ state: 'input' }); + }); + + it('clears input on output 1.5 s after the signal', async () => { + const term = await a.create(wt, { command: String.raw`sleep 0.3; printf '\a'; sleep 1.5; printf later; exec sleep 60` }); + await b.waitFor('activity', isInput(term.termId)); + const from = b.mark(); + expect(await b.waitFor('activity', (m) => m.termId === term.termId, { from })).toEqual({ + t: 'activity', + termId: term.termId, + unseen: true, + state: 'working', + }); + }); + + it('keeps input on output 0.5 s after the signal', async () => { + const term = await started(a, String.raw`sleep 1.3; printf '\a'; sleep 0.5; printf soon; exec sleep 60`); + await b.waitFor('activity', isInput(term.termId)); + const from = b.mark(); + await a.waitOutput(term.termId, 'soon'); + await sleep(1000); + expect(activityOf(b, term.termId, from)).toEqual([]); + expect(await entryOf(term.termId)).toMatchObject({ state: 'input' }); + }); +}); diff --git a/test/process/daemon-terminals-lifecycle.test.ts b/test/process/daemon-terminals-lifecycle.test.ts new file mode 100644 index 0000000..0c65f29 --- /dev/null +++ b/test/process/daemon-terminals-lifecycle.test.ts @@ -0,0 +1,369 @@ +import { chmodSync, existsSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { describe, expect, it, vi } from 'vitest'; +import type { Layout } from '../../src/protocol/index.js'; +import type { DaemonClient, Event, RequestBody } from '../support/daemon-client.js'; +import { addWorktree, alive, git, sleep, waitUntil } from '../support/daemon-host.js'; +import { a, b, daemon, host, MiB, repo, restart, started, useDaemonTerminalsHost, wt } from '../support/daemon-terminals.js'; + +vi.setConfig({ testTimeout: 60_000, hookTimeout: 30_000 }); +useDaemonTerminalsHost(); + +const ANY_REQ: unknown = expect.any(Number); + +/** Messages from index `from`, without activity flags, which login start-up output may raise at any time. */ +const repliesFrom = (client: DaemonClient, from: number): Event[] => client.messages.slice(from).filter((m) => m.t !== 'activity'); + +const pidIn = (dir: string, name: string): Promise => + waitUntil(() => { + const file = join(dir, name); + if (!existsSync(file)) return undefined; + const text = readFileSync(file, 'utf8'); + return text.endsWith('\n') ? Number(text) : undefined; + }, `pid file ${name}`); + +const countByte = (data: Uint8Array, byte: number): number => { + let n = 0; + for (const b of data) if (b === byte) n++; + return n; +}; + +const stateFile = (): unknown => JSON.parse(readFileSync(host.statePath, 'utf8')); + +const stateMentions = (path: string): boolean => existsSync(host.statePath) && JSON.stringify(stateFile()).includes(JSON.stringify(path)); + +describe('terminal creation', () => { + it('starts a login interactive shell in the worktree', async () => { + const term = await started(a, null); + a.sendInput(term.termId, 'pwd\r'); + await a.waitOutput(term.termId, `${wt}\r\n`); + expect(a.view(term.termId).text()).toContain(`${wt}\r\n`); + }); + + it('runs a command in a login interactive shell with TERM and COLORTERM set', async () => { + const command = `case $- in *i*) echo mode-interactive;; esac; shopt -q login_shell && echo mode-login; echo "env=$TERM/$COLORTERM"; exec sleep 60`; + const term = await started(a, command); + await a.waitOutput(term.termId, 'env=xterm-256color/truecolor'); + const text = a.view(term.termId).text(); + expect(text).toContain('mode-interactive'); + expect(text).toContain('mode-login'); + }); + + it.each([ + ['unset', undefined], + ['relative', 'bash'], + ])('uses /bin/sh when SHELL is %s', async (_name, shell) => { + await restart({ SHELL: shell }); + const term = await started(a, 'echo "shell=$0"; exec sleep 60'); + await a.waitOutput(term.termId, 'shell=/bin/sh'); + }); + + it('answers the requester with its req and every other watcher with req null', async () => { + const outsider = await host.client(); + const from = b.mark(); + const reply = await a.request({ t: 'createTerm', worktree: wt, preset: 'my-preset', command: 'exec sleep 60', cols: 100, rows: 30 }); + const term = { termId: 1, worktree: wt, preset: 'my-preset', cols: 100, rows: 30, exit: null, unseen: false, state: 'idle' }; + expect(reply).toEqual({ t: 'termCreated', req: ANY_REQ, term }); + expect(await b.waitFor('termCreated', () => true, { from })).toEqual({ t: 'termCreated', req: null, term }); + await outsider.expectNone('termCreated', () => true, 300); + }); + + it('counts terminal ids up from 1 without reusing them', async () => { + const ids = []; + for (let i = 0; i < 3; i++) ids.push((await a.create(wt, { command: 'exec sleep 60' })).termId); + expect(ids).toEqual([1, 2, 3]); + await a.ok({ t: 'closeTerm', termId: 3 }); + expect((await a.create(wt, { command: 'exec sleep 60' })).termId).toBe(4); + }); + + it('does not attach the creator', async () => { + const term = await a.create(wt, { command: 'echo visible-output; exec sleep 60' }); + await sleep(500); + expect(a.received.filter((r) => r.kind !== 'message' && r.termId === term.termId)).toEqual([]); + }); + + it('fails with unknown-worktree for a worktree of a repo this connection does not watch', async () => { + const outsider = await host.client(); + expect(await outsider.fails({ t: 'createTerm', worktree: wt, preset: 'shell', command: null, cols: 80, rows: 24 })).toBe( + 'unknown-worktree', + ); + }); + + it('fails with unknown-worktree for a path that is not in the list', async () => { + expect(await a.fails({ t: 'createTerm', worktree: join(repo, 'sub'), preset: 'shell', command: null, cols: 80, rows: 24 })).toBe( + 'unknown-worktree', + ); + }); + + it('fails with spawn-failed when the worktree directory is gone', async () => { + const gone = addWorktree(repo, join(host.dir, 'gone'), 'gone'); + await a.waitFor('worktreesChanged', (m) => m.worktrees.some((w) => w.path === gone)); + rmSync(gone, { recursive: true }); + await a.waitFor('worktreesChanged', (m) => m.worktrees.some((w) => w.path === gone && w.prunable)); + expect(await a.fails({ t: 'createTerm', worktree: gone, preset: 'shell', command: null, cols: 80, rows: 24 })).toBe('spawn-failed'); + }); + + it('fails with spawn-failed when the shell does not exist', async () => { + await restart({ SHELL: join(host.dir, 'no-such-shell') }); + expect(await a.fails({ t: 'createTerm', worktree: wt, preset: 'shell', command: null, cols: 80, rows: 24 })).toBe('spawn-failed'); + }); +}); + +describe('terminal access', () => { + it.each([ + ['attach', { t: 'attach', termId: 99 }], + ['detach', { t: 'detach', termId: 99 }], + ['closeTerm', { t: 'closeTerm', termId: 99 }], + ] as const)('fails %s of an unknown terminal with unknown-term', async (_name, body) => { + expect(await a.fails(body)).toBe('unknown-term'); + }); + + it('answers resize and input for an unknown terminal with an uncorrelated unknown-term', async () => { + let from = a.mark(); + a.send({ t: 'resize', termId: 99, cols: 80, rows: 24 }); + expect(await a.waitFor('error', () => true, { from })).toMatchObject({ req: null, code: 'unknown-term' }); + from = a.mark(); + a.sendInput(99, 'x'); + expect(await a.waitFor('error', () => true, { from })).toMatchObject({ req: null, code: 'unknown-term' }); + }); + + it('fails requests for a terminal of a repo the connection does not watch with not-watched', async () => { + const term = await a.create(wt, { command: 'exec sleep 60' }); + const outsider = await host.client(); + expect(await outsider.fails({ t: 'attach', termId: term.termId })).toBe('not-watched'); + expect(await outsider.fails({ t: 'closeTerm', termId: term.termId })).toBe('not-watched'); + let from = outsider.mark(); + outsider.send({ t: 'resize', termId: term.termId, cols: 80, rows: 24 }); + expect(await outsider.waitFor('error', () => true, { from })).toMatchObject({ req: null, code: 'not-watched' }); + from = outsider.mark(); + outsider.sendInput(term.termId, 'x'); + expect(await outsider.waitFor('error', () => true, { from })).toMatchObject({ req: null, code: 'not-watched' }); + }); +}); + +describe('terminal lifetime', () => { + it('keeps running when its only client disconnects, keeping the output meanwhile', async () => { + const term = await started(a, 'sleep 0.8; echo MARKER-AFTER-LEAVE; exec sleep 60'); + a.close(); + await sleep(1500); + const c = await host.client(); + const state = await c.watch(repo); + expect(state.terminals.find((t) => t.termId === term.termId)?.exit).toBeNull(); + const view = await c.attach(term.termId); + expect(await view.screen.text()).toContain('MARKER-AFTER-LEAVE'); + }); + + it('keeps running when its client unwatches the repo', async () => { + const term = await started(a, 'exec sleep 60'); + await a.ok({ t: 'unwatchRepo', repo }); + await b.expectNone('termExited', () => true, 300); + expect((await b.watch(repo)).terminals.map((t) => t.termId)).toEqual([term.termId]); + }); + + it('delivers a final burst before termExited', async () => { + const term = await started(a, "read go; head -c 1048576 /dev/zero | tr '\\0' x; exit 0"); + a.sendInput(term.termId, 'go\r'); + await a.waitFor('termExited', (m) => m.termId === term.termId, { timeout: 30_000 }); + const exitedAt = a.received.findIndex((r) => r.kind === 'message' && r.message.t === 'termExited'); + const before = a.received + .slice(0, exitedAt) + .reduce((n, r) => n + (r.kind === 'output' && r.termId === term.termId ? countByte(r.data, 0x78) : 0), 0); + expect(before).toBe(MiB); + }); + + it('reports the exit code to every watcher and keeps the terminal until closed', async () => { + const term = await a.create(wt, { command: 'exit 3' }); + const expected = { t: 'termExited', termId: term.termId, code: 3, signal: null }; + expect(await a.waitFor('termExited')).toEqual(expected); + expect(await b.waitFor('termExited')).toEqual(expected); + const state = await (await host.client()).watch(repo); + expect(state.terminals.find((t) => t.termId === term.termId)?.exit).toEqual({ code: 3, signal: null }); + await a.ok({ t: 'closeTerm', termId: term.termId }); + }); + + it('reports the name of a fatal signal', async () => { + const term = await a.create(wt, { command: 'kill -KILL $$' }); + expect(await a.waitFor('termExited', (m) => m.termId === term.termId)).toMatchObject({ signal: 'SIGKILL' }); + }); + + it('ends the shell and its background job on close, then reports termClosed before done', async () => { + const term = await a.create(wt, { command: 'sleep 600 & echo $! > bg.pid; echo $$ > sh.pid; wait' }); + const shell = await pidIn(wt, 'sh.pid'); + const job = await pidIn(wt, 'bg.pid'); + const from = a.mark(); + await a.ok({ t: 'closeTerm', termId: term.termId }); + expect(repliesFrom(a, from).map((m) => m.t)).toEqual(['termClosed', 'done']); + expect(await b.waitFor('termClosed')).toEqual({ t: 'termClosed', termId: term.termId }); + await waitUntil(() => !alive(shell) && !alive(job), 'shell and job to end', 7000); + expect((await b.watch(repo)).terminals).toEqual([]); + expect(await a.fails({ t: 'closeTerm', termId: term.termId })).toBe('unknown-term'); + }); + + it('kills a shell that ignores SIGHUP 5 s after close', async () => { + const term = await a.create(wt, { command: "trap '' HUP; echo $$ > sh.pid; while :; do sleep 0.2; done" }); + const shell = await pidIn(wt, 'sh.pid'); + await a.ok({ t: 'closeTerm', termId: term.termId }); + await sleep(3000); + expect(alive(shell)).toBe(true); + await waitUntil(() => !alive(shell), 'the shell to be killed', 5000); + }); +}); + +describe('checked state and layouts', () => { + const twoPanes = (one: number, two: number): Layout => ({ + tabs: [{ id: 'main', root: { split: 'right', ratio: 0.5, a: { term: one }, b: { term: two } } }], + active: 0, + }); + + it('broadcasts a checkbox change to every watcher before answering done', async () => { + const from = a.mark(); + const fromB = b.mark(); + await a.ok({ t: 'setChecked', worktree: wt, checked: true }); + expect(a.messages.slice(from)).toEqual([ + { t: 'checkedChanged', worktree: wt, checked: true }, + { t: 'done', req: ANY_REQ }, + ]); + expect(await b.waitFor('checkedChanged', () => true, { from: fromB })).toEqual({ t: 'checkedChanged', worktree: wt, checked: true }); + expect((await (await host.client()).watch(repo)).checked).toEqual([wt]); + expect(stateMentions(wt)).toBe(true); + expect(statSync(host.statePath).mode & 0o777).toBe(0o600); + }); + + it.each<[string, (worktree: string) => RequestBody]>([ + ['setChecked', (worktree) => ({ t: 'setChecked', worktree, checked: true })], + ['setLayout', (worktree) => ({ t: 'setLayout', worktree, layout: { tabs: [], active: 0 } })], + ])('fails %s for a worktree not in a watched list with unknown-worktree', async (_name, body) => { + const outsider = await host.client(); + expect(await outsider.fails(body(wt))).toBe('unknown-worktree'); + expect(await a.fails(body(join(repo, 'sub')))).toBe('unknown-worktree'); + }); + + it('broadcasts a layout of the worktree terminals and reports it in repoState', async () => { + const one = await a.create(wt, { command: 'exec sleep 60' }); + const two = await a.create(wt, { command: 'exec sleep 60' }); + const layout = twoPanes(one.termId, two.termId); + const from = a.mark(); + await a.ok({ t: 'setLayout', worktree: wt, layout }); + expect(repliesFrom(a, from)).toEqual([ + { t: 'layoutChanged', worktree: wt, layout }, + { t: 'done', req: ANY_REQ }, + ]); + await b.waitFor('layoutChanged', (m) => m.worktree === wt); + expect((await (await host.client()).watch(repo)).layouts).toEqual([{ worktree: wt, layout }]); + }); + + it('rejects a layout holding a terminal of another worktree, keeping the stored one', async () => { + const own = await a.create(wt, { command: 'exec sleep 60' }); + const other = await a.create(repo, { command: 'exec sleep 60' }); + const kept: Layout = { tabs: [{ id: 'only', root: { term: own.termId } }], active: 0 }; + await a.ok({ t: 'setLayout', worktree: wt, layout: kept }); + expect(await a.fails({ t: 'setLayout', worktree: wt, layout: twoPanes(own.termId, other.termId) })).toBe('unknown-term'); + expect(await a.fails({ t: 'setLayout', worktree: wt, layout: twoPanes(own.termId, 999) })).toBe('unknown-term'); + expect((await (await host.client()).watch(repo)).layouts).toEqual([{ worktree: wt, layout: kept }]); + }); + + it('replaces a split by its other half when a terminal in it is closed', async () => { + const one = await a.create(wt, { command: 'exec sleep 60' }); + const two = await a.create(wt, { command: 'exec sleep 60' }); + const fromSet = b.mark(); + await a.ok({ t: 'setLayout', worktree: wt, layout: twoPanes(one.termId, two.termId) }); + await b.waitFor('layoutChanged', () => true, { from: fromSet }); + const from = b.mark(); + await a.ok({ t: 'closeTerm', termId: one.termId }); + expect(await b.waitFor('layoutChanged', () => true, { from })).toEqual({ + t: 'layoutChanged', + worktree: wt, + layout: { tabs: [{ id: 'main', root: { term: two.termId } }], active: 0 }, + }); + }); + + it('removes a tab left empty when its last terminal is closed', async () => { + const one = await a.create(wt, { command: 'exec sleep 60' }); + const fromSet = b.mark(); + await a.ok({ t: 'setLayout', worktree: wt, layout: { tabs: [{ id: 'solo', root: { term: one.termId } }], active: 0 } }); + await b.waitFor('layoutChanged', () => true, { from: fromSet }); + const from = b.mark(); + await a.ok({ t: 'closeTerm', termId: one.termId }); + expect(await b.waitFor('layoutChanged', () => true, { from })).toEqual({ + t: 'layoutChanged', + worktree: wt, + layout: { tabs: [], active: 0 }, + }); + }); + + it.skipIf(process.getuid?.() === 0)( + 'answers internal for a change that cannot be persisted, keeping state and broadcasting nothing', + async () => { + const one = await a.create(wt, { command: 'exec sleep 60' }); + await a.ok({ t: 'setChecked', worktree: repo, checked: true }); + const before = await (await host.client()).watch(repo); + chmodSync(host.stateDir, 0o500); + try { + const fromB = b.mark(); + expect(await a.fails({ t: 'setChecked', worktree: wt, checked: true })).toBe('internal'); + const layout: Layout = { tabs: [{ id: 'solo', root: { term: one.termId } }], active: 0 }; + expect(await a.fails({ t: 'setLayout', worktree: wt, layout })).toBe('internal'); + await sleep(300); + expect(b.messages.slice(fromB).filter((m) => m.t === 'checkedChanged' || m.t === 'layoutChanged')).toEqual([]); + const after = await (await host.client()).watch(repo); + expect({ checked: after.checked, layouts: after.layouts }).toEqual({ checked: before.checked, layouts: before.layouts }); + } finally { + chmodSync(host.stateDir, 0o700); + } + }, + ); +}); + +describe('persistence and pruning', () => { + it('keeps checked worktrees across a restart and drops layout terminals', async () => { + const term = await a.create(wt, { command: 'exec sleep 60' }); + await a.ok({ t: 'setChecked', worktree: wt, checked: true }); + await a.ok({ t: 'setLayout', worktree: wt, layout: { tabs: [{ id: 'x', root: { term: term.termId } }], active: 0 } }); + await restart(); + const state = await (await host.client()).watch(repo); + expect(state.checked).toEqual([wt]); + expect(state.layouts.find((l) => l.worktree === wt)?.layout.tabs ?? []).toEqual([]); + expect(state.terminals).toEqual([]); + }); + + it('prunes a removed worktree without terminals', async () => { + const gone = addWorktree(repo, join(host.dir, 'gone'), 'gone'); + await a.waitFor('worktreesChanged', (m) => m.worktrees.some((w) => w.path === gone)); + await a.ok({ t: 'setChecked', worktree: gone, checked: true }); + expect(stateMentions(gone)).toBe(true); + git(repo, 'worktree', 'remove', gone); + await a.waitFor('worktreesChanged', (m) => !m.worktrees.some((w) => w.path === gone)); + await waitUntil(() => !stateMentions(gone), 'the entry to be pruned', 2000); + expect((await (await host.client()).watch(repo)).checked).toEqual([]); + }); + + it('keeps a removed worktree with a live terminal until the terminal is closed', async () => { + const gone = addWorktree(repo, join(host.dir, 'gone'), 'gone'); + await a.waitFor('worktreesChanged', (m) => m.worktrees.some((w) => w.path === gone)); + const term = await a.create(gone, { command: 'exec sleep 600' }); + await a.ok({ t: 'setChecked', worktree: gone, checked: true }); + git(repo, 'worktree', 'remove', '--force', gone); + await a.waitFor('worktreesChanged', (m) => !m.worktrees.some((w) => w.path === gone)); + await sleep(500); + expect(stateMentions(gone)).toBe(true); + const state = await (await host.client()).watch(repo); + expect(state.terminals.map((t) => t.worktree)).toEqual([gone]); + await a.ok({ t: 'closeTerm', termId: term.termId }); + await waitUntil(() => !stateMentions(gone), 'the entry to be pruned', 2000); + }); + + it('moves a corrupt state file aside and starts with no checked worktrees', async () => { + await a.ok({ t: 'setChecked', worktree: wt, checked: true }); + a.send({ t: 'shutdown' }); + await daemon.exited; + writeFileSync(host.statePath, '{"version":1,"repos":['); + await host.start(); + // The socket answers before the state is loaded; connections wait for it. + const names = await waitUntil(() => { + const moved = readdirSync(host.stateDir).filter((n) => n.startsWith('state.json.corrupt-')); + return moved.length > 0 && moved; + }, 'the corrupt state file moved aside'); + expect(names).toHaveLength(1); + expect((await (await host.client()).watch(repo)).checked).toEqual([]); + }); +}); diff --git a/test/process/daemon-terminals-output.test.ts b/test/process/daemon-terminals-output.test.ts new file mode 100644 index 0000000..2837d24 --- /dev/null +++ b/test/process/daemon-terminals-output.test.ts @@ -0,0 +1,231 @@ +import { readFileSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { describe, expect, it, vi } from 'vitest'; +import { FLOW_HIGH, MAX_FRAME } from '../../src/protocol/index.js'; +import { replay } from '../support/daemon-client.js'; +import { sleep, waitUntil } from '../support/daemon-host.js'; +import { a, b, host, KiB, MiB, repo, started, useDaemonTerminalsHost, wt } from '../support/daemon-terminals.js'; + +vi.setConfig({ testTimeout: 60_000, hookTimeout: 30_000 }); +useDaemonTerminalsHost(); + +describe('attach and snapshot', () => { + const GENERATOR = String.raw` +read go +i=0 +while [ $i -lt 3000 ]; do printf 'line %05d 中文字符 😀 \033[3%dmcolour\033[0m\n' $i $((i % 7)); i=$((i+1)); done +echo PHASE-ONE +read go +while [ $i -lt 6000 ]; do printf 'line %05d wide \033[1;4;3%dmstyled\033[0m\n' $i $((i % 7)); i=$((i+1)); done +printf '\033[?1h\033[?2004h\033[?25l' +printf '\033[?1049h\033[2J\033[Halternate 中文\033[5;10Hplaced \033[1;31mred\033[0m' +printf '\033[3;4HDONE-MARK' +exec sleep 600 +`; + + it('restores the same screen, cursor, modes and scrollback for a client attaching mid-output', async () => { + writeFileSync(join(wt, 'gen.sh'), GENERATOR); + const term = await started(a, 'bash ./gen.sh'); + a.sendInput(term.termId, 'go\r'); + await a.waitOutput(term.termId, 'PHASE-ONE', 20_000); + await b.attach(term.termId); + a.sendInput(term.termId, 'go\r'); + await a.waitOutput(term.termId, 'DONE-MARK', 20_000); + await b.waitOutput(term.termId, 'DONE-MARK', 20_000); + const seenByA = await a.view(term.termId).screen.state(); + const seenByB = await b.view(term.termId).screen.state(); + expect(seenByA.buffer).toBe('alternate'); + expect(seenByB).toEqual(seenByA); + }); + + it('continues output exactly at the snapshot offset however attaches interleave with output', async () => { + const term = await started(a, 'read go; seq 1 400000; echo SEQ-DONE; exec sleep 600'); + a.sendInput(term.termId, 'go\r'); + for (let i = 0; i < 8; i++) { + await b.attach(term.termId, 20_000); + await sleep(40); + } + await a.waitOutput(term.termId, 'SEQ-DONE', 30_000); + // B's last attach may come after the output ended, leaving SEQ-DONE in its snapshot. + await waitUntil(async () => (await b.view(term.termId).screen.text()).includes('SEQ-DONE'), 'SEQ-DONE on B', 30_000); + const reference = a.view(term.termId); + const [first] = reference.attachments; + if (first === undefined) throw new Error('A has no snapshot'); + const all = reference.bytes(); + const view = b.view(term.termId); + expect(view.gaps).toEqual([]); + expect(reference.gaps).toEqual([]); + for (const attachment of view.attachments) { + if (attachment.firstOutput !== null) expect(attachment.firstOutput).toBe(attachment.snapshotOffset); + const upTo = all.subarray(0, attachment.snapshotOffset - first.snapshotOffset); + expect(await replay(80, 24, attachment.snapshot)).toEqual(await replay(80, 24, first.snapshot, upTo)); + } + const last = view.attachments.at(-1); + if (last === undefined) throw new Error('B has no snapshot'); + expect(view.bytes().equals(all.subarray(last.snapshotOffset - first.snapshotOffset))).toBe(true); + // The snapshot may cut a line: its tail starts B's output. + const numbers = view + .text() + .split('\r\n') + .slice(1) + .filter((line) => /^\d+$/.test(line)) + .map(Number); + expect(numbers.every((n, i) => i === 0 || n === (numbers[i - 1] ?? 0) + 1)).toBe(true); + }); + + it('shrinks the scrollback of a snapshot that would exceed MAX_FRAME', async () => { + const awk = `awk 'BEGIN { for (l = 0; l < 5100; l++) { s = ""; for (c = 0; c < 500; c++) s = s "\\033[31ma\\033[32mb"; print s } }'; echo BIG-DONE`; + const term = await a.create(wt, { command: awk, cols: 1000, rows: 50 }); + await a.waitFor('termExited', (m) => m.termId === term.termId, { timeout: 90_000 }); + const view = await a.attach(term.termId, 60_000); + const snapshot = view.attachments[0]?.snapshot; + if (snapshot === undefined) throw new Error('no snapshot'); + expect(snapshot.length).toBeGreaterThan(MiB); + expect(snapshot.length + 12).toBeLessThanOrEqual(MAX_FRAME); + expect(await view.screen.text()).toContain('BIG-DONE'); + }, 180_000); + + it('re-attaches with a new snapshot when attaching while attached', async () => { + const term = await started(a, 'echo hello; exec sleep 60'); + await a.waitOutput(term.termId, 'hello'); + await a.attach(term.termId); + expect(a.view(term.termId).attachments).toHaveLength(2); + expect(await a.view(term.termId).screen.text()).toContain('hello'); + }); + + it('detaches whether or not attached, and stops output after detaching', async () => { + const term = await a.create(wt, { command: null }); + await a.ok({ t: 'detach', termId: term.termId }); + await a.attach(term.termId); + await a.ok({ t: 'detach', termId: term.termId }); + const count = a.received.length; + b.sendInput(term.termId, 'echo after-detach\r'); + await sleep(500); + expect(a.received.slice(count).filter((r) => r.kind !== 'message')).toEqual([]); + }); + + it('attaches to an exited terminal', async () => { + const term = await a.create(wt, { command: 'echo bye-bye; exit 0' }); + await a.waitFor('termExited', (m) => m.termId === term.termId); + const view = await a.attach(term.termId); + expect(await view.screen.text()).toContain('bye-bye'); + }); +}); + +describe('output flow control', () => { + it('evicts a client that never acks while an acking client receives every byte', async () => { + const term = await started(b, 'read go; seq 1 1500000; echo FLOOD-DONE'); + a.autoAck = false; + await a.attach(term.termId); + const goAt = Date.now(); + b.sendInput(term.termId, 'go\r'); + expect(await a.waitFor('detached', () => true, { timeout: 20_000 })).toEqual({ t: 'detached', termId: term.termId, reason: 'lagging' }); + expect(Date.now() - goAt).toBeGreaterThanOrEqual(1900); + expect(a.view(term.termId).bytes().length).toBeLessThan(FLOW_HIGH + 512 * KiB); + await b.waitFor('termExited', (m) => m.termId === term.termId, { timeout: 60_000 }); + const view = b.view(term.termId); + expect(view.gaps).toEqual([]); + const numbers = view + .text() + .split('\r\n') + .filter((line) => /^\d+$/.test(line)); + expect(numbers).toHaveLength(1_500_000); + expect(numbers.every((n, i) => Number(n) === i + 1)).toBe(true); + expect(view.text()).toContain('FLOOD-DONE'); + }); + + it('lets an unwatched flood complete and shows its final screen to a later attach', async () => { + const term = await a.create(wt, { command: 'seq 1 2000000; echo NOBODY-DONE' }); + await a.waitFor('termExited', (m) => m.termId === term.termId, { timeout: 60_000 }); + const text = await (await a.attach(term.termId)).screen.text(); + expect(text).toContain('2000000'); + expect(text).toContain('NOBODY-DONE'); + }); + + it('closes a connection that acks beyond the output sent to it, leaving the terminal running', async () => { + const term = await started(a, 'echo some-output; exec sleep 60'); + await a.waitOutput(term.termId, 'some-output'); + a.send({ t: 'ack', termId: term.termId, offset: a.view(term.termId).position + MiB }); + expect(await a.waitFor('error')).toMatchObject({ req: null, code: 'bad-message' }); + await a.waitClosed(); + expect((await b.watch(repo)).terminals.find((t) => t.termId === term.termId)?.exit).toBeNull(); + }); + + it('ignores an ack at or below the current ack position', async () => { + const term = await started(a, 'echo some-output; exec sleep 60'); + await a.waitOutput(term.termId, 'some-output'); + const position = a.view(term.termId).position; + a.send({ t: 'ack', termId: term.termId, offset: position }); + a.send({ t: 'ack', termId: term.termId, offset: position }); + a.send({ t: 'ack', termId: term.termId, offset: 0 }); + await a.expectNone('error', () => true, 400); + expect(a.isClosed).toBe(false); + }); +}); + +describe('input', () => { + it('echoes a command round trip', async () => { + const term = await started(a, null); + a.sendInput(term.termId, "echo h''i\r"); + await a.waitOutput(term.termId, 'hi\r\n'); + expect(a.view(term.termId).text()).toContain('hi\r\n'); + }); + + it('writes input frames to the PTY in order', async () => { + const term = await started(a, 'stty -echo; cat > in.txt'); + const lines = Array.from({ length: 300 }, (_, i) => `line ${String(i)}`); + for (const line of lines) a.sendInput(term.termId, `${line}\n`); + a.sendInput(term.termId, '\x04'); + await a.waitFor('termExited', (m) => m.termId === term.termId, { timeout: 10_000 }); + expect(readFileSync(join(wt, 'in.txt'), 'utf8')).toBe(lines.map((l) => `${l}\n`).join('')); + }); + + it('discards input to an exited terminal without an error', async () => { + const term = await a.create(wt, { command: 'exit 0' }); + await a.waitFor('termExited', (m) => m.termId === term.termId); + a.sendInput(term.termId, 'ignored\r'); + await a.expectNone('error', () => true, 400); + expect(a.isClosed).toBe(false); + }); + + it('answers input beyond 1 MiB unaccepted with busy while still processing acks', async () => { + const stuck = await started(a, 'stty raw -echo; echo READY; exec sleep 600'); + await a.waitOutput(stuck.termId, 'READY'); + const flood = await started(a, 'yes flood-line'); + const before = a.view(flood.termId).position; + const from = a.mark(); + const frame = new Uint8Array(64 * KiB).fill(0x61); + for (let i = 0; i < 24; i++) a.sendInput(stuck.termId, frame); + await a.waitFor('error', (m) => m.code === 'busy', { from }); + await waitUntil(() => a.view(flood.termId).position - before > 4 * FLOW_HIGH, 'flood output past the flow window', 10_000); + const busy = a.messages.slice(from).filter((m) => m.t === 'error'); + expect(busy.every((m) => m.req === null && m.code === 'busy')).toBe(true); + expect(busy.length).toBeGreaterThanOrEqual(1); + expect(busy.length).toBeLessThanOrEqual(8); + expect(a.isClosed).toBe(false); + }); +}); + +describe('resize', () => { + it('sets the size the program sees, the last resize winning', async () => { + const term = await started(a, null); + a.resize(term.termId, 90, 20); + a.resize(term.termId, 100, 30); + a.sendInput(term.termId, 'stty size\r'); + await a.waitOutput(term.termId, '30 100\r\n'); + const state = await (await host.client()).watch(repo); + expect(state.terminals.find((t) => t.termId === term.termId)).toMatchObject({ cols: 100, rows: 30 }); + }); + + it('resizes the mirror, so snapshots have the new size', async () => { + const term = await started(a, null); + a.resize(term.termId, 100, 30); + a.sendInput(term.termId, `printf '%099d|\\n' 7\r`); + await a.waitOutput(term.termId, '7|\r\n'); + const c = await host.client(); + await c.watch(repo); + const view = await c.attach(term.termId); + expect(await view.screen.text()).toContain(`${'0'.repeat(98)}7|`); + expect(await view.screen.state()).toEqual(await a.view(term.termId).screen.state()); + }); +}); diff --git a/test/process/daemon-terminals-unseen.test.ts b/test/process/daemon-terminals-unseen.test.ts new file mode 100644 index 0000000..58f6d74 --- /dev/null +++ b/test/process/daemon-terminals-unseen.test.ts @@ -0,0 +1,155 @@ +import { describe, expect, it, vi } from 'vitest'; +import type { Terminal } from '../../src/protocol/index.js'; +import type { DaemonClient, EventOf } from '../support/daemon-client.js'; +import { sleep } from '../support/daemon-host.js'; +import { a, b, host, repo, started, useDaemonTerminalsHost, wt } from '../support/daemon-terminals.js'; + +vi.setConfig({ testTimeout: 60_000, hookTimeout: 30_000 }); +useDaemonTerminalsHost(); + +describe('activity', () => { + /** Activity messages about `termId` that `client` received from index `from`. */ + const activityOf = (client: DaemonClient, termId: number, from: number): EventOf<'activity'>[] => + client.messages.slice(from).filter((m): m is EventOf<'activity'> => m.t === 'activity' && m.termId === termId); + + const isInput = + (termId: number): ((m: EventOf<'activity'>) => boolean) => + (m) => + m.termId === termId && m.state === 'input'; + + /** The terminal entry a fresh watcher of the repo receives. */ + const entryOf = async (termId: number): Promise => + (await (await host.client()).watch(repo)).terminals.find((t) => t.termId === termId); + + it('flags output while hidden as unseen and working to every watcher', async () => { + const term = await a.create(wt, { command: 'sleep 0.3; echo out; exec sleep 60' }); + for (const client of [a, b]) { + expect(await client.waitFor('activity', (m) => m.termId === term.termId && m.unseen && m.state === 'working')).toEqual({ + t: 'activity', + termId: term.termId, + unseen: true, + state: 'working', + }); + } + }); + + it('keeps unseen false while one of two clients shows the terminal', async () => { + const term = await a.create(wt, { command: 'read -r _; echo shown-output; exec sleep 60' }); + a.send({ t: 'setVisible', termIds: [term.termId] }); + await a.attach(term.termId); + const from = b.mark(); + a.sendInput(term.termId, '\r'); + await a.waitOutput(term.termId, 'shown-output'); + await sleep(300); + expect(activityOf(b, term.termId, from).filter((m) => m.unseen)).toEqual([]); + expect(await entryOf(term.termId)).toMatchObject({ unseen: false, state: 'working' }); + }); + + it('clears unseen when shown, keeps the state, and sends only changes', async () => { + const term = await started(a, null); + a.sendInput(term.termId, "sleep 1.2; printf '\\a'\r"); + await b.waitFor('activity', (m) => isInput(term.termId)(m) && m.unseen, { timeout: 10_000 }); + let from = b.mark(); + a.send({ t: 'setVisible', termIds: [term.termId] }); + expect(await b.waitFor('activity', () => true, { from })).toEqual({ + t: 'activity', + termId: term.termId, + unseen: false, + state: 'input', + }); + from = b.mark(); + a.sendInput(term.termId, 'echo while-visible\r'); + await a.waitOutput(term.termId, 'while-visible\r\n'); + await sleep(400); + expect(activityOf(b, term.termId, from)).toEqual([{ t: 'activity', termId: term.termId, unseen: false, state: 'working' }]); + a.send({ t: 'setVisible', termIds: [] }); + from = b.mark(); + a.sendInput(term.termId, 'echo hidden-again\r'); + expect(await b.waitFor('activity', (m) => m.unseen, { from })).toEqual({ + t: 'activity', + termId: term.termId, + unseen: true, + state: 'working', + }); + a.sendInput(term.termId, 'echo more\r'); + await b.expectNone('activity', () => true, 400); + }); + + it('flags an exit while hidden as unseen', async () => { + const term = await a.create(wt, { command: 'read -r _; exec sleep 1' }); + a.send({ t: 'setVisible', termIds: [term.termId] }); + await a.attach(term.termId); + a.sendInput(term.termId, '\r'); + await sleep(400); + a.send({ t: 'setVisible', termIds: [] }); + const from = b.mark(); + const position = a.view(term.termId).position; + await b.waitFor('termExited', (m) => m.termId === term.termId, { from }); + expect(await b.waitFor('activity', (m) => m.termId === term.termId && m.unseen, { from })).toEqual({ + t: 'activity', + termId: term.termId, + unseen: true, + state: 'working', + }); + expect(a.view(term.termId).position).toBe(position); + }); + + it('reports working, then idle 3 s after the last output', async () => { + const from = b.mark(); + const term = await started(a, 'echo quiet-line; exec sleep 60'); + await a.waitOutput(term.termId, 'quiet-line'); + const outputAt = Date.now(); + await b.waitFor('activity', (m) => m.termId === term.termId && m.state === 'idle', { from, timeout: 8000 }); + const idleAt = Date.now(); + expect(idleAt - outputAt).toBeGreaterThanOrEqual(2800); + expect(idleAt - outputAt).toBeLessThanOrEqual(4500); + const states = activityOf(b, term.termId, from) + .map((m) => m.state) + .filter((state, i, all) => i === 0 || state !== all[i - 1]); + expect(states.slice(-2)).toEqual(['working', 'idle']); + }); + + it('withdraws visibility when the showing client disconnects', async () => { + const term = await started(b, null); + await b.waitFor('activity', (m) => m.termId === term.termId && m.unseen); + const c = await host.client(); + await c.watch(repo); + let from = b.mark(); + c.send({ t: 'setVisible', termIds: [term.termId] }); + await b.waitFor('activity', (m) => !m.unseen, { from }); + c.close(); + await c.waitClosed(); + await sleep(200); + from = b.mark(); + b.sendInput(term.termId, 'echo after-leave\r'); + expect(await b.waitFor('activity', (m) => m.unseen, { from })).toMatchObject({ termId: term.termId, unseen: true }); + }); + + it('withdraws visibility when the showing client unwatches the repo', async () => { + const term = await started(b, null); + await b.waitFor('activity', (m) => m.termId === term.termId && m.unseen); + let from = b.mark(); + a.send({ t: 'setVisible', termIds: [term.termId] }); + await b.waitFor('activity', (m) => !m.unseen, { from }); + await a.ok({ t: 'unwatchRepo', repo }); + from = b.mark(); + b.sendInput(term.termId, 'echo after-unwatch\r'); + expect(await b.waitFor('activity', (m) => m.unseen, { from })).toMatchObject({ termId: term.termId, unseen: true }); + }); + + it('ignores visibility from a connection that does not watch the repo, and unknown ids', async () => { + const term = await started(b, null); + await b.waitFor('activity', (m) => m.termId === term.termId && m.unseen); + const from = b.mark(); + b.send({ t: 'setVisible', termIds: [term.termId] }); + await b.waitFor('activity', (m) => !m.unseen, { from }); + b.send({ t: 'setVisible', termIds: [] }); + const outsider = await host.client(); + outsider.send({ t: 'setVisible', termIds: [term.termId, 9999] }); + await sleep(200); + const later = b.mark(); + b.sendInput(term.termId, 'echo hidden\r'); + expect(await b.waitFor('activity', (m) => m.unseen, { from: later })).toMatchObject({ termId: term.termId, unseen: true }); + await outsider.expectNone('error', () => true, 200); + }); +}); diff --git a/test/process/daemon-terminals.test.ts b/test/process/daemon-terminals.test.ts deleted file mode 100644 index 3b9c7fa..0000000 --- a/test/process/daemon-terminals.test.ts +++ /dev/null @@ -1,905 +0,0 @@ -import { chmodSync, existsSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs'; -import { join } from 'node:path'; -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { FLOW_HIGH, MAX_FRAME, type Layout, type Terminal } from '../../src/protocol/index.js'; -import { replay, type DaemonClient, type Event, type EventOf, type RequestBody } from '../support/daemon-client.js'; -import { addWorktree, alive, DaemonHost, git, makeRepo, sleep, waitUntil, type WtdProcess } from '../support/daemon-host.js'; - -vi.setConfig({ testTimeout: 60_000, hookTimeout: 30_000 }); - -const ANY_REQ: unknown = expect.any(Number); - -const KiB = 1024; -const MiB = 1024 * KiB; - -let host: DaemonHost; -let daemon: WtdProcess; -let repo: string; -let wt: string; -let a: DaemonClient; -let b: DaemonClient; - -beforeEach(async () => { - host = DaemonHost.create(); - repo = makeRepo(join(host.dir, 'repo')); - wt = addWorktree(repo, join(host.dir, 'wt'), 'wt'); - daemon = await host.start(); - a = await host.client(); - b = await host.client(); - await a.watch(repo); - await b.watch(repo); -}); - -afterEach(async () => { - await host.cleanup(); -}); - -/** - * Creates a terminal in `worktree` and attaches `client` to it. A command runs only once attached, - * so all its output follows the snapshot; an interactive shell is waited for until it has run a - * command and printed its prompt, so login start-up output is over. - */ -const started = async ( - client: DaemonClient, - command: string | null, - { worktree = wt, cols = 80, rows = 24 }: { worktree?: string; cols?: number; rows?: number } = {}, -): Promise => { - const term = await client.create(worktree, { command: command === null ? null : `read -r _; ${command}`, cols, rows }); - await client.attach(term.termId); - if (command === null) { - client.sendInput(term.termId, 'echo READY-$((6*7))\r'); - await client.waitOutput(term.termId, 'READY-42\r\n', 10_000); - await sleep(300); - } else { - client.sendInput(term.termId, '\r'); - } - return term; -}; - -/** Messages from index `from`, without activity flags, which login start-up output may raise at any time. */ -const repliesFrom = (client: DaemonClient, from: number): Event[] => client.messages.slice(from).filter((m) => m.t !== 'activity'); - -/** Restarts the daemon with extra environment, re-watching from fresh clients. */ -const restart = async (env: Record = {}): Promise => { - a.send({ t: 'shutdown' }); - await daemon.exited; - daemon = await host.start(env); - a = await host.client(); - b = await host.client(); - await a.watch(repo); - await b.watch(repo); -}; - -const pidIn = (dir: string, name: string): Promise => - waitUntil(() => { - const file = join(dir, name); - if (!existsSync(file)) return undefined; - const text = readFileSync(file, 'utf8'); - return text.endsWith('\n') ? Number(text) : undefined; - }, `pid file ${name}`); - -const countByte = (data: Uint8Array, byte: number): number => { - let n = 0; - for (const b of data) if (b === byte) n++; - return n; -}; - -const stateFile = (): unknown => JSON.parse(readFileSync(host.statePath, 'utf8')); - -const stateMentions = (path: string): boolean => existsSync(host.statePath) && JSON.stringify(stateFile()).includes(JSON.stringify(path)); - -describe('terminal creation', () => { - it('starts a login interactive shell in the worktree', async () => { - const term = await started(a, null); - a.sendInput(term.termId, 'pwd\r'); - await a.waitOutput(term.termId, `${wt}\r\n`); - expect(a.view(term.termId).text()).toContain(`${wt}\r\n`); - }); - - it('runs a command in a login interactive shell with TERM and COLORTERM set', async () => { - const command = `case $- in *i*) echo mode-interactive;; esac; shopt -q login_shell && echo mode-login; echo "env=$TERM/$COLORTERM"; exec sleep 60`; - const term = await started(a, command); - await a.waitOutput(term.termId, 'env=xterm-256color/truecolor'); - const text = a.view(term.termId).text(); - expect(text).toContain('mode-interactive'); - expect(text).toContain('mode-login'); - }); - - it.each([ - ['unset', undefined], - ['relative', 'bash'], - ])('uses /bin/sh when SHELL is %s', async (_name, shell) => { - await restart({ SHELL: shell }); - const term = await started(a, 'echo "shell=$0"; exec sleep 60'); - await a.waitOutput(term.termId, 'shell=/bin/sh'); - }); - - it('answers the requester with its req and every other watcher with req null', async () => { - const outsider = await host.client(); - const from = b.mark(); - const reply = await a.request({ t: 'createTerm', worktree: wt, preset: 'my-preset', command: 'exec sleep 60', cols: 100, rows: 30 }); - const term = { termId: 1, worktree: wt, preset: 'my-preset', cols: 100, rows: 30, exit: null, unseen: false, state: 'idle' }; - expect(reply).toEqual({ t: 'termCreated', req: ANY_REQ, term }); - expect(await b.waitFor('termCreated', () => true, { from })).toEqual({ t: 'termCreated', req: null, term }); - await outsider.expectNone('termCreated', () => true, 300); - }); - - it('counts terminal ids up from 1 without reusing them', async () => { - const ids = []; - for (let i = 0; i < 3; i++) ids.push((await a.create(wt, { command: 'exec sleep 60' })).termId); - expect(ids).toEqual([1, 2, 3]); - await a.ok({ t: 'closeTerm', termId: 3 }); - expect((await a.create(wt, { command: 'exec sleep 60' })).termId).toBe(4); - }); - - it('does not attach the creator', async () => { - const term = await a.create(wt, { command: 'echo visible-output; exec sleep 60' }); - await sleep(500); - expect(a.received.filter((r) => r.kind !== 'message' && r.termId === term.termId)).toEqual([]); - }); - - it('fails with unknown-worktree for a worktree of a repo this connection does not watch', async () => { - const outsider = await host.client(); - expect(await outsider.fails({ t: 'createTerm', worktree: wt, preset: 'shell', command: null, cols: 80, rows: 24 })).toBe( - 'unknown-worktree', - ); - }); - - it('fails with unknown-worktree for a path that is not in the list', async () => { - expect(await a.fails({ t: 'createTerm', worktree: join(repo, 'sub'), preset: 'shell', command: null, cols: 80, rows: 24 })).toBe( - 'unknown-worktree', - ); - }); - - it('fails with spawn-failed when the worktree directory is gone', async () => { - const gone = addWorktree(repo, join(host.dir, 'gone'), 'gone'); - await a.waitFor('worktreesChanged', (m) => m.worktrees.some((w) => w.path === gone)); - rmSync(gone, { recursive: true }); - await a.waitFor('worktreesChanged', (m) => m.worktrees.some((w) => w.path === gone && w.prunable)); - expect(await a.fails({ t: 'createTerm', worktree: gone, preset: 'shell', command: null, cols: 80, rows: 24 })).toBe('spawn-failed'); - }); - - it('fails with spawn-failed when the shell does not exist', async () => { - await restart({ SHELL: join(host.dir, 'no-such-shell') }); - expect(await a.fails({ t: 'createTerm', worktree: wt, preset: 'shell', command: null, cols: 80, rows: 24 })).toBe('spawn-failed'); - }); -}); - -describe('terminal access', () => { - it.each([ - ['attach', { t: 'attach', termId: 99 }], - ['detach', { t: 'detach', termId: 99 }], - ['closeTerm', { t: 'closeTerm', termId: 99 }], - ] as const)('fails %s of an unknown terminal with unknown-term', async (_name, body) => { - expect(await a.fails(body)).toBe('unknown-term'); - }); - - it('answers resize and input for an unknown terminal with an uncorrelated unknown-term', async () => { - let from = a.mark(); - a.send({ t: 'resize', termId: 99, cols: 80, rows: 24 }); - expect(await a.waitFor('error', () => true, { from })).toMatchObject({ req: null, code: 'unknown-term' }); - from = a.mark(); - a.sendInput(99, 'x'); - expect(await a.waitFor('error', () => true, { from })).toMatchObject({ req: null, code: 'unknown-term' }); - }); - - it('fails requests for a terminal of a repo the connection does not watch with not-watched', async () => { - const term = await a.create(wt, { command: 'exec sleep 60' }); - const outsider = await host.client(); - expect(await outsider.fails({ t: 'attach', termId: term.termId })).toBe('not-watched'); - expect(await outsider.fails({ t: 'closeTerm', termId: term.termId })).toBe('not-watched'); - let from = outsider.mark(); - outsider.send({ t: 'resize', termId: term.termId, cols: 80, rows: 24 }); - expect(await outsider.waitFor('error', () => true, { from })).toMatchObject({ req: null, code: 'not-watched' }); - from = outsider.mark(); - outsider.sendInput(term.termId, 'x'); - expect(await outsider.waitFor('error', () => true, { from })).toMatchObject({ req: null, code: 'not-watched' }); - }); -}); - -describe('terminal lifetime', () => { - it('keeps running when its only client disconnects, keeping the output meanwhile', async () => { - const term = await started(a, 'sleep 0.8; echo MARKER-AFTER-LEAVE; exec sleep 60'); - a.close(); - await sleep(1500); - const c = await host.client(); - const state = await c.watch(repo); - expect(state.terminals.find((t) => t.termId === term.termId)?.exit).toBeNull(); - const view = await c.attach(term.termId); - expect(await view.screen.text()).toContain('MARKER-AFTER-LEAVE'); - }); - - it('keeps running when its client unwatches the repo', async () => { - const term = await started(a, 'exec sleep 60'); - await a.ok({ t: 'unwatchRepo', repo }); - await b.expectNone('termExited', () => true, 300); - expect((await b.watch(repo)).terminals.map((t) => t.termId)).toEqual([term.termId]); - }); - - it('delivers a final burst before termExited', async () => { - const term = await started(a, "read go; head -c 1048576 /dev/zero | tr '\\0' x; exit 0"); - a.sendInput(term.termId, 'go\r'); - await a.waitFor('termExited', (m) => m.termId === term.termId, { timeout: 30_000 }); - const exitedAt = a.received.findIndex((r) => r.kind === 'message' && r.message.t === 'termExited'); - const before = a.received - .slice(0, exitedAt) - .reduce((n, r) => n + (r.kind === 'output' && r.termId === term.termId ? countByte(r.data, 0x78) : 0), 0); - expect(before).toBe(MiB); - }); - - it('reports the exit code to every watcher and keeps the terminal until closed', async () => { - const term = await a.create(wt, { command: 'exit 3' }); - const expected = { t: 'termExited', termId: term.termId, code: 3, signal: null }; - expect(await a.waitFor('termExited')).toEqual(expected); - expect(await b.waitFor('termExited')).toEqual(expected); - const state = await (await host.client()).watch(repo); - expect(state.terminals.find((t) => t.termId === term.termId)?.exit).toEqual({ code: 3, signal: null }); - await a.ok({ t: 'closeTerm', termId: term.termId }); - }); - - it('reports the name of a fatal signal', async () => { - const term = await a.create(wt, { command: 'kill -KILL $$' }); - expect(await a.waitFor('termExited', (m) => m.termId === term.termId)).toMatchObject({ signal: 'SIGKILL' }); - }); - - it('ends the shell and its background job on close, then reports termClosed before done', async () => { - const term = await a.create(wt, { command: 'sleep 600 & echo $! > bg.pid; echo $$ > sh.pid; wait' }); - const shell = await pidIn(wt, 'sh.pid'); - const job = await pidIn(wt, 'bg.pid'); - const from = a.mark(); - await a.ok({ t: 'closeTerm', termId: term.termId }); - expect(repliesFrom(a, from).map((m) => m.t)).toEqual(['termClosed', 'done']); - expect(await b.waitFor('termClosed')).toEqual({ t: 'termClosed', termId: term.termId }); - await waitUntil(() => !alive(shell) && !alive(job), 'shell and job to end', 7000); - expect((await b.watch(repo)).terminals).toEqual([]); - expect(await a.fails({ t: 'closeTerm', termId: term.termId })).toBe('unknown-term'); - }); - - it('kills a shell that ignores SIGHUP 5 s after close', async () => { - const term = await a.create(wt, { command: "trap '' HUP; echo $$ > sh.pid; while :; do sleep 0.2; done" }); - const shell = await pidIn(wt, 'sh.pid'); - await a.ok({ t: 'closeTerm', termId: term.termId }); - await sleep(3000); - expect(alive(shell)).toBe(true); - await waitUntil(() => !alive(shell), 'the shell to be killed', 5000); - }); -}); - -describe('attach and snapshot', () => { - const GENERATOR = String.raw` -read go -i=0 -while [ $i -lt 3000 ]; do printf 'line %05d 中文字符 😀 \033[3%dmcolour\033[0m\n' $i $((i % 7)); i=$((i+1)); done -echo PHASE-ONE -read go -while [ $i -lt 6000 ]; do printf 'line %05d wide \033[1;4;3%dmstyled\033[0m\n' $i $((i % 7)); i=$((i+1)); done -printf '\033[?1h\033[?2004h\033[?25l' -printf '\033[?1049h\033[2J\033[Halternate 中文\033[5;10Hplaced \033[1;31mred\033[0m' -printf '\033[3;4HDONE-MARK' -exec sleep 600 -`; - - it('restores the same screen, cursor, modes and scrollback for a client attaching mid-output', async () => { - writeFileSync(join(wt, 'gen.sh'), GENERATOR); - const term = await started(a, 'bash ./gen.sh'); - a.sendInput(term.termId, 'go\r'); - await a.waitOutput(term.termId, 'PHASE-ONE', 20_000); - await b.attach(term.termId); - a.sendInput(term.termId, 'go\r'); - await a.waitOutput(term.termId, 'DONE-MARK', 20_000); - await b.waitOutput(term.termId, 'DONE-MARK', 20_000); - const seenByA = await a.view(term.termId).screen.state(); - const seenByB = await b.view(term.termId).screen.state(); - expect(seenByA.buffer).toBe('alternate'); - expect(seenByB).toEqual(seenByA); - }); - - it('continues output exactly at the snapshot offset however attaches interleave with output', async () => { - const term = await started(a, 'read go; seq 1 400000; echo SEQ-DONE; exec sleep 600'); - a.sendInput(term.termId, 'go\r'); - for (let i = 0; i < 8; i++) { - await b.attach(term.termId, 20_000); - await sleep(40); - } - await a.waitOutput(term.termId, 'SEQ-DONE', 30_000); - // B's last attach may come after the output ended, leaving SEQ-DONE in its snapshot. - await waitUntil(async () => (await b.view(term.termId).screen.text()).includes('SEQ-DONE'), 'SEQ-DONE on B', 30_000); - const reference = a.view(term.termId); - const [first] = reference.attachments; - if (first === undefined) throw new Error('A has no snapshot'); - const all = reference.bytes(); - const view = b.view(term.termId); - expect(view.gaps).toEqual([]); - expect(reference.gaps).toEqual([]); - for (const attachment of view.attachments) { - if (attachment.firstOutput !== null) expect(attachment.firstOutput).toBe(attachment.snapshotOffset); - const upTo = all.subarray(0, attachment.snapshotOffset - first.snapshotOffset); - expect(await replay(80, 24, attachment.snapshot)).toEqual(await replay(80, 24, first.snapshot, upTo)); - } - const last = view.attachments.at(-1); - if (last === undefined) throw new Error('B has no snapshot'); - expect(view.bytes().equals(all.subarray(last.snapshotOffset - first.snapshotOffset))).toBe(true); - // The snapshot may cut a line: its tail starts B's output. - const numbers = view - .text() - .split('\r\n') - .slice(1) - .filter((line) => /^\d+$/.test(line)) - .map(Number); - expect(numbers.every((n, i) => i === 0 || n === (numbers[i - 1] ?? 0) + 1)).toBe(true); - }); - - it('shrinks the scrollback of a snapshot that would exceed MAX_FRAME', async () => { - const awk = `awk 'BEGIN { for (l = 0; l < 5100; l++) { s = ""; for (c = 0; c < 500; c++) s = s "\\033[31ma\\033[32mb"; print s } }'; echo BIG-DONE`; - const term = await a.create(wt, { command: awk, cols: 1000, rows: 50 }); - await a.waitFor('termExited', (m) => m.termId === term.termId, { timeout: 90_000 }); - const view = await a.attach(term.termId, 60_000); - const snapshot = view.attachments[0]?.snapshot; - if (snapshot === undefined) throw new Error('no snapshot'); - expect(snapshot.length).toBeGreaterThan(MiB); - expect(snapshot.length + 12).toBeLessThanOrEqual(MAX_FRAME); - expect(await view.screen.text()).toContain('BIG-DONE'); - }, 180_000); - - it('re-attaches with a new snapshot when attaching while attached', async () => { - const term = await started(a, 'echo hello; exec sleep 60'); - await a.waitOutput(term.termId, 'hello'); - await a.attach(term.termId); - expect(a.view(term.termId).attachments).toHaveLength(2); - expect(await a.view(term.termId).screen.text()).toContain('hello'); - }); - - it('detaches whether or not attached, and stops output after detaching', async () => { - const term = await a.create(wt, { command: null }); - await a.ok({ t: 'detach', termId: term.termId }); - await a.attach(term.termId); - await a.ok({ t: 'detach', termId: term.termId }); - const count = a.received.length; - b.sendInput(term.termId, 'echo after-detach\r'); - await sleep(500); - expect(a.received.slice(count).filter((r) => r.kind !== 'message')).toEqual([]); - }); - - it('attaches to an exited terminal', async () => { - const term = await a.create(wt, { command: 'echo bye-bye; exit 0' }); - await a.waitFor('termExited', (m) => m.termId === term.termId); - const view = await a.attach(term.termId); - expect(await view.screen.text()).toContain('bye-bye'); - }); -}); - -describe('output flow control', () => { - it('evicts a client that never acks while an acking client receives every byte', async () => { - const term = await started(b, 'read go; seq 1 1500000; echo FLOOD-DONE'); - a.autoAck = false; - await a.attach(term.termId); - const goAt = Date.now(); - b.sendInput(term.termId, 'go\r'); - expect(await a.waitFor('detached', () => true, { timeout: 20_000 })).toEqual({ t: 'detached', termId: term.termId, reason: 'lagging' }); - expect(Date.now() - goAt).toBeGreaterThanOrEqual(1900); - expect(a.view(term.termId).bytes().length).toBeLessThan(FLOW_HIGH + 512 * KiB); - await b.waitFor('termExited', (m) => m.termId === term.termId, { timeout: 60_000 }); - const view = b.view(term.termId); - expect(view.gaps).toEqual([]); - const numbers = view - .text() - .split('\r\n') - .filter((line) => /^\d+$/.test(line)); - expect(numbers).toHaveLength(1_500_000); - expect(numbers.every((n, i) => Number(n) === i + 1)).toBe(true); - expect(view.text()).toContain('FLOOD-DONE'); - }); - - it('lets an unwatched flood complete and shows its final screen to a later attach', async () => { - const term = await a.create(wt, { command: 'seq 1 2000000; echo NOBODY-DONE' }); - await a.waitFor('termExited', (m) => m.termId === term.termId, { timeout: 60_000 }); - const text = await (await a.attach(term.termId)).screen.text(); - expect(text).toContain('2000000'); - expect(text).toContain('NOBODY-DONE'); - }); - - it('closes a connection that acks beyond the output sent to it, leaving the terminal running', async () => { - const term = await started(a, 'echo some-output; exec sleep 60'); - await a.waitOutput(term.termId, 'some-output'); - a.send({ t: 'ack', termId: term.termId, offset: a.view(term.termId).position + MiB }); - expect(await a.waitFor('error')).toMatchObject({ req: null, code: 'bad-message' }); - await a.waitClosed(); - expect((await b.watch(repo)).terminals.find((t) => t.termId === term.termId)?.exit).toBeNull(); - }); - - it('ignores an ack at or below the current ack position', async () => { - const term = await started(a, 'echo some-output; exec sleep 60'); - await a.waitOutput(term.termId, 'some-output'); - const position = a.view(term.termId).position; - a.send({ t: 'ack', termId: term.termId, offset: position }); - a.send({ t: 'ack', termId: term.termId, offset: position }); - a.send({ t: 'ack', termId: term.termId, offset: 0 }); - await a.expectNone('error', () => true, 400); - expect(a.isClosed).toBe(false); - }); -}); - -describe('input', () => { - it('echoes a command round trip', async () => { - const term = await started(a, null); - a.sendInput(term.termId, "echo h''i\r"); - await a.waitOutput(term.termId, 'hi\r\n'); - expect(a.view(term.termId).text()).toContain('hi\r\n'); - }); - - it('writes input frames to the PTY in order', async () => { - const term = await started(a, 'stty -echo; cat > in.txt'); - const lines = Array.from({ length: 300 }, (_, i) => `line ${String(i)}`); - for (const line of lines) a.sendInput(term.termId, `${line}\n`); - a.sendInput(term.termId, '\x04'); - await a.waitFor('termExited', (m) => m.termId === term.termId, { timeout: 10_000 }); - expect(readFileSync(join(wt, 'in.txt'), 'utf8')).toBe(lines.map((l) => `${l}\n`).join('')); - }); - - it('discards input to an exited terminal without an error', async () => { - const term = await a.create(wt, { command: 'exit 0' }); - await a.waitFor('termExited', (m) => m.termId === term.termId); - a.sendInput(term.termId, 'ignored\r'); - await a.expectNone('error', () => true, 400); - expect(a.isClosed).toBe(false); - }); - - it('answers input beyond 1 MiB unaccepted with busy while still processing acks', async () => { - const stuck = await started(a, 'stty raw -echo; echo READY; exec sleep 600'); - await a.waitOutput(stuck.termId, 'READY'); - const flood = await started(a, 'yes flood-line'); - const before = a.view(flood.termId).position; - const from = a.mark(); - const frame = new Uint8Array(64 * KiB).fill(0x61); - for (let i = 0; i < 24; i++) a.sendInput(stuck.termId, frame); - await a.waitFor('error', (m) => m.code === 'busy', { from }); - await waitUntil(() => a.view(flood.termId).position - before > 4 * FLOW_HIGH, 'flood output past the flow window', 10_000); - const busy = a.messages.slice(from).filter((m) => m.t === 'error'); - expect(busy.every((m) => m.req === null && m.code === 'busy')).toBe(true); - expect(busy.length).toBeGreaterThanOrEqual(1); - expect(busy.length).toBeLessThanOrEqual(8); - expect(a.isClosed).toBe(false); - }); -}); - -describe('resize', () => { - it('sets the size the program sees, the last resize winning', async () => { - const term = await started(a, null); - a.resize(term.termId, 90, 20); - a.resize(term.termId, 100, 30); - a.sendInput(term.termId, 'stty size\r'); - await a.waitOutput(term.termId, '30 100\r\n'); - const state = await (await host.client()).watch(repo); - expect(state.terminals.find((t) => t.termId === term.termId)).toMatchObject({ cols: 100, rows: 30 }); - }); - - it('resizes the mirror, so snapshots have the new size', async () => { - const term = await started(a, null); - a.resize(term.termId, 100, 30); - a.sendInput(term.termId, `printf '%099d|\\n' 7\r`); - await a.waitOutput(term.termId, '7|\r\n'); - const c = await host.client(); - await c.watch(repo); - const view = await c.attach(term.termId); - expect(await view.screen.text()).toContain(`${'0'.repeat(98)}7|`); - expect(await view.screen.state()).toEqual(await a.view(term.termId).screen.state()); - }); -}); - -describe('activity', () => { - /** Activity messages about `termId` that `client` received from index `from`. */ - const activityOf = (client: DaemonClient, termId: number, from: number): EventOf<'activity'>[] => - client.messages.slice(from).filter((m): m is EventOf<'activity'> => m.t === 'activity' && m.termId === termId); - - const isInput = - (termId: number): ((m: EventOf<'activity'>) => boolean) => - (m) => - m.termId === termId && m.state === 'input'; - - /** The terminal entry a fresh watcher of the repo receives. */ - const entryOf = async (termId: number): Promise => - (await (await host.client()).watch(repo)).terminals.find((t) => t.termId === termId); - - it('flags output while hidden as unseen and working to every watcher', async () => { - const term = await a.create(wt, { command: 'sleep 0.3; echo out; exec sleep 60' }); - for (const client of [a, b]) { - expect(await client.waitFor('activity', (m) => m.termId === term.termId && m.unseen && m.state === 'working')).toEqual({ - t: 'activity', - termId: term.termId, - unseen: true, - state: 'working', - }); - } - }); - - it('keeps unseen false while one of two clients shows the terminal', async () => { - const term = await a.create(wt, { command: 'read -r _; echo shown-output; exec sleep 60' }); - a.send({ t: 'setVisible', termIds: [term.termId] }); - await a.attach(term.termId); - const from = b.mark(); - a.sendInput(term.termId, '\r'); - await a.waitOutput(term.termId, 'shown-output'); - await sleep(300); - expect(activityOf(b, term.termId, from).filter((m) => m.unseen)).toEqual([]); - expect(await entryOf(term.termId)).toMatchObject({ unseen: false, state: 'working' }); - }); - - it('clears unseen when shown, keeps the state, and sends only changes', async () => { - const term = await started(a, null); - a.sendInput(term.termId, "sleep 1.2; printf '\\a'\r"); - await b.waitFor('activity', (m) => isInput(term.termId)(m) && m.unseen, { timeout: 10_000 }); - let from = b.mark(); - a.send({ t: 'setVisible', termIds: [term.termId] }); - expect(await b.waitFor('activity', () => true, { from })).toEqual({ - t: 'activity', - termId: term.termId, - unseen: false, - state: 'input', - }); - from = b.mark(); - a.sendInput(term.termId, 'echo while-visible\r'); - await a.waitOutput(term.termId, 'while-visible\r\n'); - await sleep(400); - expect(activityOf(b, term.termId, from)).toEqual([{ t: 'activity', termId: term.termId, unseen: false, state: 'working' }]); - a.send({ t: 'setVisible', termIds: [] }); - from = b.mark(); - a.sendInput(term.termId, 'echo hidden-again\r'); - expect(await b.waitFor('activity', (m) => m.unseen, { from })).toEqual({ - t: 'activity', - termId: term.termId, - unseen: true, - state: 'working', - }); - a.sendInput(term.termId, 'echo more\r'); - await b.expectNone('activity', () => true, 400); - }); - - it('flags an exit while hidden as unseen', async () => { - const term = await a.create(wt, { command: 'read -r _; exec sleep 1' }); - a.send({ t: 'setVisible', termIds: [term.termId] }); - await a.attach(term.termId); - a.sendInput(term.termId, '\r'); - await sleep(400); - a.send({ t: 'setVisible', termIds: [] }); - const from = b.mark(); - const position = a.view(term.termId).position; - await b.waitFor('termExited', (m) => m.termId === term.termId, { from }); - expect(await b.waitFor('activity', (m) => m.termId === term.termId && m.unseen, { from })).toEqual({ - t: 'activity', - termId: term.termId, - unseen: true, - state: 'working', - }); - expect(a.view(term.termId).position).toBe(position); - }); - - it('reports working, then idle 3 s after the last output', async () => { - const from = b.mark(); - const term = await started(a, 'echo quiet-line; exec sleep 60'); - await a.waitOutput(term.termId, 'quiet-line'); - const outputAt = Date.now(); - await b.waitFor('activity', (m) => m.termId === term.termId && m.state === 'idle', { from, timeout: 8000 }); - const idleAt = Date.now(); - expect(idleAt - outputAt).toBeGreaterThanOrEqual(2800); - expect(idleAt - outputAt).toBeLessThanOrEqual(4500); - const states = activityOf(b, term.termId, from) - .map((m) => m.state) - .filter((state, i, all) => i === 0 || state !== all[i - 1]); - expect(states.slice(-2)).toEqual(['working', 'idle']); - }); - - it('sets input on a bell, also in the terminal entry', async () => { - const term = await a.create(wt, { command: String.raw`sleep 0.3; printf '\a'; exec sleep 60` }); - expect(await a.waitFor('activity', isInput(term.termId))).toEqual({ t: 'activity', termId: term.termId, unseen: true, state: 'input' }); - expect(await entryOf(term.termId)).toMatchObject({ unseen: true, state: 'input' }); - }); - - it.each([ - ['OSC 9', String.raw`printf '\033]9;done\a'`], - ['OSC 777', String.raw`printf '\033]777;notify;t;b\a'`], - ['OSC 99', String.raw`printf '\033]99;;hi\033\\'`], - ])('sets input on an %s notification', async (_name, print) => { - const term = await a.create(wt, { command: `sleep 0.3; ${print}; exec sleep 60` }); - expect(await b.waitFor('activity', isInput(term.termId))).toMatchObject({ state: 'input' }); - }); - - it('does not set input on a ConEmu progress report', async () => { - const from = b.mark(); - const term = await started(a, String.raw`printf '\033]9;4;1;50\a'; sleep 0.3; printf progress-done; exec sleep 60`); - await a.waitOutput(term.termId, 'progress-done'); - await sleep(700); - expect(activityOf(b, term.termId, from).filter((m) => m.state === 'input')).toEqual([]); - }); - - it('sets input once for an OSC 777 notification split across output chunks', async () => { - const from = b.mark(); - const term = await started( - a, - String.raw`printf '\033]777;noti'; sleep 0.3; printf 'fy;t;b\a'; sleep 0.3; printf split-done; exec sleep 60`, - ); - await a.waitOutput(term.termId, 'split-done'); - await sleep(500); - expect(a.view(term.termId).chunks.some((c) => Buffer.from(c).toString().endsWith('\x1b]777;noti'))).toBe(true); - expect(activityOf(b, term.termId, from).filter((m) => m.state === 'input')).toHaveLength(1); - }); - - it('ignores a bell answering a tab within 1 s and honours one after', async () => { - const from = b.mark(); - const term = await started( - a, - String.raw`printf 'ready\n'; IFS= read -r -n1 _; printf '\a'; printf rang; sleep 1.5; printf '\a'; exec sleep 60`, - ); - await a.waitOutput(term.termId, 'ready'); - await sleep(1200); - a.sendInput(term.termId, '\t'); - await a.waitOutput(term.termId, 'rang'); - await sleep(500); - expect(activityOf(b, term.termId, from).filter((m) => m.state === 'input')).toEqual([]); - await b.waitFor('activity', isInput(term.termId), { from }); - }); - - it('clears input when a key is typed', async () => { - const term = await a.create(wt, { command: String.raw`stty -echo; sleep 0.3; printf '\a'; exec sleep 60` }); - await b.waitFor('activity', isInput(term.termId)); - const from = b.mark(); - a.sendInput(term.termId, 'y'); - expect(await b.waitFor('activity', (m) => m.termId === term.termId, { from })).toEqual({ - t: 'activity', - termId: term.termId, - unseen: true, - state: 'working', - }); - }); - - it('keeps input on focus reports and passes them to the program', async () => { - const term = await a.create(wt, { command: String.raw`stty -echo -icanon; sleep 0.3; printf '\a'; exec cat -v` }); - await a.attach(term.termId); - await b.waitFor('activity', isInput(term.termId)); - const from = b.mark(); - a.sendInput(term.termId, '\x1b[O\x1b[I'); - await a.waitOutput(term.termId, '^[[O^[[I'); - await sleep(500); - expect(activityOf(b, term.termId, from)).toEqual([]); - expect(await entryOf(term.termId)).toMatchObject({ state: 'input' }); - }); - - it('keeps input when the program answers a focus report 5 s after the signal at once', async () => { - const term = await a.create(wt, { - command: String.raw`stty -echo -icanon; sleep 0.3; printf '\a'; head -c 3 >/dev/null; printf '\033(B\017'; exec sleep 60`, - }); - await a.attach(term.termId); - await b.waitFor('activity', isInput(term.termId)); - await sleep(5000); - const from = b.mark(); - a.sendInput(term.termId, '\x1b[I'); - await waitUntil(() => a.view(term.termId).bytes().includes('\x1b(B\x0f'), 'the reply to the focus report'); - await sleep(500); - expect(activityOf(b, term.termId, from)).toEqual([]); - expect(await entryOf(term.termId)).toMatchObject({ state: 'input' }); - }); - - it('clears input on output 1.5 s after the signal', async () => { - const term = await a.create(wt, { command: String.raw`sleep 0.3; printf '\a'; sleep 1.5; printf later; exec sleep 60` }); - await b.waitFor('activity', isInput(term.termId)); - const from = b.mark(); - expect(await b.waitFor('activity', (m) => m.termId === term.termId, { from })).toEqual({ - t: 'activity', - termId: term.termId, - unseen: true, - state: 'working', - }); - }); - - it('keeps input on output 0.5 s after the signal', async () => { - const term = await started(a, String.raw`sleep 1.3; printf '\a'; sleep 0.5; printf soon; exec sleep 60`); - await b.waitFor('activity', isInput(term.termId)); - const from = b.mark(); - await a.waitOutput(term.termId, 'soon'); - await sleep(1000); - expect(activityOf(b, term.termId, from)).toEqual([]); - expect(await entryOf(term.termId)).toMatchObject({ state: 'input' }); - }); - - it('withdraws visibility when the showing client disconnects', async () => { - const term = await started(b, null); - await b.waitFor('activity', (m) => m.termId === term.termId && m.unseen); - const c = await host.client(); - await c.watch(repo); - let from = b.mark(); - c.send({ t: 'setVisible', termIds: [term.termId] }); - await b.waitFor('activity', (m) => !m.unseen, { from }); - c.close(); - await c.waitClosed(); - await sleep(200); - from = b.mark(); - b.sendInput(term.termId, 'echo after-leave\r'); - expect(await b.waitFor('activity', (m) => m.unseen, { from })).toMatchObject({ termId: term.termId, unseen: true }); - }); - - it('withdraws visibility when the showing client unwatches the repo', async () => { - const term = await started(b, null); - await b.waitFor('activity', (m) => m.termId === term.termId && m.unseen); - let from = b.mark(); - a.send({ t: 'setVisible', termIds: [term.termId] }); - await b.waitFor('activity', (m) => !m.unseen, { from }); - await a.ok({ t: 'unwatchRepo', repo }); - from = b.mark(); - b.sendInput(term.termId, 'echo after-unwatch\r'); - expect(await b.waitFor('activity', (m) => m.unseen, { from })).toMatchObject({ termId: term.termId, unseen: true }); - }); - - it('ignores visibility from a connection that does not watch the repo, and unknown ids', async () => { - const term = await started(b, null); - await b.waitFor('activity', (m) => m.termId === term.termId && m.unseen); - const from = b.mark(); - b.send({ t: 'setVisible', termIds: [term.termId] }); - await b.waitFor('activity', (m) => !m.unseen, { from }); - b.send({ t: 'setVisible', termIds: [] }); - const outsider = await host.client(); - outsider.send({ t: 'setVisible', termIds: [term.termId, 9999] }); - await sleep(200); - const later = b.mark(); - b.sendInput(term.termId, 'echo hidden\r'); - expect(await b.waitFor('activity', (m) => m.unseen, { from: later })).toMatchObject({ termId: term.termId, unseen: true }); - await outsider.expectNone('error', () => true, 200); - }); -}); - -describe('checked state and layouts', () => { - const twoPanes = (one: number, two: number): Layout => ({ - tabs: [{ id: 'main', root: { split: 'right', ratio: 0.5, a: { term: one }, b: { term: two } } }], - active: 0, - }); - - it('broadcasts a checkbox change to every watcher before answering done', async () => { - const from = a.mark(); - const fromB = b.mark(); - await a.ok({ t: 'setChecked', worktree: wt, checked: true }); - expect(a.messages.slice(from)).toEqual([ - { t: 'checkedChanged', worktree: wt, checked: true }, - { t: 'done', req: ANY_REQ }, - ]); - expect(await b.waitFor('checkedChanged', () => true, { from: fromB })).toEqual({ t: 'checkedChanged', worktree: wt, checked: true }); - expect((await (await host.client()).watch(repo)).checked).toEqual([wt]); - expect(stateMentions(wt)).toBe(true); - expect(statSync(host.statePath).mode & 0o777).toBe(0o600); - }); - - it.each<[string, (worktree: string) => RequestBody]>([ - ['setChecked', (worktree) => ({ t: 'setChecked', worktree, checked: true })], - ['setLayout', (worktree) => ({ t: 'setLayout', worktree, layout: { tabs: [], active: 0 } })], - ])('fails %s for a worktree not in a watched list with unknown-worktree', async (_name, body) => { - const outsider = await host.client(); - expect(await outsider.fails(body(wt))).toBe('unknown-worktree'); - expect(await a.fails(body(join(repo, 'sub')))).toBe('unknown-worktree'); - }); - - it('broadcasts a layout of the worktree terminals and reports it in repoState', async () => { - const one = await a.create(wt, { command: 'exec sleep 60' }); - const two = await a.create(wt, { command: 'exec sleep 60' }); - const layout = twoPanes(one.termId, two.termId); - const from = a.mark(); - await a.ok({ t: 'setLayout', worktree: wt, layout }); - expect(repliesFrom(a, from)).toEqual([ - { t: 'layoutChanged', worktree: wt, layout }, - { t: 'done', req: ANY_REQ }, - ]); - await b.waitFor('layoutChanged', (m) => m.worktree === wt); - expect((await (await host.client()).watch(repo)).layouts).toEqual([{ worktree: wt, layout }]); - }); - - it('rejects a layout holding a terminal of another worktree, keeping the stored one', async () => { - const own = await a.create(wt, { command: 'exec sleep 60' }); - const other = await a.create(repo, { command: 'exec sleep 60' }); - const kept: Layout = { tabs: [{ id: 'only', root: { term: own.termId } }], active: 0 }; - await a.ok({ t: 'setLayout', worktree: wt, layout: kept }); - expect(await a.fails({ t: 'setLayout', worktree: wt, layout: twoPanes(own.termId, other.termId) })).toBe('unknown-term'); - expect(await a.fails({ t: 'setLayout', worktree: wt, layout: twoPanes(own.termId, 999) })).toBe('unknown-term'); - expect((await (await host.client()).watch(repo)).layouts).toEqual([{ worktree: wt, layout: kept }]); - }); - - it('replaces a split by its other half when a terminal in it is closed', async () => { - const one = await a.create(wt, { command: 'exec sleep 60' }); - const two = await a.create(wt, { command: 'exec sleep 60' }); - const fromSet = b.mark(); - await a.ok({ t: 'setLayout', worktree: wt, layout: twoPanes(one.termId, two.termId) }); - await b.waitFor('layoutChanged', () => true, { from: fromSet }); - const from = b.mark(); - await a.ok({ t: 'closeTerm', termId: one.termId }); - expect(await b.waitFor('layoutChanged', () => true, { from })).toEqual({ - t: 'layoutChanged', - worktree: wt, - layout: { tabs: [{ id: 'main', root: { term: two.termId } }], active: 0 }, - }); - }); - - it('removes a tab left empty when its last terminal is closed', async () => { - const one = await a.create(wt, { command: 'exec sleep 60' }); - const fromSet = b.mark(); - await a.ok({ t: 'setLayout', worktree: wt, layout: { tabs: [{ id: 'solo', root: { term: one.termId } }], active: 0 } }); - await b.waitFor('layoutChanged', () => true, { from: fromSet }); - const from = b.mark(); - await a.ok({ t: 'closeTerm', termId: one.termId }); - expect(await b.waitFor('layoutChanged', () => true, { from })).toEqual({ - t: 'layoutChanged', - worktree: wt, - layout: { tabs: [], active: 0 }, - }); - }); - - it.skipIf(process.getuid?.() === 0)( - 'answers internal for a change that cannot be persisted, keeping state and broadcasting nothing', - async () => { - const one = await a.create(wt, { command: 'exec sleep 60' }); - await a.ok({ t: 'setChecked', worktree: repo, checked: true }); - const before = await (await host.client()).watch(repo); - chmodSync(host.stateDir, 0o500); - try { - const fromB = b.mark(); - expect(await a.fails({ t: 'setChecked', worktree: wt, checked: true })).toBe('internal'); - const layout: Layout = { tabs: [{ id: 'solo', root: { term: one.termId } }], active: 0 }; - expect(await a.fails({ t: 'setLayout', worktree: wt, layout })).toBe('internal'); - await sleep(300); - expect(b.messages.slice(fromB).filter((m) => m.t === 'checkedChanged' || m.t === 'layoutChanged')).toEqual([]); - const after = await (await host.client()).watch(repo); - expect({ checked: after.checked, layouts: after.layouts }).toEqual({ checked: before.checked, layouts: before.layouts }); - } finally { - chmodSync(host.stateDir, 0o700); - } - }, - ); -}); - -describe('persistence and pruning', () => { - it('keeps checked worktrees across a restart and drops layout terminals', async () => { - const term = await a.create(wt, { command: 'exec sleep 60' }); - await a.ok({ t: 'setChecked', worktree: wt, checked: true }); - await a.ok({ t: 'setLayout', worktree: wt, layout: { tabs: [{ id: 'x', root: { term: term.termId } }], active: 0 } }); - await restart(); - const state = await (await host.client()).watch(repo); - expect(state.checked).toEqual([wt]); - expect(state.layouts.find((l) => l.worktree === wt)?.layout.tabs ?? []).toEqual([]); - expect(state.terminals).toEqual([]); - }); - - it('prunes a removed worktree without terminals', async () => { - const gone = addWorktree(repo, join(host.dir, 'gone'), 'gone'); - await a.waitFor('worktreesChanged', (m) => m.worktrees.some((w) => w.path === gone)); - await a.ok({ t: 'setChecked', worktree: gone, checked: true }); - expect(stateMentions(gone)).toBe(true); - git(repo, 'worktree', 'remove', gone); - await a.waitFor('worktreesChanged', (m) => !m.worktrees.some((w) => w.path === gone)); - await waitUntil(() => !stateMentions(gone), 'the entry to be pruned', 2000); - expect((await (await host.client()).watch(repo)).checked).toEqual([]); - }); - - it('keeps a removed worktree with a live terminal until the terminal is closed', async () => { - const gone = addWorktree(repo, join(host.dir, 'gone'), 'gone'); - await a.waitFor('worktreesChanged', (m) => m.worktrees.some((w) => w.path === gone)); - const term = await a.create(gone, { command: 'exec sleep 600' }); - await a.ok({ t: 'setChecked', worktree: gone, checked: true }); - git(repo, 'worktree', 'remove', '--force', gone); - await a.waitFor('worktreesChanged', (m) => !m.worktrees.some((w) => w.path === gone)); - await sleep(500); - expect(stateMentions(gone)).toBe(true); - const state = await (await host.client()).watch(repo); - expect(state.terminals.map((t) => t.worktree)).toEqual([gone]); - await a.ok({ t: 'closeTerm', termId: term.termId }); - await waitUntil(() => !stateMentions(gone), 'the entry to be pruned', 2000); - }); - - it('moves a corrupt state file aside and starts with no checked worktrees', async () => { - await a.ok({ t: 'setChecked', worktree: wt, checked: true }); - a.send({ t: 'shutdown' }); - await daemon.exited; - writeFileSync(host.statePath, '{"version":1,"repos":['); - daemon = await host.start(); - // The socket answers before the state is loaded; connections wait for it. - const names = await waitUntil(() => { - const moved = readdirSync(host.stateDir).filter((n) => n.startsWith('state.json.corrupt-')); - return moved.length > 0 && moved; - }, 'the corrupt state file moved aside'); - expect(names).toHaveLength(1); - expect((await (await host.client()).watch(repo)).checked).toEqual([]); - }); -}); diff --git a/test/process/hub-router-config.test.ts b/test/process/hub-router-config.test.ts new file mode 100644 index 0000000..9a492c3 --- /dev/null +++ b/test/process/hub-router-config.test.ts @@ -0,0 +1,404 @@ +import { mkdirSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { describe, expect, it, vi } from 'vitest'; +import { makeRepo, sleep } from '../support/daemon-host.js'; +import { FakeDaemon } from '../support/fake-daemon.js'; +import { installRemote } from '../support/fake-ssh.js'; +import type { HubClient, HubMessageOf } from '../support/hub-client.js'; +import { + configure, + connected, + connectedHost, + host, + installedBox, + nodeRemote, + repo, + ssh, + useHubRouterHost, + watching, + wt, +} from '../support/hub-router.js'; + +vi.setConfig({ testTimeout: 60_000, hookTimeout: 30_000 }); +useHubRouterHost(); + +const SHELL = { name: 'shell', command: null }; +const CLAUDE = { name: 'claude', command: 'claude' }; +/** The presets of a configuration without `presets`. */ +const DEFAULT_PRESETS = [SHELL, CLAUDE, { name: 'codex', command: 'codex' }]; + +/** The preset lists of every `presets` message received so far. */ +const presetsOf = (client: HubClient): HubMessageOf<'presets'>['presets'][] => + client.messages.flatMap((m) => (m.t === 'presets' ? [m.presets] : [])); + +/** Reads `config.json` as JSON. */ +const configFile = (): unknown => JSON.parse(readFileSync(host.configPath, 'utf8')); + +describe('configuration snapshots', () => { + it('lists no repos when the configuration has none', async () => { + host.writeConfig({ port: host.port }); + const client = await host.session(); + expect(client.hosts()[0]?.repos).toEqual([]); + }); + + it('lists the configured repos for host 0', async () => { + const client = await host.session(); + expect(client.hosts()[0]?.repos).toEqual([repo]); + }); + + it('expands ~/ against the home directory without resolving symbolic links', async () => { + symlinkSync(repo, join(host.home, 'linked')); + host.writeConfig({ port: host.port, repos: ['~/linked'] }); + const client = await host.session(); + expect(client.hosts()[0]?.repos).toEqual([join(host.home, 'linked')]); + }); + + it('sends the configured presets exactly, in their order', async () => { + host.presets = [CLAUDE, SHELL]; + host.writeRepos([repo]); + const client = await host.session(); + expect(presetsOf(client)).toEqual([[CLAUDE, SHELL]]); + }); + + it('gives a new session an edited configuration, leaving an open session unchanged', async () => { + const open = await host.session(); + expect(presetsOf(open)).toEqual([DEFAULT_PRESETS]); + const other = makeRepo(join(host.dir, 'other')); + host.presets = [SHELL, CLAUDE]; + host.writeRepos([repo, other]); + const fresh = await host.session(); + expect(fresh.hosts()[0]?.repos).toEqual([repo, other]); + expect(presetsOf(fresh)).toEqual([[SHELL, CLAUDE]]); + await open.expectNone('hosts', (m) => m.hosts.some((h) => h.repos.includes(other)), 1000); + expect(open.hosts()[0]?.repos).toEqual([repo]); + expect(presetsOf(open)).toEqual([DEFAULT_PRESETS]); + }); + + it.each([ + ['an empty presets list', []], + [ + 'two presets named a', + [ + { name: 'a', command: null }, + { name: 'a', command: 'x' }, + ], + ], + ['a preset whose command is empty', [{ name: 'a', command: '' }]], + ])('gives a new session an internal error and the last valid repos and presets after an edit to %s', async (_name, presets) => { + host.presets = [CLAUDE, SHELL]; + host.writeRepos([repo]); + expect(presetsOf(await host.session())).toEqual([[CLAUDE, SHELL]]); + host.writeConfig({ port: host.port, repos: [join(host.dir, 'x')], presets }); + const client = await host.open(['wtd', `wtd.token.${host.token()}`]); + await client.handshake(); + const error = await client.waitFor('error'); + expect(error).toMatchObject({ req: null, host: null, code: 'internal' }); + expect(error.message).toContain('presets'); + expect(client.hosts()[0]?.repos).toEqual([repo]); + expect(presetsOf(client)).toEqual([[CLAUDE, SHELL]]); + }); +}); + +describe('repo discovery through the hub', () => { + it('discovers repos below the host’s configured roots, at depth 3', async () => { + const one = makeRepo(join(host.home, 'src', 'one')); + const deep = makeRepo(join(host.home, 'src', 'a', 'b', 'deep')); + makeRepo(join(host.home, 'src', 'a', 'b', 'c', 'too-deep')); + makeRepo(join(host.home, 'elsewhere', 'outside')); + host.writeConfig({ port: host.port, repos: [repo], roots: ['~/src'] }); + const client = await host.session(); + await connected(client); + const reply = await client.hubRequest({ t: 'discoverRepos', host: 0 }); + expect(reply).toEqual({ from: 'hub', m: { t: 'reposDiscovered', req: 1, host: 0, repos: [deep, one] } }); + }); + + it('discovers below the home directory by default', async () => { + const mine = makeRepo(join(host.home, 'GitHub', 'mine')); + const client = await host.session(); + await connected(client); + const reply = await client.hubRequest({ t: 'discoverRepos', host: 0 }); + expect(reply.m.t === 'reposDiscovered' ? reply.m.repos : []).toContain(mine); + }); + + it('discovers on a remote host against the remote home', async () => { + const remote = nodeRemote('box'); + await installRemote(host, ssh, 'box'); + const found = makeRepo(join(remote.home, 'code', 'found')); + configure([{ name: 'box', ssh: 'box', roots: ['~/code'] }]); + const client = await host.session(); + await connectedHost(client, 1); + const reply = await client.hubRequest({ t: 'discoverRepos', host: 1 }); + expect(reply).toEqual({ from: 'hub', m: { t: 'reposDiscovered', req: 1, host: 1, repos: [found] } }); + }); + + it('keeps a hub-level request apart from a daemon request with the same id', async () => { + const client = await host.session(); + await connected(client); + const from = client.mark(); + client.sendJson({ t: 'host', host: 0, m: { t: 'watchRepo', req: 7, repo } }); + client.sendJson({ t: 'discoverRepos', req: 7, host: 0 }); + await client.waitFor('reposDiscovered', (m) => m.req === 7, { from }); + await client.waitEvent(0, 'done', (m) => m.req === 7, { from }); + await sleep(1000); + const replies = client.messages.slice(from).filter((m) => { + if (m.t === 'host') return 'req' in m.m && m.m.req === 7; + return 'req' in m && m.req === 7; + }); + expect(replies.map((m) => (m.t === 'host' ? `host:${m.m.t}` : m.t)).sort()).toEqual(['host:done', 'reposDiscovered']); + }); + + it('answers discovery for a host that is not connected as routing does', async () => { + ssh.failHost('box', 'ssh: Could not resolve hostname box'); + configure([{ name: 'box', ssh: 'box' }]); + const client = await host.session(); + await client.waitHost(1, (h) => h.status === 'reconnecting' || h.status === 'down'); + expect(await client.hubRequest({ t: 'discoverRepos', host: 1 })).toMatchObject({ + from: 'hub', + m: { t: 'error', req: 1, host: 1, code: 'host-unavailable' }, + }); + expect(await client.hubRequest({ t: 'discoverRepos', host: 9 })).toMatchObject({ + from: 'hub', + m: { t: 'error', code: 'unknown-host' }, + }); + }); + + it('answers discovery for an outdated host with version-mismatch', async () => { + await FakeDaemon.listen(host.socket, { protocol: 99 }); + const client = await host.session(); + await client.waitHost(0, (h) => h.status === 'outdated'); + expect(await client.hubRequest({ t: 'discoverRepos', host: 0 })).toMatchObject({ m: { t: 'error', code: 'version-mismatch' } }); + }); +}); + +describe('adding repos', () => { + it('adds a repo to config.json, answers done, then lists it in hosts', async () => { + const added = makeRepo(join(host.dir, 'added')); + const client = await host.session(); + await connected(client); + const from = client.mark(); + const reply = await client.hubRequest({ t: 'addRepo', host: 0, repo: added }); + expect(reply).toEqual({ from: 'hub', m: { t: 'done', req: 1 } }); + const listed = await client.waitHost(0, (h) => h.repos.includes(added), { from }); + expect(listed.repos).toEqual([repo, added]); + const doneAt = client.messages.findIndex((m, i) => i >= from && m.t === 'done'); + const hostsAt = client.messages.findIndex((m, i) => i >= from && m.t === 'hosts' && m.hosts[0]?.repos.includes(added) === true); + expect(doneAt).toBeLessThan(hostsAt); + expect(configFile()).toEqual({ port: host.port, repos: [repo, added] }); + }); + + it('refuses a directory that is not a repository’s main worktree with not-a-repo, leaving config.json unchanged', async () => { + const plain = join(host.dir, 'plain'); + mkdirSync(plain); + const before = readFileSync(host.configPath, 'utf8'); + const client = await host.session(); + await connected(client); + expect(await client.hubRequest({ t: 'addRepo', host: 0, repo: plain })).toMatchObject({ + from: 'hub', + m: { t: 'error', req: 1, host: 0, code: 'not-a-repo' }, + }); + expect(await client.hubRequest({ t: 'addRepo', host: 0, repo: wt })).toMatchObject({ m: { t: 'error', code: 'not-a-repo' } }); + expect(readFileSync(host.configPath, 'utf8')).toBe(before); + }); + + it('answers done without an edit for a repo already listed, as given or by a ~/ form', async () => { + symlinkSync(repo, join(host.home, 'linked')); + host.writeConfig({ port: host.port, repos: [repo, '~/linked'] }); + const before = readFileSync(host.configPath, 'utf8'); + const client = await host.session(); + await connected(client); + expect((await client.hubRequest({ t: 'addRepo', host: 0, repo })).m).toEqual({ t: 'done', req: 1 }); + expect((await client.hubRequest({ t: 'addRepo', host: 0, repo: join(host.home, 'linked') })).m).toEqual({ t: 'done', req: 2 }); + expect(readFileSync(host.configPath, 'utf8')).toBe(before); + }); + + it('lists a repo added to a remote host in every open session', async () => { + const { remote, remoteRepo } = await installedBox(); + const second = makeRepo(join(remote.home, 'src', 'second')); + const a = await host.session(); + const b = await host.session(); + await connectedHost(a, 1); + await connectedHost(b, 1); + const fromB = b.mark(); + expect((await a.hubRequest({ t: 'addRepo', host: 1, repo: second })).m).toEqual({ t: 'done', req: 1 }); + expect((await a.waitHost(1, (h) => h.repos.includes(second))).repos).toEqual([remoteRepo, second]); + expect((await b.waitHost(1, (h) => h.repos.includes(second), { from: fromB })).repos).toEqual([remoteRepo, second]); + expect(b.hosts()[0]?.repos).toEqual([repo]); + expect(configFile()).toEqual({ port: host.port, repos: [repo], hosts: [{ name: 'box', ssh: 'box', repos: [remoteRepo, second] }] }); + }); + + it('answers adding to a host that is not connected as routing does', async () => { + ssh.failHost('box', 'ssh: Could not resolve hostname box'); + configure([{ name: 'box', ssh: 'box' }]); + const client = await host.session(); + await client.waitHost(1, (h) => h.status === 'reconnecting' || h.status === 'down'); + expect(await client.hubRequest({ t: 'addRepo', host: 1, repo: '/srv/x' })).toMatchObject({ + m: { t: 'error', host: 1, code: 'host-unavailable' }, + }); + }); +}); + +describe('removing repos', () => { + it('removes a repo listed by a ~/ form, answers done, then drops it from hosts', async () => { + const app = makeRepo(join(host.home, 'app')); + host.writeConfig({ port: host.port, repos: [repo, '~/app'] }); + const client = await host.session(); + await connected(client); + expect(client.hosts()[0]?.repos).toEqual([repo, app]); + const from = client.mark(); + expect((await client.hubRequest({ t: 'removeRepo', host: 0, repo: app })).m).toEqual({ t: 'done', req: 1 }); + expect((await client.waitHost(0, (h) => !h.repos.includes(app), { from })).repos).toEqual([repo]); + expect(configFile()).toEqual({ port: host.port, repos: [repo] }); + }); + + it('refuses a repo with a running terminal with busy, leaving config.json unchanged', async () => { + const client = await watching(); + await client.create(0, wt, { command: 'exec sleep 60' }); + const before = readFileSync(host.configPath, 'utf8'); + expect(await client.hubRequest({ t: 'removeRepo', host: 0, repo })).toMatchObject({ + from: 'hub', + m: { t: 'error', req: 3, host: 0, code: 'busy' }, + }); + expect(readFileSync(host.configPath, 'utf8')).toBe(before); + }); + + it('refuses a repo with an exited terminal not yet closed, and removes it once closed', async () => { + const client = await watching(); + const term = await client.create(0, repo, { command: 'exit 0' }); + await client.waitEvent(0, 'termExited', (m) => m.termId === term.termId, { timeout: 10_000 }); + expect((await client.hubRequest({ t: 'removeRepo', host: 0, repo })).m).toMatchObject({ t: 'error', code: 'busy' }); + await client.ok(0, { t: 'closeTerm', termId: term.termId }); + expect((await client.hubRequest({ t: 'removeRepo', host: 0, repo })).m).toMatchObject({ t: 'done' }); + expect(configFile()).toEqual({ port: host.port, repos: [] }); + }); + + it('removes a listed repo whose directory no longer exists', async () => { + const gone = makeRepo(join(host.dir, 'gone')); + host.writeRepos([repo, gone]); + rmSync(gone, { recursive: true, force: true }); + const client = await host.session(); + await connected(client); + expect((await client.hubRequest({ t: 'removeRepo', host: 0, repo: gone })).m).toEqual({ t: 'done', req: 1 }); + expect(configFile()).toEqual({ port: host.port, repos: [repo] }); + }); + + it('answers done without an edit for a repo it does not list', async () => { + const before = readFileSync(host.configPath, 'utf8'); + const client = await host.session(); + await connected(client); + expect((await client.hubRequest({ t: 'removeRepo', host: 0, repo: join(host.dir, 'never') })).m).toEqual({ t: 'done', req: 1 }); + expect(readFileSync(host.configPath, 'utf8')).toBe(before); + }); +}); + +describe('editing presets', () => { + const HTOP = { name: 'htop', command: 'htop' }; + + it('adds a preset to config.json, answers done, then sends the presets to every open session', async () => { + host.presets = [SHELL]; + host.writeRepos([repo]); + const a = await host.session(); + const b = await host.session(); + const [fromA, fromB] = [a.mark(), b.mark()]; + expect(await a.hubRequest({ t: 'addPreset', preset: HTOP })).toEqual({ from: 'hub', m: { t: 'done', req: 1 } }); + for (const [client, from] of [ + [a, fromA], + [b, fromB], + ] as const) { + expect((await client.waitFor('presets', () => true, { from })).presets).toEqual([SHELL, HTOP]); + } + const doneAt = a.messages.findIndex((m, i) => i >= fromA && m.t === 'done'); + const presetsAt = a.messages.findIndex((m, i) => i >= fromA && m.t === 'presets'); + expect(doneAt).toBeLessThan(presetsAt); + expect(configFile()).toEqual({ port: host.port, repos: [repo], presets: [SHELL, HTOP] }); + }); + + it('removes a preset, writing the defaults without it when config.json had none', async () => { + host.writeConfig({ port: host.port, repos: [repo] }); + const client = await host.session(); + const from = client.mark(); + expect((await client.hubRequest({ t: 'removePreset', name: 'claude' })).m).toEqual({ t: 'done', req: 1 }); + const expected = [SHELL, { name: 'codex', command: 'codex' }]; + expect((await client.waitFor('presets', () => true, { from })).presets).toEqual(expected); + expect(configFile()).toEqual({ port: host.port, repos: [repo], presets: expected }); + }); + + it('moves a preset in config.json, answers done, then sends the new order to every open session', async () => { + host.presets = [SHELL, CLAUDE, HTOP]; + host.writeRepos([repo]); + const a = await host.session(); + const b = await host.session(); + const fromB = b.mark(); + expect((await a.hubRequest({ t: 'movePreset', name: 'htop', to: 0 })).m).toEqual({ t: 'done', req: 1 }); + expect((await b.waitFor('presets', () => true, { from: fromB })).presets).toEqual([HTOP, SHELL, CLAUDE]); + expect(configFile()).toEqual({ port: host.port, repos: [repo], presets: [HTOP, SHELL, CLAUDE] }); + }); + + it('answers done without sending presets for a name it does not list', async () => { + host.presets = [SHELL]; + host.writeRepos([repo]); + const before = readFileSync(host.configPath, 'utf8'); + const client = await host.session(); + expect((await client.hubRequest({ t: 'removePreset', name: 'nope' })).m).toEqual({ t: 'done', req: 1 }); + await client.expectNone('presets', () => true, 300); + expect(readFileSync(host.configPath, 'utf8')).toBe(before); + }); + + it.each([ + ['adding a taken name', { t: 'addPreset', preset: { name: 'shell', command: 'zsh' } }, 'a preset named shell exists'], + ['removing the last preset', { t: 'removePreset', name: 'shell' }, 'the last preset cannot be removed'], + ] as const)('refuses %s with internal, leaving config.json unchanged', async (_name, body, text) => { + host.presets = [SHELL]; + host.writeRepos([repo]); + const before = readFileSync(host.configPath, 'utf8'); + const client = await host.session(); + const reply = await client.hubRequest(body); + expect(reply).toMatchObject({ from: 'hub', m: { t: 'error', req: 1, host: null, code: 'internal' } }); + expect(reply.m.t === 'error' ? reply.m.message : '').toContain(text); + expect(readFileSync(host.configPath, 'utf8')).toBe(before); + }); +}); + +describe('configuration edits', () => { + it('refuses to edit an invalid config.json, naming the problem and leaving it unchanged', async () => { + const added = makeRepo(join(host.dir, 'added')); + const client = await host.session(); + await connected(client); + writeFileSync(host.configPath, JSON.stringify({ port: host.port, theme: 'dark' })); + const reply = await client.hubRequest({ t: 'addRepo', host: 0, repo: added }); + expect(reply).toMatchObject({ from: 'hub', m: { t: 'error', req: 1, code: 'internal' } }); + expect(reply.m.t === 'error' ? reply.m.message : '').toContain('theme'); + expect(configFile()).toEqual({ port: host.port, theme: 'dark' }); + }); + + it('refuses to edit a host the file no longer lists, leaving it unchanged', async () => { + const { remote } = await installedBox(); + const other = makeRepo(join(remote.home, 'src', 'other')); + const client = await host.session(); + await connectedHost(client, 1); + host.writeConfig({ port: host.port, repos: [repo] }); + const before = readFileSync(host.configPath, 'utf8'); + expect((await client.hubRequest({ t: 'addRepo', host: 1, repo: other })).m).toMatchObject({ t: 'error', code: 'internal' }); + expect(readFileSync(host.configPath, 'utf8')).toBe(before); + }); + + it('creates config.json owner-only to record a repo when there was none', async () => { + rmSync(host.configPath); + const added = makeRepo(join(host.dir, 'added')); + const client = await host.session(); + await connected(client); + expect((await client.hubRequest({ t: 'addRepo', host: 0, repo: added })).m).toEqual({ t: 'done', req: 1 }); + expect(configFile()).toEqual({ repos: [added] }); + }); + + it('changes nothing else in an open session’s snapshot', async () => { + const added = makeRepo(join(host.dir, 'added')); + const client = await host.session(); + await connected(client); + host.presets = [CLAUDE, SHELL]; + host.writeRepos([repo]); + const from = client.mark(); + expect((await client.hubRequest({ t: 'addRepo', host: 0, repo: added })).m).toEqual({ t: 'done', req: 1 }); + await client.waitHost(0, (h) => h.repos.includes(added), { from }); + expect(presetsOf(client)).toEqual([DEFAULT_PRESETS]); + }); +}); diff --git a/test/process/hub-router-links.test.ts b/test/process/hub-router-links.test.ts new file mode 100644 index 0000000..cd56af2 --- /dev/null +++ b/test/process/hub-router-links.test.ts @@ -0,0 +1,200 @@ +import { hostname } from 'node:os'; +import { describe, expect, it, vi } from 'vitest'; +import { DAEMON_PROTOCOL_VERSION } from '../../src/protocol/index.js'; +import { alive, sleep, waitUntil } from '../support/daemon-host.js'; +import { FakeDaemon } from '../support/fake-daemon.js'; +import { + ANY_INSTANCE, + connected, + host, + hub, + instanceOf, + packageVersion, + repo, + started, + useHubRouterHost, + watching, +} from '../support/hub-router.js'; + +vi.setConfig({ testTimeout: 60_000, hookTimeout: 30_000 }); +useHubRouterHost(); + +describe('daemon links and host status', () => { + it('starts the daemon and reports host 0 connected with its instance', async () => { + expect(host.daemonPids()).toEqual([]); + const client = await host.session(); + const entry = await connected(client); + expect(entry).toEqual({ + idx: 0, + name: hostname().slice(0, 64) || 'local', + remote: false, + status: 'connected', + reason: null, + daemonVersion: packageVersion(), + instance: ANY_INSTANCE, + login: null, + repos: [repo], + }); + expect(host.daemonPids()).toHaveLength(1); + const daemon = await host.client(); + expect((await daemon.handshake()).instance).toBe(entry.instance); + }); + + it('reports reconnecting, then connected with a new instance, when the daemon is killed', async () => { + const client = await host.session(); + const first = await instanceOf(client); + const [pid] = host.daemonPids(); + if (pid === undefined) throw new Error('no daemon'); + const from = client.mark(); + process.kill(pid, 'SIGKILL'); + expect(await client.waitHost(0, (h) => h.status === 'reconnecting', { from })).toMatchObject({ instance: null }); + const second = await instanceOf(client, from); + expect(second).not.toBe(first); + }); + + it.each([ + ['an older', 3], + ['a newer', 99], + ])('reports a daemon of %s protocol as outdated with its version and instance, keeping the link', async (_name, protocol) => { + const fake = await FakeDaemon.listen(host.socket, { protocol, version: '9.9.9', instance: 'old_D-1' }); + const client = await host.session(); + expect(await client.waitHost(0, (h) => h.status === 'outdated')).toMatchObject({ + daemonVersion: '9.9.9', + instance: 'old_D-1', + reason: null, + }); + await sleep(1000); + expect(fake.connections).toBe(1); + expect(client.hosts()[0]?.status).toBe('outdated'); + }); + + it('reports reconnecting, then down after 3 failures, and keeps retrying', async () => { + const fake = await FakeDaemon.listen(host.socket, { protocol: DAEMON_PROTOCOL_VERSION, accept: 'close' }); + const client = await host.session(); + await client.waitHost(0, (h) => h.status === 'reconnecting'); + await client.waitHost(0, (h) => h.status === 'down', { timeout: 10_000 }); + expect(fake.connections).toBeGreaterThanOrEqual(3); + const atDown = fake.connections; + await waitUntil(() => fake.connections >= atDown + 2, 'retries while down', 15_000); + expect(client.hosts()[0]?.status).toBe('down'); + }); +}); + +describe('daemon restart', () => { + it.each([ + ['an older', 3], + ['a newer', 99], + ])('restarts an outdated daemon of %s protocol into one speaking the hub’s protocol', async (_name, protocol) => { + const fake = await FakeDaemon.listen(host.socket, { protocol }); + const client = await host.session(); + await client.waitHost(0, (h) => h.status === 'outdated'); + const from = client.mark(); + const reply = await client.hubRequest({ t: 'restartDaemon', host: 0 }, 15_000); + expect(reply).toEqual({ from: 'hub', m: { t: 'done', req: 1 } }); + expect(fake.shutdowns).toBe(1); + await fake.closed; + expect(await instanceOf(client, from)).not.toBe('fake_instance'); + // A daemon the session's reconnect started alongside the restart refuses to start and exits. + await waitUntil(() => host.daemonPids().length === 1, 'one daemon'); + const direct = await host.client(); + expect((await direct.handshake()).protocol).toBe(DAEMON_PROTOCOL_VERSION); + }); + + it('restarts a newer daemon whose messages after hello are not valid for the hub’s protocol', async () => { + const fake = await FakeDaemon.listen(host.socket, { + protocol: DAEMON_PROTOCOL_VERSION + 1, + instance: 'newer_1', + afterHello: ['{"t":"frobnicated","widgets":[1,2,3]}', '{"t":"repoState","repo":7}'], + }); + const client = await host.session(); + await client.waitHost(0, (h) => h.status === 'outdated'); + const from = client.mark(); + const reply = await client.hubRequest({ t: 'restartDaemon', host: 0 }, 15_000); + expect(reply).toEqual({ from: 'hub', m: { t: 'done', req: 1 } }); + expect(fake.shutdowns).toBe(1); + await fake.closed; + const entry = await client.waitHost(0, (h) => h.status === 'connected', { from, timeout: 10_000 }); + expect(entry.instance).not.toBe('newer_1'); + expect(entry.daemonVersion).toBe(packageVersion()); + }); + + it('shares one restart between concurrent requests from two sessions', async () => { + const a = await host.session(); + const b = await host.session(); + const old = await instanceOf(a); + await connected(b); + const oldPid = await waitUntil(() => { + const pids = host.daemonPids(); + return pids.length === 1 ? pids[0] : undefined; + }, 'one daemon'); + const fromA = a.mark(); + const fromB = b.mark(); + const replies = await Promise.all([ + a.hubRequest({ t: 'restartDaemon', host: 0 }, 15_000), + b.hubRequest({ t: 'restartDaemon', host: 0 }, 15_000), + ]); + expect(replies.map((r) => r.m.t)).toEqual(['done', 'done']); + // `done` follows the old daemon's socket going quiet; its process may still be exiting. + await waitUntil(() => !alive(oldPid), 'the old daemon to exit'); + const newA = await instanceOf(a, fromA); + const newB = await instanceOf(b, fromB); + expect(newA).not.toBe(old); + expect(newB).toBe(newA); + // Both sessions reached the new daemon, so every reconnect has dialled; daemons they started alongside it exit. + await waitUntil(() => host.daemonPids().length === 1, 'one daemon'); + await sleep(500); + expect(host.daemonPids()).toHaveLength(1); + }); + + it('answers internal when the daemon does not go away within 10 s', async () => { + const fake = await FakeDaemon.listen(host.socket, { protocol: 99, onShutdown: 'ignore' }); + const client = await host.session(); + await client.waitHost(0, (h) => h.status === 'outdated'); + const sentAt = Date.now(); + const reply = await client.hubRequest({ t: 'restartDaemon', host: 0 }, 20_000); + expect(reply).toMatchObject({ from: 'hub', m: { t: 'error', req: 1, code: 'internal' } }); + expect(Date.now() - sentAt).toBeGreaterThanOrEqual(9500); + expect(fake.shutdowns).toBe(1); + }); + + it('answers host-unavailable for a host that is not connected or outdated', async () => { + const fake = await FakeDaemon.listen(host.socket, { protocol: DAEMON_PROTOCOL_VERSION, accept: 'close' }); + const client = await host.session(); + await client.waitHost(0, (h) => h.status === 'reconnecting' || h.status === 'down'); + const reply = await client.hubRequest({ t: 'restartDaemon', host: 0 }); + expect(reply).toMatchObject({ from: 'hub', m: { t: 'error', req: 1, host: 0, code: 'host-unavailable' } }); + expect(fake.shutdowns).toBe(0); + }); +}); + +describe('session end and hub shutdown', () => { + it('keeps terminals running when a session closes', async () => { + const first = await watching(); + const term = await started(first, null); + const [daemonPid] = host.daemonPids(); + first.close(); + await sleep(500); + const second = await watching(); + const state = second.daemonEvents(0, 'repoState').at(-1); + expect(state?.terminals.find((t) => t.termId === term.termId)?.exit).toBeNull(); + expect(host.daemonPids()).toEqual([daemonPid]); + }); + + it('closes sessions with 1001 on SIGTERM and exits 0, leaving the daemon running', async () => { + const client = await watching(); + const [daemonPid] = host.daemonPids(); + hub.kill('SIGTERM'); + await client.waitClosed(); + expect(client.closeCode).toBe(1001); + expect(await hub.exited).toEqual({ code: 0, signal: null }); + expect(alive(daemonPid ?? 0)).toBe(true); + }); + + it('closes sessions with 1001 on an authenticated shutdown request', async () => { + const client = await host.session(); + await host.api('POST', '/api/shutdown'); + await client.waitClosed(); + expect(client.closeCode).toBe(1001); + expect(await hub.exited).toEqual({ code: 0, signal: null }); + }); +}); diff --git a/test/process/hub-router-reinstall.test.ts b/test/process/hub-router-reinstall.test.ts new file mode 100644 index 0000000..dc92b7a --- /dev/null +++ b/test/process/hub-router-reinstall.test.ts @@ -0,0 +1,138 @@ +import { existsSync } from 'node:fs'; +import { join } from 'node:path'; +import { describe, expect, it, vi } from 'vitest'; +import { alive, sleep, waitUntil } from '../support/daemon-host.js'; +import { FakeDaemon } from '../support/fake-daemon.js'; +import { + configure, + connectedHost, + host, + INSTALL_TIMEOUT, + installedBox, + instanceOf, + nodeRemote, + packageVersion, + REMOTE_TIMEOUT, + remoteInstance, + ssh, + useHubRouterHost, +} from '../support/hub-router.js'; + +vi.setConfig({ testTimeout: 60_000, hookTimeout: 30_000 }); +useHubRouterHost(); + +describe('remote daemon restart and reinstall', () => { + it('restarts a connected remote daemon into a new instance', async () => { + const { remote } = await installedBox(); + const client = await host.session(); + const old = await remoteInstance(client, 1); + const [oldPid] = remote.daemonPids(); + const from = client.mark(); + const reply = await client.hubRequest({ t: 'restartDaemon', host: 1 }, 20_000); + expect(reply).toEqual({ from: 'hub', m: { t: 'done', req: 1 } }); + expect(await remoteInstance(client, 1, from)).not.toBe(old); + await waitUntil(() => oldPid !== undefined && !alive(oldPid), 'the old remote daemon to exit'); + await waitUntil(() => remote.daemonPids().length === 1, 'one remote daemon'); + }); + + it( + 'reinstalls an outdated remote: installs the hub’s bundle, shuts the old daemon down and connects to the new one', + async () => { + const { remote } = await installedBox(); + const releases = remote.releases(); + const fake = await FakeDaemon.listen(remote.socket, { protocol: 4, version: '0.0.4', instance: 'old_remote' }); + const client = await host.session(); + expect(await client.waitHost(1, (h) => h.status === 'outdated', { timeout: REMOTE_TIMEOUT })).toMatchObject({ + daemonVersion: '0.0.4', + instance: 'old_remote', + }); + const from = client.mark(); + const reply = await client.hubRequest({ t: 'reinstallDaemon', host: 1 }, INSTALL_TIMEOUT); + expect(reply).toEqual({ from: 'hub', m: { t: 'done', req: 1 } }); + expect(fake.shutdowns).toBeGreaterThanOrEqual(1); + const entry = await connectedHost(client, 1, from); + expect(entry).toMatchObject({ daemonVersion: packageVersion() }); + expect(remote.releases()).toHaveLength(2); + expect(remote.releases()).toEqual(expect.arrayContaining(releases)); + expect(remote.current()).not.toBe(join('versions', releases[0] ?? '')); + }, + INSTALL_TIMEOUT + 30_000, + ); + + it( + 'installs wtd on a down remote that never had it and connects', + async () => { + const remote = nodeRemote('box'); + configure([{ name: 'box', ssh: 'box' }]); + const client = await host.session(); + await client.waitHost(1, (h) => h.status === 'down', { timeout: 10_000 }); + const from = client.mark(); + const reply = await client.hubRequest({ t: 'reinstallDaemon', host: 1 }, INSTALL_TIMEOUT); + expect(reply).toEqual({ from: 'hub', m: { t: 'done', req: 1 } }); + expect(await connectedHost(client, 1, from)).toMatchObject({ daemonVersion: packageVersion() }); + expect(remote.releases()).toHaveLength(1); + expect(existsSync(remote.shim)).toBe(true); + }, + INSTALL_TIMEOUT + 30_000, + ); + + it( + 'reports an installation failure as internal, naming the install step and the missing Node', + async () => { + ssh.addHost('box'); + configure([{ name: 'box', ssh: 'box' }]); + const client = await host.session(); + await client.waitHost(1, (h) => h.status === 'down', { timeout: 10_000 }); + const reply = await client.hubRequest({ t: 'reinstallDaemon', host: 1 }, INSTALL_TIMEOUT); + expect(reply).toMatchObject({ from: 'hub', m: { t: 'error', req: 1, host: 1, code: 'internal' } }); + const message = reply.m.t === 'error' ? reply.m.message : ''; + expect(message).toMatch(/^install:/); + expect(message).toMatch(/node/i); + }, + INSTALL_TIMEOUT + 30_000, + ); + + it( + 'lets a restart requested during a reinstall share it', + async () => { + const { remote } = await installedBox(); + await FakeDaemon.listen(remote.socket, { protocol: 4, version: '0.0.4', instance: 'old_remote' }); + const a = await host.session(); + const b = await host.session(); + await a.waitHost(1, (h) => h.status === 'outdated', { timeout: REMOTE_TIMEOUT }); + await b.waitHost(1, (h) => h.status === 'outdated', { timeout: REMOTE_TIMEOUT }); + const fromA = a.mark(); + const fromB = b.mark(); + const reinstall = a.hubRequest({ t: 'reinstallDaemon', host: 1 }, INSTALL_TIMEOUT); + await sleep(200); + const restart = b.hubRequest({ t: 'restartDaemon', host: 1 }, INSTALL_TIMEOUT); + const [ra, rb] = await Promise.all([reinstall, restart]); + expect(ra.m.t).toBe('done'); + expect(rb.m.t).toBe('done'); + const instanceA = await remoteInstance(a, 1, fromA); + expect(await remoteInstance(b, 1, fromB)).toBe(instanceA); + await waitUntil(() => remote.daemonPids().length === 1, 'one remote daemon'); + await sleep(500); + expect(remote.daemonPids()).toHaveLength(1); + expect(remote.releases()).toHaveLength(2); + }, + INSTALL_TIMEOUT + 30_000, + ); + + it('refuses to reinstall the local host, touching no daemon', async () => { + const client = await host.session(); + const instance = await instanceOf(client); + const [pid] = host.daemonPids(); + const reply = await client.hubRequest({ t: 'reinstallDaemon', host: 0 }); + expect(reply).toMatchObject({ from: 'hub', m: { t: 'error', req: 1, host: 0, code: 'internal' } }); + await sleep(500); + expect(host.daemonPids()).toEqual([pid]); + expect(client.hosts()[0]).toMatchObject({ status: 'connected', instance }); + }); + + it('answers a reinstall for an unknown host with unknown-host', async () => { + const client = await host.session(); + const reply = await client.hubRequest({ t: 'reinstallDaemon', host: 7 }); + expect(reply).toMatchObject({ from: 'hub', m: { t: 'error', req: 1, code: 'unknown-host' } }); + }); +}); diff --git a/test/process/hub-router-remote.test.ts b/test/process/hub-router-remote.test.ts new file mode 100644 index 0000000..c8cd9fa --- /dev/null +++ b/test/process/hub-router-remote.test.ts @@ -0,0 +1,274 @@ +import { hostname } from 'node:os'; +import { describe, expect, it, vi } from 'vitest'; +import { alive, residentBytes, waitUntil } from '../support/daemon-host.js'; +import { loginRun } from '../support/fake-ssh.js'; +import type { HostEntry, HubClient } from '../support/hub-client.js'; +import { + ANY_INSTANCE, + configure, + connectedHost, + host, + hub, + installedBox, + MiB, + packageVersion, + REMOTE_TIMEOUT, + remoteInstance, + repo, + ssh, + useHubRouterHost, +} from '../support/hub-router.js'; + +vi.setConfig({ testTimeout: 60_000, hookTimeout: 30_000 }); +useHubRouterHost(); + +const LOGIN_URL: unknown = expect.stringMatching(/^https:\/\/login\.tailscale\.com\/a\/wtd\d+$/); + +/** Host `idx`'s entries in every `hosts` message from `from` on. */ +const entriesOf = (client: HubClient, idx: number, from = 0): HostEntry[] => + client.messages.slice(from).flatMap((m) => (m.t === 'hosts' ? m.hosts.filter((h) => h.idx === idx) : [])); + +/** Host `idx`'s statuses from `from` on, without consecutive repeats. */ +const statusesOf = (client: HubClient, idx: number, from = 0): string[] => + entriesOf(client, idx, from) + .map((h) => h.status) + .filter((status, i, all) => i === 0 || all[i - 1] !== status); + +/** The SSH runs of `box` that ran the connect command. */ +const connectRuns = (): number[] => + ssh + .calls() + .filter((call) => call.alias === 'box' && call.command.includes('connect')) + .map((call) => call.pid); + +/** Host 1's entry once it needs login, and the login URL. */ +const needsLogin = async (client: HubClient, from = 0): Promise<{ entry: HostEntry; url: string }> => { + const entry = await client.waitHost(1, (h) => h.status === 'needs-login', { from, timeout: REMOTE_TIMEOUT }); + if (entry.login === null) throw new Error('needs-login without a login URL'); + return { entry, url: entry.login }; +}; + +describe('remote hosts', () => { + it('lists configured remote hosts after the local host, in configuration order', async () => { + ssh.failHost('b-alias', 'ssh: Could not resolve hostname b-alias'); + ssh.failHost('a-alias', 'ssh: Could not resolve hostname a-alias'); + configure([ + { name: 'b', ssh: 'b-alias' }, + { name: 'a', ssh: 'a-alias', repos: ['/srv/a'] }, + ]); + const client = await host.session(); + expect(client.hosts().map((h) => [h.idx, h.name, h.remote, h.repos])).toEqual([ + [0, hostname().slice(0, 64) || 'local', false, [repo]], + [1, 'b', true, []], + [2, 'a', true, ['/srv/a']], + ]); + }); + + it('connects a remote host through its SSH command, reporting its name, index and the remote daemon’s instance', async () => { + const { remote, remoteRepo } = await installedBox(); + const client = await host.session(); + const entry = await connectedHost(client, 1); + expect(entry).toEqual({ + idx: 1, + name: 'box', + remote: true, + status: 'connected', + reason: null, + daemonVersion: packageVersion(), + instance: ANY_INSTANCE, + login: null, + repos: [remoteRepo], + }); + expect(remote.daemonPids()).toHaveLength(1); + expect((await remote.hello()).instance).toBe(entry.instance); + const link = ssh.calls().find((call) => call.alias === 'box' && call.command.includes('connect')); + expect(link?.command).toBe('"$HOME/.local/bin/wtd" connect'); + expect(link?.args.slice(0, 12)).toEqual([ + '-o', + 'BatchMode=yes', + '-o', + 'ConnectTimeout=10', + '-o', + 'ControlMaster=auto', + '-o', + 'ControlPersist=10m', + '-o', + `ControlPath=${host.runDir}/ssh-%C`, + '-o', + 'ServerAliveInterval=15', + ]); + }); + + it('round-trips requests, terminals and output through a remote host', async () => { + const { remoteRepo } = await installedBox(); + const client = await host.session(); + await connectedHost(client, 1); + const state = await client.watch(1, remoteRepo); + expect(state.worktrees.map((w) => w.path)).toEqual([remoteRepo]); + const term = await client.create(1, remoteRepo); + await client.attach(1, term.termId); + client.sendInput(1, term.termId, 'echo remote-$((40+2))-trip\r'); + await client.waitOutput(1, term.termId, 'remote-42-trip', 10_000); + expect(client.daemonEvents(0, 'termCreated')).toEqual([]); + }); + + it('reports reconnecting, then connected to the same daemon, when the SSH process is killed', async () => { + await installedBox(); + const client = await host.session(); + const first = await remoteInstance(client, 1); + const pids = await waitUntil(() => { + const live = ssh.livePids('box'); + return live.length > 0 ? live : undefined; + }, 'the hub’s SSH process'); + const from = client.mark(); + for (const pid of pids) process.kill(pid, 'SIGKILL'); + expect(await client.waitHost(1, (h) => h.status === 'reconnecting', { from })).toMatchObject({ instance: null }); + expect(await remoteInstance(client, 1, from)).toBe(first); + }); + + it('reports a host whose SSH command keeps failing as down, naming SSH’s last error line', async () => { + ssh.failHost('box', 'ssh: Could not resolve hostname box'); + configure([{ name: 'box', ssh: 'box' }]); + const client = await host.session(); + expect(await client.waitHost(1, (h) => h.status === 'reconnecting')).toMatchObject({ + reason: 'ssh: Could not resolve hostname box', + instance: null, + }); + expect(await client.waitHost(1, (h) => h.status === 'down', { timeout: 10_000 })).toMatchObject({ + reason: 'ssh: Could not resolve hostname box', + daemonVersion: null, + instance: null, + }); + expect(client.hosts()[0]?.status).toBe('connected'); + }); + + it('reports a remote without wtd as down, with a reason', async () => { + ssh.addHost('box'); + configure([{ name: 'box', ssh: 'box' }]); + const client = await host.session(); + const entry = await client.waitHost(1, (h) => h.status === 'down', { timeout: 10_000 }); + expect(entry.reason).toEqual(expect.stringMatching(/\S/)); + }); + + it('keeps its memory bounded and the reason short when SSH writes 8 MiB to standard error without a newline', async () => { + const before = residentBytes(hub.pid); + ssh.spewHost('box', 8 * MiB); + configure([{ name: 'box', ssh: 'box' }]); + const client = await host.session(); + const entry = await client.waitHost(1, (h) => h.status === 'down', { timeout: 30_000 }); + expect(entry.reason?.length ?? 0).toBeGreaterThan(0); + expect(entry.reason?.length ?? 0).toBeLessThanOrEqual(1024); + expect(residentBytes(hub.pid) - before).toBeLessThan(16 * MiB); + }); + + it('ends an SSH process that writes over 1 MiB to standard error instead of leaving its link stalled', async () => { + await installedBox(); + ssh.noisyHost('box', 2 * MiB); + const client = await host.session(); + const entry = await client.waitHost(1, (h) => h.status === 'reconnecting' || h.status === 'down', { timeout: REMOTE_TIMEOUT }); + expect(entry.reason).toMatch(/^x{1,1024}$/); + }); + + it('answers requests to a down remote host with host-unavailable', async () => { + ssh.failHost('box', 'ssh: Could not resolve hostname box'); + configure([{ name: 'box', ssh: 'box' }]); + const client = await host.session(); + await client.waitHost(1, (h) => h.status === 'reconnecting' || h.status === 'down'); + const reply = await client.request(1, { t: 'watchRepo', repo: '/srv/app' }); + expect(reply).toMatchObject({ from: 'hub', m: { t: 'error', req: 1, host: 1, code: 'host-unavailable' } }); + }); + + it('kills SSH processes of a closed session that ignore SIGTERM', async () => { + ssh.addHost('box'); + ssh.hangHost('box'); + configure([{ name: 'box', ssh: 'box' }]); + const client = await host.session(); + const pids = await waitUntil(() => { + const live = ssh.livePids('box'); + return live.length > 0 && live; + }, 'an SSH process for box'); + client.close(); + await waitUntil(() => pids.every((pid) => !alive(pid)), 'the session’s SSH processes to be killed', 5000); + expect(pids.filter(alive)).toEqual([]); + }); + + it('ends the SSH processes of a closed session', async () => { + await installedBox(); + const client = await host.session(); + await connectedHost(client, 1); + const pids = ssh.livePids('box'); + expect(pids.length).toBeGreaterThan(0); + client.close(); + await waitUntil(() => pids.every((pid) => !alive(pid)), 'the session’s SSH processes to end', 5000); + }); +}); + +describe('Tailscale SSH check login', () => { + it('reports needs-login with the URL within 1 s of the banner, then connects over the same SSH process once approved', async () => { + await installedBox(); + ssh.loginHost('box'); + const before = connectRuns().length; + const client = await host.session(); + await waitUntil(() => connectRuns().length > before, 'the hub’s SSH process for box'); + const entry = await client.waitHost(1, (h) => h.status === 'needs-login', { timeout: 5000 }); + const receivedAt = Date.now(); + expect(entry).toMatchObject({ status: 'needs-login', login: LOGIN_URL, reason: null, daemonVersion: null, instance: null }); + const url = entry.login ?? ''; + expect(receivedAt - ssh.loginWrittenAt(url)).toBeLessThanOrEqual(1000); + expect(connectRuns()).toHaveLength(before + 1); + ssh.approveLogin(url); + expect(await connectedHost(client, 1)).toMatchObject({ login: null, reason: null }); + expect(connectRuns()).toHaveLength(before + 1); + expect(alive(loginRun(url))).toBe(true); + expect(statusesOf(client, 1)).toEqual(['connecting', 'needs-login', 'connected']); + }); + + it('reports reconnecting with SSH’s reason and a null login when the check expires, then the next run’s URL', async () => { + await installedBox(); + ssh.loginHost('box'); + const client = await host.session(); + const { url: first } = await needsLogin(client); + const from = client.mark(); + ssh.expireLogin(first, 'Tailscale SSH check timed out'); + expect(await client.waitHost(1, (h) => h.status === 'reconnecting', { from })).toMatchObject({ + reason: 'Tailscale SSH check timed out', + login: null, + }); + const { url: second } = await needsLogin(client, from); + expect(second).not.toBe(first); + }); + + it('answers a request to a needs-login host with host-unavailable', async () => { + await installedBox(); + ssh.loginHost('box'); + const client = await host.session(); + await needsLogin(client); + const reply = await client.request(1, { t: 'watchRepo', repo: '/srv/app' }); + expect(reply).toMatchObject({ from: 'hub', m: { t: 'error', req: 1, host: 1, code: 'host-unavailable' } }); + }); + + it('redials every other needs-login session once one session’s link greets, leaving connected sessions alone', async () => { + await installedBox(); + const c = await host.session(); + const cEntry = await connectedHost(c, 1); + ssh.loginHost('box'); + const a = await host.session(); + const { url: aUrl } = await needsLogin(a); + const b = await host.session(); + const { url: bUrl } = await needsLogin(b); + const bRun = loginRun(bUrl); + expect(bRun).not.toBe(loginRun(aUrl)); + const runsBefore = connectRuns().length; + const fromB = b.mark(); + const fromC = c.mark(); + ssh.skipLogin('box'); + ssh.approveLogin(aUrl); + await connectedHost(a, 1); + expect(await connectedHost(b, 1, fromB)).toMatchObject({ login: null }); + expect(statusesOf(b, 1, fromB)).toEqual(['connecting', 'connected']); + expect(entriesOf(b, 1, fromB)[0]).toMatchObject({ status: 'connecting', login: null }); + await waitUntil(() => !alive(bRun), 'B’s original SSH process to end', 5000); + expect(connectRuns().length).toBeGreaterThan(runsBefore); + for (const entry of entriesOf(c, 1, fromC)) expect(entry).toEqual(cEntry); + }); +}); diff --git a/test/process/hub-router-routing.test.ts b/test/process/hub-router-routing.test.ts new file mode 100644 index 0000000..94622d0 --- /dev/null +++ b/test/process/hub-router-routing.test.ts @@ -0,0 +1,176 @@ +import { existsSync } from 'node:fs'; +import { join } from 'node:path'; +import { describe, expect, it, vi } from 'vitest'; +import { FLOW_HIGH, FrameKind, DAEMON_PROTOCOL_VERSION, type Terminal } from '../../src/protocol/index.js'; +import { alive, DaemonHost, residentBytes, sleep, waitUntil } from '../support/daemon-host.js'; +import { FakeDaemon, SocketProxy } from '../support/fake-daemon.js'; +import { ANY_TEXT, connected, host, hub, MiB, repo, started, useHubRouterHost, watching, wt } from '../support/hub-router.js'; + +vi.setConfig({ testTimeout: 60_000, hookTimeout: 30_000 }); +useHubRouterHost(); + +describe('routing', () => { + it('relays daemon messages and data byte-identically inside their envelopes', async () => { + const backend = DaemonHost.create(); + try { + await backend.start(); + const proxy = await SocketProxy.listen(host.socket, backend.socket); + const client = await host.session(); + await connected(client); + await client.watch(0, repo); + const term = await started(client, null); + client.sendInput(0, term.termId, 'echo round-$((40+2))-trip\r'); + await client.waitOutput(0, term.termId, 'round-42-trip'); + await sleep(500); + const daemonTexts = proxy.controlTexts().filter((text) => !text.includes('"t":"hello"')); + expect(daemonTexts.some((text) => text.includes('"t":"repoState"'))).toBe(true); + expect(daemonTexts.some((text) => text.includes('"t":"termCreated"'))).toBe(true); + for (const text of daemonTexts) + expect( + client.texts.some((envelope) => envelope.includes(text)), + text, + ).toBe(true); + const daemonData = proxy.fromDaemon.filter((f) => f.kind !== FrameKind.control); + expect(daemonData.some((f) => f.kind === FrameKind.snapshot)).toBe(true); + expect(client.data.map((d) => Buffer.from(d.raw.subarray(0, 3)))).toEqual(daemonData.map((f) => Buffer.from([f.kind, 0, 0]))); + expect(client.data.map((d) => Buffer.from(d.raw.subarray(3)))).toEqual(daemonData.map((f) => Buffer.from(f.payload))); + await proxy.close(); + } finally { + await backend.cleanup(); + } + }); + + it('forwards requests without rewriting their ids, and acks only those the browser sends', async () => { + const backend = DaemonHost.create(); + try { + await backend.start(); + const proxy = await SocketProxy.listen(host.socket, backend.socket); + const client = await host.session(); + await connected(client); + client.autoAck = false; + await client.watch(0, repo); + const term = await started(client, 'seq 1 50000; echo SEQ-DONE; exec sleep 60'); + await client.waitOutput(0, term.termId, 'SEQ-DONE', 10_000); + await sleep(500); + const sent = (): unknown[] => proxy.controlTextsToDaemon().map((text): unknown => JSON.parse(text)); + expect(sent()).toContainEqual({ t: 'watchRepo', req: 1, repo }); + expect(sent().filter((m) => typeof m === 'object' && m !== null && 't' in m && m.t === 'ack')).toEqual([]); + const position = client.view(0, term.termId).position; + client.sendHost(0, { t: 'ack', termId: term.termId, offset: position }); + await waitUntil( + () => sent().some((m) => JSON.stringify(m) === JSON.stringify({ t: 'ack', termId: term.termId, offset: position })), + 'the ack', + ); + await proxy.close(); + } finally { + await backend.cleanup(); + } + }); + + it('answers a request to a reconnecting host with host-unavailable and drops its fire-and-forget messages', async () => { + await FakeDaemon.listen(host.socket, { protocol: DAEMON_PROTOCOL_VERSION, accept: 'close' }); + const client = await host.session(); + await client.waitHost(0, (h) => h.status === 'reconnecting' || h.status === 'down'); + const reply = await client.request(0, { t: 'watchRepo', repo }); + expect(reply).toEqual({ from: 'hub', m: { t: 'error', req: 1, host: 0, code: 'host-unavailable', message: ANY_TEXT } }); + const from = client.mark(); + client.sendHost(0, { t: 'ack', termId: 1, offset: 0 }); + client.sendHost(0, { t: 'resize', termId: 1, cols: 80, rows: 24 }); + client.sendHost(0, { t: 'setVisible', termIds: [1] }); + client.sendInput(0, 1, 'x'); + await sleep(500); + expect(client.messages.slice(from).filter((m) => m.t === 'error')).toEqual([]); + expect(client.isClosed).toBe(false); + }); + + it('answers a request to an outdated host with version-mismatch', async () => { + await FakeDaemon.listen(host.socket, { protocol: 99 }); + const client = await host.session(); + await client.waitHost(0, (h) => h.status === 'outdated'); + const reply = await client.request(0, { t: 'watchRepo', repo }); + expect(reply).toMatchObject({ from: 'hub', m: { t: 'error', req: 1, host: 0, code: 'version-mismatch' } }); + }); + + it('answers a message for an unknown host with unknown-host', async () => { + const client = await host.session(); + await connected(client); + const reply = await client.request(5, { t: 'watchRepo', repo }); + expect(reply).toMatchObject({ from: 'hub', m: { t: 'error', req: 1, code: 'unknown-host' } }); + const from = client.mark(); + client.sendHost(5, { t: 'setVisible', termIds: [] }); + expect(await client.waitFor('error', () => true, { from })).toMatchObject({ req: null, code: 'unknown-host' }); + expect(client.isClosed).toBe(false); + }); + + it('keeps sessions with the same request ids apart', async () => { + const a = await host.session(); + const b = await host.session(); + await connected(a); + await connected(b); + await a.watch(0, repo); + await b.watch(0, repo); + const term = await b.create(0, wt, { command: 'exec sleep 60' }); + await a.waitEvent(0, 'termCreated', (m) => m.term.termId === term.termId); + expect(a.daemonEvents(0, 'done').map((m) => m.req)).toEqual([1]); + expect(b.daemonEvents(0, 'done').map((m) => m.req)).toEqual([1]); + expect(a.daemonEvents(0, 'termCreated').map((m) => m.req)).toEqual([null]); + expect(b.daemonEvents(0, 'termCreated').map((m) => m.req)).toEqual([2]); + }); + + it('relays a snapshot near the frame limit intact', async () => { + const client = await watching(); + const awk = `awk 'BEGIN { for (l = 0; l < 5100; l++) { s = ""; for (c = 0; c < 500; c++) s = s "\\033[31ma\\033[32mb"; print s } }'; echo BIG-DONE`; + const term = await client.create(0, wt, { command: awk, cols: 1000, rows: 50 }); + await client.waitEvent(0, 'termExited', (m) => m.termId === term.termId, { timeout: 90_000 }); + const view = await client.attach(0, term.termId, 60_000); + const snapshot = view.attachments[0]?.snapshot; + if (snapshot === undefined) throw new Error('no snapshot'); + expect(snapshot.length).toBeGreaterThan(8 * MiB); + const direct = await host.client(); + await direct.watch(repo); + const reference = (await direct.attach(term.termId, 60_000)).attachments[0]?.snapshot; + expect(Buffer.from(snapshot).equals(Buffer.from(reference ?? new Uint8Array()))).toBe(true); + expect(await view.screen.text()).toContain('BIG-DONE'); + }, 180_000); +}); + +describe('back-pressure', () => { + it('lets a browser that never acks receive at most FLOW_HIGH plus one chunk of a flood', async () => { + const client = await watching(); + const term = await started(client, 'read go; seq 1 1500000; echo FLOOD-DONE'); + client.autoAck = false; + const before = client.view(0, term.termId).bytes().length; + const from = client.data.length; + client.sendInput(0, term.termId, 'go\r'); + await client.waitEvent(0, 'detached', (m) => m.termId === term.termId, { timeout: 20_000 }); + const frames = client.data.slice(from).filter((d) => d.kind === 'output' && d.termId === term.termId); + const largest = Math.max(0, ...frames.map((d) => d.data.length)); + expect(largest).toBeGreaterThan(0); + expect(client.view(0, term.termId).bytes().length - before).toBeLessThanOrEqual(FLOW_HIGH + largest); + }); + + it('stays bounded for a session that stops reading, which can still send, while others keep receiving', async () => { + const reader = await watching(); + const ticker = await started(reader, 'while true; do echo tick-$RANDOM; sleep 0.1; done'); + const stalled = await watching(); + const shell = await started(stalled, null); + const floods: Terminal[] = []; + for (let i = 0; i < 20; i++) floods.push(await stalled.create(0, wt, { command: 'yes flood-line-with-some-padding-to-fill-frames' })); + await sleep(1000); + const baseline = residentBytes(hub.pid); + for (const flood of floods) await stalled.attach(0, flood.termId, 10_000); + stalled.pauseReading(); + const marker = join(host.dir, 'typed-while-stalled'); + let peak = baseline; + const tickerBefore = reader.view(0, ticker.termId).bytes().length; + for (let i = 0; i < 10; i++) { + await sleep(500); + peak = Math.max(peak, residentBytes(hub.pid)); + if (i === 2) stalled.sendInput(0, shell.termId, `touch ${marker}\r`); + } + expect(peak - baseline).toBeLessThan(64 * MiB); + expect(reader.view(0, ticker.termId).bytes().length).toBeGreaterThan(tickerBefore); + await waitUntil(() => existsSync(marker), 'input from the stalled session'); + expect(alive(hub.pid)).toBe(true); + }); +}); diff --git a/test/process/hub-router.test.ts b/test/process/hub-router.test.ts deleted file mode 100644 index a89bb93..0000000 --- a/test/process/hub-router.test.ts +++ /dev/null @@ -1,1211 +0,0 @@ -import { existsSync, mkdirSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'; -import { hostname } from 'node:os'; -import { join } from 'node:path'; -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { FLOW_HIGH, FrameKind, DAEMON_PROTOCOL_VERSION, type Terminal } from '../../src/protocol/index.js'; -import { addWorktree, alive, DaemonHost, makeRepo, residentBytes, sleep, waitUntil, type WtdProcess } from '../support/daemon-host.js'; -import { FakeDaemon, SocketProxy } from '../support/fake-daemon.js'; -import { currentNodeDir, FakeSsh, installRemote, loginRun, type FakeRemote } from '../support/fake-ssh.js'; -import type { HostEntry, HubClient, HubMessageOf } from '../support/hub-client.js'; -import { HubHost } from '../support/hub-host.js'; -import { REPO_ROOT } from '../support/exec.js'; - -vi.setConfig({ testTimeout: 60_000, hookTimeout: 30_000 }); - -const ANY_TEXT: unknown = expect.any(String); -const ANY_INSTANCE: unknown = expect.stringMatching(/^[A-Za-z0-9_-]{1,64}$/); - -const KiB = 1024; -const MiB = 1024 * KiB; - -const SHELL = { name: 'shell', command: null }; -const CLAUDE = { name: 'claude', command: 'claude' }; -/** The presets of a configuration without `presets`. */ -const DEFAULT_PRESETS = [SHELL, CLAUDE, { name: 'codex', command: 'codex' }]; - -let host: HubHost; -let hub: WtdProcess; -let repo: string; -let wt: string; -let ssh: FakeSsh; - -beforeEach(async () => { - host = await HubHost.createHub(); - ssh = new FakeSsh(join(host.dir, 'ssh')); - host.sshProgram = ssh.program; - repo = makeRepo(join(host.dir, 'repo')); - wt = addWorktree(repo, join(host.dir, 'wt'), 'wt'); - host.writeRepos([repo]); - hub = await host.startHub(); -}); - -afterEach(async () => { - // Stop the hub first, so it starts no SSH runs while the fake remotes go away. - for (const pid of host.hubPids()) process.kill(pid, 'SIGKILL'); - await waitUntil(() => host.hubPids().length === 0, 'hubs to exit').catch(() => undefined); - await ssh.cleanup(); - await host.cleanup(); -}); - -const packageVersion = (): string => { - const pkg: unknown = JSON.parse(readFileSync(join(REPO_ROOT, 'package.json'), 'utf8')); - if (typeof pkg !== 'object' || pkg === null || !('version' in pkg) || typeof pkg.version !== 'string') throw new Error('no version'); - return pkg.version; -}; - -const connected = (client: HubClient, from = 0, timeout = 10_000): Promise<{ instance: string | null }> => - client.waitHost(0, (h) => h.status === 'connected', { from, timeout }); - -/** Instance of host 0 once `connected`. */ -const instanceOf = async (client: HubClient, from = 0): Promise => { - const { instance } = await connected(client, from); - if (instance === null) throw new Error('connected without an instance'); - return instance; -}; - -/** Creates a terminal, attaches, and for a shell waits until it ran a command. */ -const started = async (client: HubClient, command: string | null, worktree = wt): Promise => { - const term = await client.create(0, worktree, { command }); - await client.attach(0, term.termId); - if (command === null) { - client.sendInput(0, term.termId, 'echo READY-$((6*7))\r'); - await client.waitOutput(0, term.termId, 'READY-42\r\n', 10_000); - await sleep(300); - } - return term; -}; - -/** The preset lists of every `presets` message received so far. */ -const presetsOf = (client: HubClient): HubMessageOf<'presets'>['presets'][] => - client.messages.flatMap((m) => (m.t === 'presets' ? [m.presets] : [])); - -/** A connected session watching the repo. */ -const watching = async (): Promise => { - const client = await host.session(); - await connected(client); - await client.watch(0, repo); - return client; -}; - -describe('configuration snapshots', () => { - it('lists no repos when the configuration has none', async () => { - host.writeConfig({ port: host.port }); - const client = await host.session(); - expect(client.hosts()[0]?.repos).toEqual([]); - }); - - it('lists the configured repos for host 0', async () => { - const client = await host.session(); - expect(client.hosts()[0]?.repos).toEqual([repo]); - }); - - it('expands ~/ against the home directory without resolving symbolic links', async () => { - symlinkSync(repo, join(host.home, 'linked')); - host.writeConfig({ port: host.port, repos: ['~/linked'] }); - const client = await host.session(); - expect(client.hosts()[0]?.repos).toEqual([join(host.home, 'linked')]); - }); - - it('sends the configured presets exactly, in their order', async () => { - host.presets = [CLAUDE, SHELL]; - host.writeRepos([repo]); - const client = await host.session(); - expect(presetsOf(client)).toEqual([[CLAUDE, SHELL]]); - }); - - it('gives a new session an edited configuration, leaving an open session unchanged', async () => { - const open = await host.session(); - expect(presetsOf(open)).toEqual([DEFAULT_PRESETS]); - const other = makeRepo(join(host.dir, 'other')); - host.presets = [SHELL, CLAUDE]; - host.writeRepos([repo, other]); - const fresh = await host.session(); - expect(fresh.hosts()[0]?.repos).toEqual([repo, other]); - expect(presetsOf(fresh)).toEqual([[SHELL, CLAUDE]]); - await open.expectNone('hosts', (m) => m.hosts.some((h) => h.repos.includes(other)), 1000); - expect(open.hosts()[0]?.repos).toEqual([repo]); - expect(presetsOf(open)).toEqual([DEFAULT_PRESETS]); - }); - - it.each([ - ['an empty presets list', []], - [ - 'two presets named a', - [ - { name: 'a', command: null }, - { name: 'a', command: 'x' }, - ], - ], - ['a preset whose command is empty', [{ name: 'a', command: '' }]], - ])('gives a new session an internal error and the last valid repos and presets after an edit to %s', async (_name, presets) => { - host.presets = [CLAUDE, SHELL]; - host.writeRepos([repo]); - expect(presetsOf(await host.session())).toEqual([[CLAUDE, SHELL]]); - host.writeConfig({ port: host.port, repos: [join(host.dir, 'x')], presets }); - const client = await host.open(['wtd', `wtd.token.${host.token()}`]); - await client.handshake(); - const error = await client.waitFor('error'); - expect(error).toMatchObject({ req: null, host: null, code: 'internal' }); - expect(error.message).toContain('presets'); - expect(client.hosts()[0]?.repos).toEqual([repo]); - expect(presetsOf(client)).toEqual([[CLAUDE, SHELL]]); - }); -}); - -describe('daemon links and host status', () => { - it('starts the daemon and reports host 0 connected with its instance', async () => { - expect(host.daemonPids()).toEqual([]); - const client = await host.session(); - const entry = await connected(client); - expect(entry).toEqual({ - idx: 0, - name: hostname().slice(0, 64) || 'local', - remote: false, - status: 'connected', - reason: null, - daemonVersion: packageVersion(), - instance: ANY_INSTANCE, - login: null, - repos: [repo], - }); - expect(host.daemonPids()).toHaveLength(1); - const daemon = await host.client(); - expect((await daemon.handshake()).instance).toBe(entry.instance); - }); - - it('reports reconnecting, then connected with a new instance, when the daemon is killed', async () => { - const client = await host.session(); - const first = await instanceOf(client); - const [pid] = host.daemonPids(); - if (pid === undefined) throw new Error('no daemon'); - const from = client.mark(); - process.kill(pid, 'SIGKILL'); - expect(await client.waitHost(0, (h) => h.status === 'reconnecting', { from })).toMatchObject({ instance: null }); - const second = await instanceOf(client, from); - expect(second).not.toBe(first); - }); - - it.each([ - ['an older', 3], - ['a newer', 99], - ])('reports a daemon of %s protocol as outdated with its version and instance, keeping the link', async (_name, protocol) => { - const fake = await FakeDaemon.listen(host.socket, { protocol, version: '9.9.9', instance: 'old_D-1' }); - const client = await host.session(); - expect(await client.waitHost(0, (h) => h.status === 'outdated')).toMatchObject({ - daemonVersion: '9.9.9', - instance: 'old_D-1', - reason: null, - }); - await sleep(1000); - expect(fake.connections).toBe(1); - expect(client.hosts()[0]?.status).toBe('outdated'); - }); - - it('reports reconnecting, then down after 3 failures, and keeps retrying', async () => { - const fake = await FakeDaemon.listen(host.socket, { protocol: DAEMON_PROTOCOL_VERSION, accept: 'close' }); - const client = await host.session(); - await client.waitHost(0, (h) => h.status === 'reconnecting'); - await client.waitHost(0, (h) => h.status === 'down', { timeout: 10_000 }); - expect(fake.connections).toBeGreaterThanOrEqual(3); - const atDown = fake.connections; - await waitUntil(() => fake.connections >= atDown + 2, 'retries while down', 15_000); - expect(client.hosts()[0]?.status).toBe('down'); - }); -}); - -describe('routing', () => { - it('relays daemon messages and data byte-identically inside their envelopes', async () => { - const backend = DaemonHost.create(); - try { - await backend.start(); - const proxy = await SocketProxy.listen(host.socket, backend.socket); - const client = await host.session(); - await connected(client); - await client.watch(0, repo); - const term = await started(client, null); - client.sendInput(0, term.termId, 'echo round-$((40+2))-trip\r'); - await client.waitOutput(0, term.termId, 'round-42-trip'); - await sleep(500); - const daemonTexts = proxy.controlTexts().filter((text) => !text.includes('"t":"hello"')); - expect(daemonTexts.some((text) => text.includes('"t":"repoState"'))).toBe(true); - expect(daemonTexts.some((text) => text.includes('"t":"termCreated"'))).toBe(true); - for (const text of daemonTexts) - expect( - client.texts.some((envelope) => envelope.includes(text)), - text, - ).toBe(true); - const daemonData = proxy.fromDaemon.filter((f) => f.kind !== FrameKind.control); - expect(daemonData.some((f) => f.kind === FrameKind.snapshot)).toBe(true); - expect(client.data.map((d) => Buffer.from(d.raw.subarray(0, 3)))).toEqual(daemonData.map((f) => Buffer.from([f.kind, 0, 0]))); - expect(client.data.map((d) => Buffer.from(d.raw.subarray(3)))).toEqual(daemonData.map((f) => Buffer.from(f.payload))); - await proxy.close(); - } finally { - await backend.cleanup(); - } - }); - - it('forwards requests without rewriting their ids, and acks only those the browser sends', async () => { - const backend = DaemonHost.create(); - try { - await backend.start(); - const proxy = await SocketProxy.listen(host.socket, backend.socket); - const client = await host.session(); - await connected(client); - client.autoAck = false; - await client.watch(0, repo); - const term = await started(client, 'seq 1 50000; echo SEQ-DONE; exec sleep 60'); - await client.waitOutput(0, term.termId, 'SEQ-DONE', 10_000); - await sleep(500); - const sent = (): unknown[] => proxy.controlTextsToDaemon().map((text): unknown => JSON.parse(text)); - expect(sent()).toContainEqual({ t: 'watchRepo', req: 1, repo }); - expect(sent().filter((m) => typeof m === 'object' && m !== null && 't' in m && m.t === 'ack')).toEqual([]); - const position = client.view(0, term.termId).position; - client.sendHost(0, { t: 'ack', termId: term.termId, offset: position }); - await waitUntil( - () => sent().some((m) => JSON.stringify(m) === JSON.stringify({ t: 'ack', termId: term.termId, offset: position })), - 'the ack', - ); - await proxy.close(); - } finally { - await backend.cleanup(); - } - }); - - it('answers a request to a reconnecting host with host-unavailable and drops its fire-and-forget messages', async () => { - await FakeDaemon.listen(host.socket, { protocol: DAEMON_PROTOCOL_VERSION, accept: 'close' }); - const client = await host.session(); - await client.waitHost(0, (h) => h.status === 'reconnecting' || h.status === 'down'); - const reply = await client.request(0, { t: 'watchRepo', repo }); - expect(reply).toEqual({ from: 'hub', m: { t: 'error', req: 1, host: 0, code: 'host-unavailable', message: ANY_TEXT } }); - const from = client.mark(); - client.sendHost(0, { t: 'ack', termId: 1, offset: 0 }); - client.sendHost(0, { t: 'resize', termId: 1, cols: 80, rows: 24 }); - client.sendHost(0, { t: 'setVisible', termIds: [1] }); - client.sendInput(0, 1, 'x'); - await sleep(500); - expect(client.messages.slice(from).filter((m) => m.t === 'error')).toEqual([]); - expect(client.isClosed).toBe(false); - }); - - it('answers a request to an outdated host with version-mismatch', async () => { - await FakeDaemon.listen(host.socket, { protocol: 99 }); - const client = await host.session(); - await client.waitHost(0, (h) => h.status === 'outdated'); - const reply = await client.request(0, { t: 'watchRepo', repo }); - expect(reply).toMatchObject({ from: 'hub', m: { t: 'error', req: 1, host: 0, code: 'version-mismatch' } }); - }); - - it('answers a message for an unknown host with unknown-host', async () => { - const client = await host.session(); - await connected(client); - const reply = await client.request(5, { t: 'watchRepo', repo }); - expect(reply).toMatchObject({ from: 'hub', m: { t: 'error', req: 1, code: 'unknown-host' } }); - const from = client.mark(); - client.sendHost(5, { t: 'setVisible', termIds: [] }); - expect(await client.waitFor('error', () => true, { from })).toMatchObject({ req: null, code: 'unknown-host' }); - expect(client.isClosed).toBe(false); - }); - - it('keeps sessions with the same request ids apart', async () => { - const a = await host.session(); - const b = await host.session(); - await connected(a); - await connected(b); - await a.watch(0, repo); - await b.watch(0, repo); - const term = await b.create(0, wt, { command: 'exec sleep 60' }); - await a.waitEvent(0, 'termCreated', (m) => m.term.termId === term.termId); - expect(a.daemonEvents(0, 'done').map((m) => m.req)).toEqual([1]); - expect(b.daemonEvents(0, 'done').map((m) => m.req)).toEqual([1]); - expect(a.daemonEvents(0, 'termCreated').map((m) => m.req)).toEqual([null]); - expect(b.daemonEvents(0, 'termCreated').map((m) => m.req)).toEqual([2]); - }); - - it('relays a snapshot near the frame limit intact', async () => { - const client = await watching(); - const awk = `awk 'BEGIN { for (l = 0; l < 5100; l++) { s = ""; for (c = 0; c < 500; c++) s = s "\\033[31ma\\033[32mb"; print s } }'; echo BIG-DONE`; - const term = await client.create(0, wt, { command: awk, cols: 1000, rows: 50 }); - await client.waitEvent(0, 'termExited', (m) => m.termId === term.termId, { timeout: 90_000 }); - const view = await client.attach(0, term.termId, 60_000); - const snapshot = view.attachments[0]?.snapshot; - if (snapshot === undefined) throw new Error('no snapshot'); - expect(snapshot.length).toBeGreaterThan(8 * MiB); - const direct = await host.client(); - await direct.watch(repo); - const reference = (await direct.attach(term.termId, 60_000)).attachments[0]?.snapshot; - expect(Buffer.from(snapshot).equals(Buffer.from(reference ?? new Uint8Array()))).toBe(true); - expect(await view.screen.text()).toContain('BIG-DONE'); - }, 180_000); -}); - -describe('back-pressure', () => { - it('lets a browser that never acks receive at most FLOW_HIGH plus one chunk of a flood', async () => { - const client = await watching(); - const term = await started(client, 'read go; seq 1 1500000; echo FLOOD-DONE'); - client.autoAck = false; - const before = client.view(0, term.termId).bytes().length; - const from = client.data.length; - client.sendInput(0, term.termId, 'go\r'); - await client.waitEvent(0, 'detached', (m) => m.termId === term.termId, { timeout: 20_000 }); - const frames = client.data.slice(from).filter((d) => d.kind === 'output' && d.termId === term.termId); - const largest = Math.max(0, ...frames.map((d) => d.data.length)); - expect(largest).toBeGreaterThan(0); - expect(client.view(0, term.termId).bytes().length - before).toBeLessThanOrEqual(FLOW_HIGH + largest); - }); - - it('stays bounded for a session that stops reading, which can still send, while others keep receiving', async () => { - const reader = await watching(); - const ticker = await started(reader, 'while true; do echo tick-$RANDOM; sleep 0.1; done'); - const stalled = await watching(); - const shell = await started(stalled, null); - const floods: Terminal[] = []; - for (let i = 0; i < 20; i++) floods.push(await stalled.create(0, wt, { command: 'yes flood-line-with-some-padding-to-fill-frames' })); - await sleep(1000); - const baseline = residentBytes(hub.pid); - for (const flood of floods) await stalled.attach(0, flood.termId, 10_000); - stalled.pauseReading(); - const marker = join(host.dir, 'typed-while-stalled'); - let peak = baseline; - const tickerBefore = reader.view(0, ticker.termId).bytes().length; - for (let i = 0; i < 10; i++) { - await sleep(500); - peak = Math.max(peak, residentBytes(hub.pid)); - if (i === 2) stalled.sendInput(0, shell.termId, `touch ${marker}\r`); - } - expect(peak - baseline).toBeLessThan(64 * MiB); - expect(reader.view(0, ticker.termId).bytes().length).toBeGreaterThan(tickerBefore); - await waitUntil(() => existsSync(marker), 'input from the stalled session'); - expect(alive(hub.pid)).toBe(true); - }); -}); - -describe('daemon restart', () => { - it.each([ - ['an older', 3], - ['a newer', 99], - ])('restarts an outdated daemon of %s protocol into one speaking the hub’s protocol', async (_name, protocol) => { - const fake = await FakeDaemon.listen(host.socket, { protocol }); - const client = await host.session(); - await client.waitHost(0, (h) => h.status === 'outdated'); - const from = client.mark(); - const reply = await client.hubRequest({ t: 'restartDaemon', host: 0 }, 15_000); - expect(reply).toEqual({ from: 'hub', m: { t: 'done', req: 1 } }); - expect(fake.shutdowns).toBe(1); - await fake.closed; - expect(await instanceOf(client, from)).not.toBe('fake_instance'); - // A daemon the session's reconnect started alongside the restart refuses to start and exits. - await waitUntil(() => host.daemonPids().length === 1, 'one daemon'); - const direct = await host.client(); - expect((await direct.handshake()).protocol).toBe(DAEMON_PROTOCOL_VERSION); - }); - - it('restarts a newer daemon whose messages after hello are not valid for the hub’s protocol', async () => { - const fake = await FakeDaemon.listen(host.socket, { - protocol: DAEMON_PROTOCOL_VERSION + 1, - instance: 'newer_1', - afterHello: ['{"t":"frobnicated","widgets":[1,2,3]}', '{"t":"repoState","repo":7}'], - }); - const client = await host.session(); - await client.waitHost(0, (h) => h.status === 'outdated'); - const from = client.mark(); - const reply = await client.hubRequest({ t: 'restartDaemon', host: 0 }, 15_000); - expect(reply).toEqual({ from: 'hub', m: { t: 'done', req: 1 } }); - expect(fake.shutdowns).toBe(1); - await fake.closed; - const entry = await client.waitHost(0, (h) => h.status === 'connected', { from, timeout: 10_000 }); - expect(entry.instance).not.toBe('newer_1'); - expect(entry.daemonVersion).toBe(packageVersion()); - }); - - it('shares one restart between concurrent requests from two sessions', async () => { - const a = await host.session(); - const b = await host.session(); - const old = await instanceOf(a); - await connected(b); - const oldPid = await waitUntil(() => { - const pids = host.daemonPids(); - return pids.length === 1 ? pids[0] : undefined; - }, 'one daemon'); - const fromA = a.mark(); - const fromB = b.mark(); - const replies = await Promise.all([ - a.hubRequest({ t: 'restartDaemon', host: 0 }, 15_000), - b.hubRequest({ t: 'restartDaemon', host: 0 }, 15_000), - ]); - expect(replies.map((r) => r.m.t)).toEqual(['done', 'done']); - // `done` follows the old daemon's socket going quiet; its process may still be exiting. - await waitUntil(() => !alive(oldPid), 'the old daemon to exit'); - const newA = await instanceOf(a, fromA); - const newB = await instanceOf(b, fromB); - expect(newA).not.toBe(old); - expect(newB).toBe(newA); - // Both sessions reached the new daemon, so every reconnect has dialled; daemons they started alongside it exit. - await waitUntil(() => host.daemonPids().length === 1, 'one daemon'); - await sleep(500); - expect(host.daemonPids()).toHaveLength(1); - }); - - it('answers internal when the daemon does not go away within 10 s', async () => { - const fake = await FakeDaemon.listen(host.socket, { protocol: 99, onShutdown: 'ignore' }); - const client = await host.session(); - await client.waitHost(0, (h) => h.status === 'outdated'); - const sentAt = Date.now(); - const reply = await client.hubRequest({ t: 'restartDaemon', host: 0 }, 20_000); - expect(reply).toMatchObject({ from: 'hub', m: { t: 'error', req: 1, code: 'internal' } }); - expect(Date.now() - sentAt).toBeGreaterThanOrEqual(9500); - expect(fake.shutdowns).toBe(1); - }); - - it('answers host-unavailable for a host that is not connected or outdated', async () => { - const fake = await FakeDaemon.listen(host.socket, { protocol: DAEMON_PROTOCOL_VERSION, accept: 'close' }); - const client = await host.session(); - await client.waitHost(0, (h) => h.status === 'reconnecting' || h.status === 'down'); - const reply = await client.hubRequest({ t: 'restartDaemon', host: 0 }); - expect(reply).toMatchObject({ from: 'hub', m: { t: 'error', req: 1, host: 0, code: 'host-unavailable' } }); - expect(fake.shutdowns).toBe(0); - }); -}); - -describe('session end and hub shutdown', () => { - it('keeps terminals running when a session closes', async () => { - const first = await watching(); - const term = await started(first, null); - const [daemonPid] = host.daemonPids(); - first.close(); - await sleep(500); - const second = await watching(); - const state = second.daemonEvents(0, 'repoState').at(-1); - expect(state?.terminals.find((t) => t.termId === term.termId)?.exit).toBeNull(); - expect(host.daemonPids()).toEqual([daemonPid]); - }); - - it('closes sessions with 1001 on SIGTERM and exits 0, leaving the daemon running', async () => { - const client = await watching(); - const [daemonPid] = host.daemonPids(); - hub.kill('SIGTERM'); - await client.waitClosed(); - expect(client.closeCode).toBe(1001); - expect(await hub.exited).toEqual({ code: 0, signal: null }); - expect(alive(daemonPid ?? 0)).toBe(true); - }); - - it('closes sessions with 1001 on an authenticated shutdown request', async () => { - const client = await host.session(); - await host.api('POST', '/api/shutdown'); - await client.waitClosed(); - expect(client.closeCode).toBe(1001); - expect(await hub.exited).toEqual({ code: 0, signal: null }); - }); -}); - -const REMOTE_TIMEOUT = 20_000; -const INSTALL_TIMEOUT = 150_000; - -interface RemoteHostConfig { - name: string; - ssh: string; - repos?: readonly string[]; - roots?: readonly string[]; -} - -/** Writes `config.json` with the local repo, `hosts` and any other keys. */ -const configure = (hosts: readonly RemoteHostConfig[], extra: Record = {}): void => { - host.writeConfig({ port: host.port, repos: [repo], hosts, ...extra }); -}; - -/** A remote whose PATH offers this runner's Node, so the hub's installer finds one there. */ -const nodeRemote = (alias: string): FakeRemote => - ssh.addHost(alias, { path: `${currentNodeDir(join(ssh.dir, `node-${alias}`))}:${ssh.pathWithoutNode}` }); - -/** The remote `box`, installed, holding one repo, configured as host 1. */ -const installedBox = async (): Promise<{ remote: FakeRemote; remoteRepo: string }> => { - const remote = nodeRemote('box'); - await installRemote(host, ssh, 'box'); - const remoteRepo = makeRepo(join(remote.home, 'src', 'app')); - configure([{ name: 'box', ssh: 'box', repos: [remoteRepo] }]); - return { remote, remoteRepo }; -}; - -const connectedHost = (client: HubClient, idx: number, from = 0, timeout = REMOTE_TIMEOUT): Promise<{ instance: string | null }> => - client.waitHost(idx, (h) => h.status === 'connected', { from, timeout }); - -/** Instance of host `idx` once `connected`. */ -const remoteInstance = async (client: HubClient, idx: number, from = 0): Promise => { - const { instance } = await connectedHost(client, idx, from); - if (instance === null) throw new Error('connected without an instance'); - return instance; -}; - -/** Reads `config.json` as JSON. */ -const configFile = (): unknown => JSON.parse(readFileSync(host.configPath, 'utf8')); - -describe('remote hosts', () => { - it('lists configured remote hosts after the local host, in configuration order', async () => { - ssh.failHost('b-alias', 'ssh: Could not resolve hostname b-alias'); - ssh.failHost('a-alias', 'ssh: Could not resolve hostname a-alias'); - configure([ - { name: 'b', ssh: 'b-alias' }, - { name: 'a', ssh: 'a-alias', repos: ['/srv/a'] }, - ]); - const client = await host.session(); - expect(client.hosts().map((h) => [h.idx, h.name, h.remote, h.repos])).toEqual([ - [0, hostname().slice(0, 64) || 'local', false, [repo]], - [1, 'b', true, []], - [2, 'a', true, ['/srv/a']], - ]); - }); - - it('connects a remote host through its SSH command, reporting its name, index and the remote daemon’s instance', async () => { - const { remote, remoteRepo } = await installedBox(); - const client = await host.session(); - const entry = await connectedHost(client, 1); - expect(entry).toEqual({ - idx: 1, - name: 'box', - remote: true, - status: 'connected', - reason: null, - daemonVersion: packageVersion(), - instance: ANY_INSTANCE, - login: null, - repos: [remoteRepo], - }); - expect(remote.daemonPids()).toHaveLength(1); - expect((await remote.hello()).instance).toBe(entry.instance); - const link = ssh.calls().find((call) => call.alias === 'box' && call.command.includes('connect')); - expect(link?.command).toBe('"$HOME/.local/bin/wtd" connect'); - expect(link?.args.slice(0, 12)).toEqual([ - '-o', - 'BatchMode=yes', - '-o', - 'ConnectTimeout=10', - '-o', - 'ControlMaster=auto', - '-o', - 'ControlPersist=10m', - '-o', - `ControlPath=${host.runDir}/ssh-%C`, - '-o', - 'ServerAliveInterval=15', - ]); - }); - - it('round-trips requests, terminals and output through a remote host', async () => { - const { remoteRepo } = await installedBox(); - const client = await host.session(); - await connectedHost(client, 1); - const state = await client.watch(1, remoteRepo); - expect(state.worktrees.map((w) => w.path)).toEqual([remoteRepo]); - const term = await client.create(1, remoteRepo); - await client.attach(1, term.termId); - client.sendInput(1, term.termId, 'echo remote-$((40+2))-trip\r'); - await client.waitOutput(1, term.termId, 'remote-42-trip', 10_000); - expect(client.daemonEvents(0, 'termCreated')).toEqual([]); - }); - - it('reports reconnecting, then connected to the same daemon, when the SSH process is killed', async () => { - await installedBox(); - const client = await host.session(); - const first = await remoteInstance(client, 1); - const pids = await waitUntil(() => { - const live = ssh.livePids('box'); - return live.length > 0 ? live : undefined; - }, 'the hub’s SSH process'); - const from = client.mark(); - for (const pid of pids) process.kill(pid, 'SIGKILL'); - expect(await client.waitHost(1, (h) => h.status === 'reconnecting', { from })).toMatchObject({ instance: null }); - expect(await remoteInstance(client, 1, from)).toBe(first); - }); - - it('reports a host whose SSH command keeps failing as down, naming SSH’s last error line', async () => { - ssh.failHost('box', 'ssh: Could not resolve hostname box'); - configure([{ name: 'box', ssh: 'box' }]); - const client = await host.session(); - expect(await client.waitHost(1, (h) => h.status === 'reconnecting')).toMatchObject({ - reason: 'ssh: Could not resolve hostname box', - instance: null, - }); - expect(await client.waitHost(1, (h) => h.status === 'down', { timeout: 10_000 })).toMatchObject({ - reason: 'ssh: Could not resolve hostname box', - daemonVersion: null, - instance: null, - }); - expect(client.hosts()[0]?.status).toBe('connected'); - }); - - it('reports a remote without wtd as down, with a reason', async () => { - ssh.addHost('box'); - configure([{ name: 'box', ssh: 'box' }]); - const client = await host.session(); - const entry = await client.waitHost(1, (h) => h.status === 'down', { timeout: 10_000 }); - expect(entry.reason).toEqual(expect.stringMatching(/\S/)); - }); - - it('keeps its memory bounded and the reason short when SSH writes 8 MiB to standard error without a newline', async () => { - const before = residentBytes(hub.pid); - ssh.spewHost('box', 8 * MiB); - configure([{ name: 'box', ssh: 'box' }]); - const client = await host.session(); - const entry = await client.waitHost(1, (h) => h.status === 'down', { timeout: 30_000 }); - expect(entry.reason?.length ?? 0).toBeGreaterThan(0); - expect(entry.reason?.length ?? 0).toBeLessThanOrEqual(1024); - expect(residentBytes(hub.pid) - before).toBeLessThan(16 * MiB); - }); - - it('ends an SSH process that writes over 1 MiB to standard error instead of leaving its link stalled', async () => { - await installedBox(); - ssh.noisyHost('box', 2 * MiB); - const client = await host.session(); - const entry = await client.waitHost(1, (h) => h.status === 'reconnecting' || h.status === 'down', { timeout: REMOTE_TIMEOUT }); - expect(entry.reason).toMatch(/^x{1,1024}$/); - }); - - it('answers requests to a down remote host with host-unavailable', async () => { - ssh.failHost('box', 'ssh: Could not resolve hostname box'); - configure([{ name: 'box', ssh: 'box' }]); - const client = await host.session(); - await client.waitHost(1, (h) => h.status === 'reconnecting' || h.status === 'down'); - const reply = await client.request(1, { t: 'watchRepo', repo: '/srv/app' }); - expect(reply).toMatchObject({ from: 'hub', m: { t: 'error', req: 1, host: 1, code: 'host-unavailable' } }); - }); - - it('kills SSH processes of a closed session that ignore SIGTERM', async () => { - ssh.addHost('box'); - ssh.hangHost('box'); - configure([{ name: 'box', ssh: 'box' }]); - const client = await host.session(); - const pids = await waitUntil(() => { - const live = ssh.livePids('box'); - return live.length > 0 && live; - }, 'an SSH process for box'); - client.close(); - await waitUntil(() => pids.every((pid) => !alive(pid)), 'the session’s SSH processes to be killed', 5000); - expect(pids.filter(alive)).toEqual([]); - }); - - it('ends the SSH processes of a closed session', async () => { - await installedBox(); - const client = await host.session(); - await connectedHost(client, 1); - const pids = ssh.livePids('box'); - expect(pids.length).toBeGreaterThan(0); - client.close(); - await waitUntil(() => pids.every((pid) => !alive(pid)), 'the session’s SSH processes to end', 5000); - }); -}); - -const LOGIN_URL: unknown = expect.stringMatching(/^https:\/\/login\.tailscale\.com\/a\/wtd\d+$/); - -/** Host `idx`'s entries in every `hosts` message from `from` on. */ -const entriesOf = (client: HubClient, idx: number, from = 0): HostEntry[] => - client.messages.slice(from).flatMap((m) => (m.t === 'hosts' ? m.hosts.filter((h) => h.idx === idx) : [])); - -/** Host `idx`'s statuses from `from` on, without consecutive repeats. */ -const statusesOf = (client: HubClient, idx: number, from = 0): string[] => - entriesOf(client, idx, from) - .map((h) => h.status) - .filter((status, i, all) => i === 0 || all[i - 1] !== status); - -/** The SSH runs of `box` that ran the connect command. */ -const connectRuns = (): number[] => - ssh - .calls() - .filter((call) => call.alias === 'box' && call.command.includes('connect')) - .map((call) => call.pid); - -/** Host 1's entry once it needs login, and the login URL. */ -const needsLogin = async (client: HubClient, from = 0): Promise<{ entry: HostEntry; url: string }> => { - const entry = await client.waitHost(1, (h) => h.status === 'needs-login', { from, timeout: REMOTE_TIMEOUT }); - if (entry.login === null) throw new Error('needs-login without a login URL'); - return { entry, url: entry.login }; -}; - -describe('Tailscale SSH check login', () => { - it('reports needs-login with the URL within 1 s of the banner, then connects over the same SSH process once approved', async () => { - await installedBox(); - ssh.loginHost('box'); - const before = connectRuns().length; - const client = await host.session(); - await waitUntil(() => connectRuns().length > before, 'the hub’s SSH process for box'); - const entry = await client.waitHost(1, (h) => h.status === 'needs-login', { timeout: 5000 }); - const receivedAt = Date.now(); - expect(entry).toMatchObject({ status: 'needs-login', login: LOGIN_URL, reason: null, daemonVersion: null, instance: null }); - const url = entry.login ?? ''; - expect(receivedAt - ssh.loginWrittenAt(url)).toBeLessThanOrEqual(1000); - expect(connectRuns()).toHaveLength(before + 1); - ssh.approveLogin(url); - expect(await connectedHost(client, 1)).toMatchObject({ login: null, reason: null }); - expect(connectRuns()).toHaveLength(before + 1); - expect(alive(loginRun(url))).toBe(true); - expect(statusesOf(client, 1)).toEqual(['connecting', 'needs-login', 'connected']); - }); - - it('reports reconnecting with SSH’s reason and a null login when the check expires, then the next run’s URL', async () => { - await installedBox(); - ssh.loginHost('box'); - const client = await host.session(); - const { url: first } = await needsLogin(client); - const from = client.mark(); - ssh.expireLogin(first, 'Tailscale SSH check timed out'); - expect(await client.waitHost(1, (h) => h.status === 'reconnecting', { from })).toMatchObject({ - reason: 'Tailscale SSH check timed out', - login: null, - }); - const { url: second } = await needsLogin(client, from); - expect(second).not.toBe(first); - }); - - it('answers a request to a needs-login host with host-unavailable', async () => { - await installedBox(); - ssh.loginHost('box'); - const client = await host.session(); - await needsLogin(client); - const reply = await client.request(1, { t: 'watchRepo', repo: '/srv/app' }); - expect(reply).toMatchObject({ from: 'hub', m: { t: 'error', req: 1, host: 1, code: 'host-unavailable' } }); - }); - - it('redials every other needs-login session once one session’s link greets, leaving connected sessions alone', async () => { - await installedBox(); - const c = await host.session(); - const cEntry = await connectedHost(c, 1); - ssh.loginHost('box'); - const a = await host.session(); - const { url: aUrl } = await needsLogin(a); - const b = await host.session(); - const { url: bUrl } = await needsLogin(b); - const bRun = loginRun(bUrl); - expect(bRun).not.toBe(loginRun(aUrl)); - const runsBefore = connectRuns().length; - const fromB = b.mark(); - const fromC = c.mark(); - ssh.skipLogin('box'); - ssh.approveLogin(aUrl); - await connectedHost(a, 1); - expect(await connectedHost(b, 1, fromB)).toMatchObject({ login: null }); - expect(statusesOf(b, 1, fromB)).toEqual(['connecting', 'connected']); - expect(entriesOf(b, 1, fromB)[0]).toMatchObject({ status: 'connecting', login: null }); - await waitUntil(() => !alive(bRun), 'B’s original SSH process to end', 5000); - expect(connectRuns().length).toBeGreaterThan(runsBefore); - for (const entry of entriesOf(c, 1, fromC)) expect(entry).toEqual(cEntry); - }); -}); - -describe('remote daemon restart and reinstall', () => { - it('restarts a connected remote daemon into a new instance', async () => { - const { remote } = await installedBox(); - const client = await host.session(); - const old = await remoteInstance(client, 1); - const [oldPid] = remote.daemonPids(); - const from = client.mark(); - const reply = await client.hubRequest({ t: 'restartDaemon', host: 1 }, 20_000); - expect(reply).toEqual({ from: 'hub', m: { t: 'done', req: 1 } }); - expect(await remoteInstance(client, 1, from)).not.toBe(old); - await waitUntil(() => oldPid !== undefined && !alive(oldPid), 'the old remote daemon to exit'); - await waitUntil(() => remote.daemonPids().length === 1, 'one remote daemon'); - }); - - it( - 'reinstalls an outdated remote: installs the hub’s bundle, shuts the old daemon down and connects to the new one', - async () => { - const { remote } = await installedBox(); - const releases = remote.releases(); - const fake = await FakeDaemon.listen(remote.socket, { protocol: 4, version: '0.0.4', instance: 'old_remote' }); - const client = await host.session(); - expect(await client.waitHost(1, (h) => h.status === 'outdated', { timeout: REMOTE_TIMEOUT })).toMatchObject({ - daemonVersion: '0.0.4', - instance: 'old_remote', - }); - const from = client.mark(); - const reply = await client.hubRequest({ t: 'reinstallDaemon', host: 1 }, INSTALL_TIMEOUT); - expect(reply).toEqual({ from: 'hub', m: { t: 'done', req: 1 } }); - expect(fake.shutdowns).toBeGreaterThanOrEqual(1); - const entry = await connectedHost(client, 1, from); - expect(entry).toMatchObject({ daemonVersion: packageVersion() }); - expect(remote.releases()).toHaveLength(2); - expect(remote.releases()).toEqual(expect.arrayContaining(releases)); - expect(remote.current()).not.toBe(join('versions', releases[0] ?? '')); - }, - INSTALL_TIMEOUT + 30_000, - ); - - it( - 'installs wtd on a down remote that never had it and connects', - async () => { - const remote = nodeRemote('box'); - configure([{ name: 'box', ssh: 'box' }]); - const client = await host.session(); - await client.waitHost(1, (h) => h.status === 'down', { timeout: 10_000 }); - const from = client.mark(); - const reply = await client.hubRequest({ t: 'reinstallDaemon', host: 1 }, INSTALL_TIMEOUT); - expect(reply).toEqual({ from: 'hub', m: { t: 'done', req: 1 } }); - expect(await connectedHost(client, 1, from)).toMatchObject({ daemonVersion: packageVersion() }); - expect(remote.releases()).toHaveLength(1); - expect(existsSync(remote.shim)).toBe(true); - }, - INSTALL_TIMEOUT + 30_000, - ); - - it( - 'reports an installation failure as internal, naming the install step and the missing Node', - async () => { - ssh.addHost('box'); - configure([{ name: 'box', ssh: 'box' }]); - const client = await host.session(); - await client.waitHost(1, (h) => h.status === 'down', { timeout: 10_000 }); - const reply = await client.hubRequest({ t: 'reinstallDaemon', host: 1 }, INSTALL_TIMEOUT); - expect(reply).toMatchObject({ from: 'hub', m: { t: 'error', req: 1, host: 1, code: 'internal' } }); - const message = reply.m.t === 'error' ? reply.m.message : ''; - expect(message).toMatch(/^install:/); - expect(message).toMatch(/node/i); - }, - INSTALL_TIMEOUT + 30_000, - ); - - it( - 'lets a restart requested during a reinstall share it', - async () => { - const { remote } = await installedBox(); - await FakeDaemon.listen(remote.socket, { protocol: 4, version: '0.0.4', instance: 'old_remote' }); - const a = await host.session(); - const b = await host.session(); - await a.waitHost(1, (h) => h.status === 'outdated', { timeout: REMOTE_TIMEOUT }); - await b.waitHost(1, (h) => h.status === 'outdated', { timeout: REMOTE_TIMEOUT }); - const fromA = a.mark(); - const fromB = b.mark(); - const reinstall = a.hubRequest({ t: 'reinstallDaemon', host: 1 }, INSTALL_TIMEOUT); - await sleep(200); - const restart = b.hubRequest({ t: 'restartDaemon', host: 1 }, INSTALL_TIMEOUT); - const [ra, rb] = await Promise.all([reinstall, restart]); - expect(ra.m.t).toBe('done'); - expect(rb.m.t).toBe('done'); - const instanceA = await remoteInstance(a, 1, fromA); - expect(await remoteInstance(b, 1, fromB)).toBe(instanceA); - await waitUntil(() => remote.daemonPids().length === 1, 'one remote daemon'); - await sleep(500); - expect(remote.daemonPids()).toHaveLength(1); - expect(remote.releases()).toHaveLength(2); - }, - INSTALL_TIMEOUT + 30_000, - ); - - it('refuses to reinstall the local host, touching no daemon', async () => { - const client = await host.session(); - const instance = await instanceOf(client); - const [pid] = host.daemonPids(); - const reply = await client.hubRequest({ t: 'reinstallDaemon', host: 0 }); - expect(reply).toMatchObject({ from: 'hub', m: { t: 'error', req: 1, host: 0, code: 'internal' } }); - await sleep(500); - expect(host.daemonPids()).toEqual([pid]); - expect(client.hosts()[0]).toMatchObject({ status: 'connected', instance }); - }); - - it('answers a reinstall for an unknown host with unknown-host', async () => { - const client = await host.session(); - const reply = await client.hubRequest({ t: 'reinstallDaemon', host: 7 }); - expect(reply).toMatchObject({ from: 'hub', m: { t: 'error', req: 1, code: 'unknown-host' } }); - }); -}); - -describe('repo discovery through the hub', () => { - it('discovers repos below the host’s configured roots, at depth 3', async () => { - const one = makeRepo(join(host.home, 'src', 'one')); - const deep = makeRepo(join(host.home, 'src', 'a', 'b', 'deep')); - makeRepo(join(host.home, 'src', 'a', 'b', 'c', 'too-deep')); - makeRepo(join(host.home, 'elsewhere', 'outside')); - host.writeConfig({ port: host.port, repos: [repo], roots: ['~/src'] }); - const client = await host.session(); - await connected(client); - const reply = await client.hubRequest({ t: 'discoverRepos', host: 0 }); - expect(reply).toEqual({ from: 'hub', m: { t: 'reposDiscovered', req: 1, host: 0, repos: [deep, one] } }); - }); - - it('discovers below the home directory by default', async () => { - const mine = makeRepo(join(host.home, 'GitHub', 'mine')); - const client = await host.session(); - await connected(client); - const reply = await client.hubRequest({ t: 'discoverRepos', host: 0 }); - expect(reply.m.t === 'reposDiscovered' ? reply.m.repos : []).toContain(mine); - }); - - it('discovers on a remote host against the remote home', async () => { - const remote = nodeRemote('box'); - await installRemote(host, ssh, 'box'); - const found = makeRepo(join(remote.home, 'code', 'found')); - configure([{ name: 'box', ssh: 'box', roots: ['~/code'] }]); - const client = await host.session(); - await connectedHost(client, 1); - const reply = await client.hubRequest({ t: 'discoverRepos', host: 1 }); - expect(reply).toEqual({ from: 'hub', m: { t: 'reposDiscovered', req: 1, host: 1, repos: [found] } }); - }); - - it('keeps a hub-level request apart from a daemon request with the same id', async () => { - const client = await host.session(); - await connected(client); - const from = client.mark(); - client.sendJson({ t: 'host', host: 0, m: { t: 'watchRepo', req: 7, repo } }); - client.sendJson({ t: 'discoverRepos', req: 7, host: 0 }); - await client.waitFor('reposDiscovered', (m) => m.req === 7, { from }); - await client.waitEvent(0, 'done', (m) => m.req === 7, { from }); - await sleep(1000); - const replies = client.messages.slice(from).filter((m) => { - if (m.t === 'host') return 'req' in m.m && m.m.req === 7; - return 'req' in m && m.req === 7; - }); - expect(replies.map((m) => (m.t === 'host' ? `host:${m.m.t}` : m.t)).sort()).toEqual(['host:done', 'reposDiscovered']); - }); - - it('answers discovery for a host that is not connected as routing does', async () => { - ssh.failHost('box', 'ssh: Could not resolve hostname box'); - configure([{ name: 'box', ssh: 'box' }]); - const client = await host.session(); - await client.waitHost(1, (h) => h.status === 'reconnecting' || h.status === 'down'); - expect(await client.hubRequest({ t: 'discoverRepos', host: 1 })).toMatchObject({ - from: 'hub', - m: { t: 'error', req: 1, host: 1, code: 'host-unavailable' }, - }); - expect(await client.hubRequest({ t: 'discoverRepos', host: 9 })).toMatchObject({ - from: 'hub', - m: { t: 'error', code: 'unknown-host' }, - }); - }); - - it('answers discovery for an outdated host with version-mismatch', async () => { - await FakeDaemon.listen(host.socket, { protocol: 99 }); - const client = await host.session(); - await client.waitHost(0, (h) => h.status === 'outdated'); - expect(await client.hubRequest({ t: 'discoverRepos', host: 0 })).toMatchObject({ m: { t: 'error', code: 'version-mismatch' } }); - }); -}); - -describe('adding repos', () => { - it('adds a repo to config.json, answers done, then lists it in hosts', async () => { - const added = makeRepo(join(host.dir, 'added')); - const client = await host.session(); - await connected(client); - const from = client.mark(); - const reply = await client.hubRequest({ t: 'addRepo', host: 0, repo: added }); - expect(reply).toEqual({ from: 'hub', m: { t: 'done', req: 1 } }); - const listed = await client.waitHost(0, (h) => h.repos.includes(added), { from }); - expect(listed.repos).toEqual([repo, added]); - const doneAt = client.messages.findIndex((m, i) => i >= from && m.t === 'done'); - const hostsAt = client.messages.findIndex((m, i) => i >= from && m.t === 'hosts' && m.hosts[0]?.repos.includes(added) === true); - expect(doneAt).toBeLessThan(hostsAt); - expect(configFile()).toEqual({ port: host.port, repos: [repo, added] }); - }); - - it('refuses a directory that is not a repository’s main worktree with not-a-repo, leaving config.json unchanged', async () => { - const plain = join(host.dir, 'plain'); - mkdirSync(plain); - const before = readFileSync(host.configPath, 'utf8'); - const client = await host.session(); - await connected(client); - expect(await client.hubRequest({ t: 'addRepo', host: 0, repo: plain })).toMatchObject({ - from: 'hub', - m: { t: 'error', req: 1, host: 0, code: 'not-a-repo' }, - }); - expect(await client.hubRequest({ t: 'addRepo', host: 0, repo: wt })).toMatchObject({ m: { t: 'error', code: 'not-a-repo' } }); - expect(readFileSync(host.configPath, 'utf8')).toBe(before); - }); - - it('answers done without an edit for a repo already listed, as given or by a ~/ form', async () => { - symlinkSync(repo, join(host.home, 'linked')); - host.writeConfig({ port: host.port, repos: [repo, '~/linked'] }); - const before = readFileSync(host.configPath, 'utf8'); - const client = await host.session(); - await connected(client); - expect((await client.hubRequest({ t: 'addRepo', host: 0, repo })).m).toEqual({ t: 'done', req: 1 }); - expect((await client.hubRequest({ t: 'addRepo', host: 0, repo: join(host.home, 'linked') })).m).toEqual({ t: 'done', req: 2 }); - expect(readFileSync(host.configPath, 'utf8')).toBe(before); - }); - - it('lists a repo added to a remote host in every open session', async () => { - const { remote, remoteRepo } = await installedBox(); - const second = makeRepo(join(remote.home, 'src', 'second')); - const a = await host.session(); - const b = await host.session(); - await connectedHost(a, 1); - await connectedHost(b, 1); - const fromB = b.mark(); - expect((await a.hubRequest({ t: 'addRepo', host: 1, repo: second })).m).toEqual({ t: 'done', req: 1 }); - expect((await a.waitHost(1, (h) => h.repos.includes(second))).repos).toEqual([remoteRepo, second]); - expect((await b.waitHost(1, (h) => h.repos.includes(second), { from: fromB })).repos).toEqual([remoteRepo, second]); - expect(b.hosts()[0]?.repos).toEqual([repo]); - expect(configFile()).toEqual({ port: host.port, repos: [repo], hosts: [{ name: 'box', ssh: 'box', repos: [remoteRepo, second] }] }); - }); - - it('answers adding to a host that is not connected as routing does', async () => { - ssh.failHost('box', 'ssh: Could not resolve hostname box'); - configure([{ name: 'box', ssh: 'box' }]); - const client = await host.session(); - await client.waitHost(1, (h) => h.status === 'reconnecting' || h.status === 'down'); - expect(await client.hubRequest({ t: 'addRepo', host: 1, repo: '/srv/x' })).toMatchObject({ - m: { t: 'error', host: 1, code: 'host-unavailable' }, - }); - }); -}); - -describe('removing repos', () => { - it('removes a repo listed by a ~/ form, answers done, then drops it from hosts', async () => { - const app = makeRepo(join(host.home, 'app')); - host.writeConfig({ port: host.port, repos: [repo, '~/app'] }); - const client = await host.session(); - await connected(client); - expect(client.hosts()[0]?.repos).toEqual([repo, app]); - const from = client.mark(); - expect((await client.hubRequest({ t: 'removeRepo', host: 0, repo: app })).m).toEqual({ t: 'done', req: 1 }); - expect((await client.waitHost(0, (h) => !h.repos.includes(app), { from })).repos).toEqual([repo]); - expect(configFile()).toEqual({ port: host.port, repos: [repo] }); - }); - - it('refuses a repo with a running terminal with busy, leaving config.json unchanged', async () => { - const client = await watching(); - await client.create(0, wt, { command: 'exec sleep 60' }); - const before = readFileSync(host.configPath, 'utf8'); - expect(await client.hubRequest({ t: 'removeRepo', host: 0, repo })).toMatchObject({ - from: 'hub', - m: { t: 'error', req: 3, host: 0, code: 'busy' }, - }); - expect(readFileSync(host.configPath, 'utf8')).toBe(before); - }); - - it('refuses a repo with an exited terminal not yet closed, and removes it once closed', async () => { - const client = await watching(); - const term = await client.create(0, repo, { command: 'exit 0' }); - await client.waitEvent(0, 'termExited', (m) => m.termId === term.termId, { timeout: 10_000 }); - expect((await client.hubRequest({ t: 'removeRepo', host: 0, repo })).m).toMatchObject({ t: 'error', code: 'busy' }); - await client.ok(0, { t: 'closeTerm', termId: term.termId }); - expect((await client.hubRequest({ t: 'removeRepo', host: 0, repo })).m).toMatchObject({ t: 'done' }); - expect(configFile()).toEqual({ port: host.port, repos: [] }); - }); - - it('removes a listed repo whose directory no longer exists', async () => { - const gone = makeRepo(join(host.dir, 'gone')); - host.writeRepos([repo, gone]); - rmSync(gone, { recursive: true, force: true }); - const client = await host.session(); - await connected(client); - expect((await client.hubRequest({ t: 'removeRepo', host: 0, repo: gone })).m).toEqual({ t: 'done', req: 1 }); - expect(configFile()).toEqual({ port: host.port, repos: [repo] }); - }); - - it('answers done without an edit for a repo it does not list', async () => { - const before = readFileSync(host.configPath, 'utf8'); - const client = await host.session(); - await connected(client); - expect((await client.hubRequest({ t: 'removeRepo', host: 0, repo: join(host.dir, 'never') })).m).toEqual({ t: 'done', req: 1 }); - expect(readFileSync(host.configPath, 'utf8')).toBe(before); - }); -}); - -describe('editing presets', () => { - const HTOP = { name: 'htop', command: 'htop' }; - - it('adds a preset to config.json, answers done, then sends the presets to every open session', async () => { - host.presets = [SHELL]; - host.writeRepos([repo]); - const a = await host.session(); - const b = await host.session(); - const [fromA, fromB] = [a.mark(), b.mark()]; - expect(await a.hubRequest({ t: 'addPreset', preset: HTOP })).toEqual({ from: 'hub', m: { t: 'done', req: 1 } }); - for (const [client, from] of [ - [a, fromA], - [b, fromB], - ] as const) { - expect((await client.waitFor('presets', () => true, { from })).presets).toEqual([SHELL, HTOP]); - } - const doneAt = a.messages.findIndex((m, i) => i >= fromA && m.t === 'done'); - const presetsAt = a.messages.findIndex((m, i) => i >= fromA && m.t === 'presets'); - expect(doneAt).toBeLessThan(presetsAt); - expect(configFile()).toEqual({ port: host.port, repos: [repo], presets: [SHELL, HTOP] }); - }); - - it('removes a preset, writing the defaults without it when config.json had none', async () => { - host.writeConfig({ port: host.port, repos: [repo] }); - const client = await host.session(); - const from = client.mark(); - expect((await client.hubRequest({ t: 'removePreset', name: 'claude' })).m).toEqual({ t: 'done', req: 1 }); - const expected = [SHELL, { name: 'codex', command: 'codex' }]; - expect((await client.waitFor('presets', () => true, { from })).presets).toEqual(expected); - expect(configFile()).toEqual({ port: host.port, repos: [repo], presets: expected }); - }); - - it('moves a preset in config.json, answers done, then sends the new order to every open session', async () => { - host.presets = [SHELL, CLAUDE, HTOP]; - host.writeRepos([repo]); - const a = await host.session(); - const b = await host.session(); - const fromB = b.mark(); - expect((await a.hubRequest({ t: 'movePreset', name: 'htop', to: 0 })).m).toEqual({ t: 'done', req: 1 }); - expect((await b.waitFor('presets', () => true, { from: fromB })).presets).toEqual([HTOP, SHELL, CLAUDE]); - expect(configFile()).toEqual({ port: host.port, repos: [repo], presets: [HTOP, SHELL, CLAUDE] }); - }); - - it('answers done without sending presets for a name it does not list', async () => { - host.presets = [SHELL]; - host.writeRepos([repo]); - const before = readFileSync(host.configPath, 'utf8'); - const client = await host.session(); - expect((await client.hubRequest({ t: 'removePreset', name: 'nope' })).m).toEqual({ t: 'done', req: 1 }); - await client.expectNone('presets', () => true, 300); - expect(readFileSync(host.configPath, 'utf8')).toBe(before); - }); - - it.each([ - ['adding a taken name', { t: 'addPreset', preset: { name: 'shell', command: 'zsh' } }, 'a preset named shell exists'], - ['removing the last preset', { t: 'removePreset', name: 'shell' }, 'the last preset cannot be removed'], - ] as const)('refuses %s with internal, leaving config.json unchanged', async (_name, body, text) => { - host.presets = [SHELL]; - host.writeRepos([repo]); - const before = readFileSync(host.configPath, 'utf8'); - const client = await host.session(); - const reply = await client.hubRequest(body); - expect(reply).toMatchObject({ from: 'hub', m: { t: 'error', req: 1, host: null, code: 'internal' } }); - expect(reply.m.t === 'error' ? reply.m.message : '').toContain(text); - expect(readFileSync(host.configPath, 'utf8')).toBe(before); - }); -}); - -describe('configuration edits', () => { - it('refuses to edit an invalid config.json, naming the problem and leaving it unchanged', async () => { - const added = makeRepo(join(host.dir, 'added')); - const client = await host.session(); - await connected(client); - writeFileSync(host.configPath, JSON.stringify({ port: host.port, theme: 'dark' })); - const reply = await client.hubRequest({ t: 'addRepo', host: 0, repo: added }); - expect(reply).toMatchObject({ from: 'hub', m: { t: 'error', req: 1, code: 'internal' } }); - expect(reply.m.t === 'error' ? reply.m.message : '').toContain('theme'); - expect(configFile()).toEqual({ port: host.port, theme: 'dark' }); - }); - - it('refuses to edit a host the file no longer lists, leaving it unchanged', async () => { - const { remote } = await installedBox(); - const other = makeRepo(join(remote.home, 'src', 'other')); - const client = await host.session(); - await connectedHost(client, 1); - host.writeConfig({ port: host.port, repos: [repo] }); - const before = readFileSync(host.configPath, 'utf8'); - expect((await client.hubRequest({ t: 'addRepo', host: 1, repo: other })).m).toMatchObject({ t: 'error', code: 'internal' }); - expect(readFileSync(host.configPath, 'utf8')).toBe(before); - }); - - it('creates config.json owner-only to record a repo when there was none', async () => { - rmSync(host.configPath); - const added = makeRepo(join(host.dir, 'added')); - const client = await host.session(); - await connected(client); - expect((await client.hubRequest({ t: 'addRepo', host: 0, repo: added })).m).toEqual({ t: 'done', req: 1 }); - expect(configFile()).toEqual({ repos: [added] }); - }); - - it('changes nothing else in an open session’s snapshot', async () => { - const added = makeRepo(join(host.dir, 'added')); - const client = await host.session(); - await connected(client); - host.presets = [CLAUDE, SHELL]; - host.writeRepos([repo]); - const from = client.mark(); - expect((await client.hubRequest({ t: 'addRepo', host: 0, repo: added })).m).toEqual({ t: 'done', req: 1 }); - await client.waitHost(0, (h) => h.repos.includes(added), { from }); - expect(presetsOf(client)).toEqual([DEFAULT_PRESETS]); - }); -}); diff --git a/test/process/hub-server-codes.test.ts b/test/process/hub-server-codes.test.ts new file mode 100644 index 0000000..780f485 --- /dev/null +++ b/test/process/hub-server-codes.test.ts @@ -0,0 +1,61 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { sleep } from '../support/daemon-host.js'; +import { ANY_HEX64, header, host, refusal, useHubServerHost } from '../support/hub-server.js'; + +vi.setConfig({ testTimeout: 60_000, hookTimeout: 30_000 }); +useHubServerHost(); + +describe('one-time codes', () => { + beforeEach(async () => { + await host.startHub(); + }); + + it('issues a code response with Cache-Control no-store', async () => { + const response = await host.api('POST', '/api/code'); + expect(response.status).toBe(200); + expect(header(response, 'cache-control')).toEqual(['no-store']); + expect(JSON.parse(response.body)).toEqual({ code: ANY_HEX64 }); + }); + + it('issues a different code each time', async () => { + expect(await host.code()).not.toBe(await host.code()); + }); + + it('accepts the hub’s own Origin', async () => { + expect((await host.api('POST', '/api/code', { origin: host.origin })).status).toBe(200); + }); + + it.each([ + ['a wrong token', () => '0'.repeat(64)], + ['no token', () => null], + ['a malformed token', () => 'x'], + ])('refuses %s with 401', async (_name, token) => { + expect((await host.api('POST', '/api/code', { token: token() })).status).toBe(401); + }); + + it('refuses a cross-origin request with 403', async () => { + expect((await host.api('POST', '/api/code', { origin: 'http://evil.example' })).status).toBe(403); + }); + + it('accepts a body of exactly 1 KiB', async () => { + expect((await host.api('POST', '/api/code', { body: 'x'.repeat(1024) })).status).toBe(200); + }); + + it('refuses a body over 1 KiB', async () => { + const response = await host.api('POST', '/api/code', { body: 'x'.repeat(1025) }); + expect(response.status).toBeGreaterThanOrEqual(400); + expect(response.status).toBeLessThan(500); + }); +}); + +describe('WebSocket authentication', () => { + beforeEach(async () => { + await host.startHub(); + }); + + it('refuses a code issued more than 30 s earlier with 401', async () => { + const code = await host.code(); + await sleep(31_000); + expect(await refusal(host.open(['wtd', `wtd.code.${code}`]))).toBe(401); + }, 90_000); +}); diff --git a/test/process/hub-server-http.test.ts b/test/process/hub-server-http.test.ts new file mode 100644 index 0000000..af920d6 --- /dev/null +++ b/test/process/hub-server-http.test.ts @@ -0,0 +1,276 @@ +import { chmodSync, existsSync, mkdirSync, readdirSync, readFileSync, readlinkSync, statSync, symlinkSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { sleep, type WtdProcess } from '../support/daemon-host.js'; +import type { RawResponse } from '../support/hub-host.js'; +import { ANY_WORD, header, HEX64, host, packageVersion, useHubServerHost, waitExit, WEB } from '../support/hub-server.js'; + +vi.setConfig({ testTimeout: 60_000, hookTimeout: 30_000 }); +useHubServerHost(); + +const CSP = + "default-src 'self'; connect-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; frame-ancestors 'none'; base-uri 'none'; object-src 'none'"; + +/** Asserts one line on stderr, without a stack trace. */ +const expectOneLine = (stderr: string): void => { + expect(stderr.trimEnd()).not.toBe(''); + expect(stderr.trimEnd()).not.toContain('\n'); + expect(stderr).not.toMatch(/\n\s+at /); +}; + +const expectSecurityHeaders = (response: RawResponse): void => { + expect(header(response, 'content-security-policy')).toEqual([CSP]); + expect(header(response, 'x-content-type-options')).toEqual(['nosniff']); + expect(header(response, 'referrer-policy')).toEqual(['no-referrer']); +}; + +/** TCP sockets in LISTEN state owned by `pid`, as `address:port`. */ +const listeningSockets = (pid: number): string[] => { + const inodes = new Set(); + for (const fd of readdirSync(`/proc/${String(pid)}/fd`)) { + try { + const match = /^socket:\[(\d+)\]$/.exec(readlinkSync(`/proc/${String(pid)}/fd/${fd}`)); + if (match?.[1] !== undefined) inodes.add(match[1]); + } catch { + // The descriptor closed while we looked. + } + } + const found: string[] = []; + for (const table of ['tcp', 'tcp6']) { + for (const line of readFileSync(`/proc/net/${table}`, 'utf8').split('\n').slice(1)) { + const fields = line.trim().split(/\s+/); + const [, local, , state] = fields; + if (local === undefined || state !== '0A' || !inodes.has(fields[9] ?? '')) continue; + const [address = '', port = ''] = local.split(':'); + const ip = + table === 'tcp' ? Array.from({ length: 4 }, (_, i) => parseInt(address.slice(6 - 2 * i, 8 - 2 * i), 16)).join('.') : `[${address}]`; + found.push(`${ip}:${String(parseInt(port, 16))}`); + } + } + return found; +}; + +/** Leaves the state directory owner-only, as the hub would create it. */ +const prepareStateDir = (): void => { + mkdirSync(host.stateDir, { recursive: true, mode: 0o700 }); + chmodSync(host.stateDir, 0o700); +}; + +describe('loopback listener and hub record', () => { + it('listens on exactly one TCP socket, 127.0.0.1 at the configured port', async () => { + const hub = await host.startHub(); + expect(listeningSockets(hub.pid)).toEqual([`127.0.0.1:${String(host.port)}`]); + }); + + it('writes its record while running and removes it on SIGTERM', async () => { + const hub = await host.startHub(); + const record = host.record(); + expect(record).toEqual({ pid: hub.pid, port: host.port, instance: ANY_WORD }); + expect(statSync(host.recordPath).mode & 0o777).toBe(0o600); + hub.kill('SIGTERM'); + expect(await waitExit(hub)).toEqual({ code: 0, signal: null }); + expect(existsSync(host.recordPath)).toBe(false); + }); + + it('leaves a record naming another instance on exit', async () => { + const hub = await host.startHub(); + const other = { pid: hub.pid, port: host.port, instance: 'another_hub' }; + writeFileSync(host.recordPath, JSON.stringify(other)); + hub.kill('SIGTERM'); + await waitExit(hub); + expect(host.record()).toEqual(other); + }); +}); + +describe('persistent token', () => { + it('is 64 lowercase hex digits in an owner-only file', async () => { + await host.startHub(); + expect(host.token()).toMatch(HEX64); + expect(statSync(host.tokenPath).mode & 0o777).toBe(0o600); + }); + + it('persists across restarts', async () => { + const first = await host.startHub(); + const token = host.token(); + first.kill('SIGTERM'); + await waitExit(first); + await host.startHub(); + expect(host.token()).toBe(token); + expect((await host.api('GET', '/api/identity', { token })).status).toBe(200); + }); + + it('refuses to start when the token path is a symbolic link, naming it', async () => { + prepareStateDir(); + const target = join(host.dir, 'elsewhere'); + writeFileSync(target, 'a'.repeat(64), { mode: 0o600 }); + symlinkSync(target, host.tokenPath); + const hub = host.wtd(['hub']); + expect(await waitExit(hub)).toEqual({ code: 1, signal: null }); + expectOneLine(hub.stderr); + expect(hub.stderr).toContain(host.tokenPath); + expect(readFileSync(target, 'utf8')).toBe('a'.repeat(64)); + }); + + it('refuses to start when the token path is a directory, naming it', async () => { + prepareStateDir(); + mkdirSync(host.tokenPath); + const hub = host.wtd(['hub']); + expect(await waitExit(hub)).toEqual({ code: 1, signal: null }); + expectOneLine(hub.stderr); + expect(hub.stderr).toContain(host.tokenPath); + }); + + it('keeps a valid token with a loose mode and tightens it to 0600', async () => { + prepareStateDir(); + const token = '0123456789abcdef'.repeat(4); + writeFileSync(host.tokenPath, token, { mode: 0o644 }); + chmodSync(host.tokenPath, 0o644); + await host.startHub(); + expect(host.token()).toBe(token); + expect(statSync(host.tokenPath).mode & 0o777).toBe(0o600); + }); + + it.each([ + ['text', 'not a token'], + ['63 digits', 'a'.repeat(63)], + ['uppercase digits', 'A'.repeat(64)], + ['empty', ''], + ])('replaces a malformed token (%s) with a new valid one', async (_name, content) => { + prepareStateDir(); + writeFileSync(host.tokenPath, content, { mode: 0o600 }); + await host.startHub(); + expect(host.token()).toMatch(HEX64); + expect(statSync(host.tokenPath).mode & 0o777).toBe(0o600); + expect((await host.api('GET', '/api/identity')).status).toBe(200); + }); +}); + +describe('request checks and headers', () => { + beforeEach(async () => { + await host.startHub(); + }); + + it.each([ + ['localhost', () => `localhost:${String(host.port)}`], + ['a foreign name', () => 'evil.example'], + ['127.0.0.1 without the port', () => '127.0.0.1'], + ['another port', () => `127.0.0.1:${String(host.port + 1)}`], + ])('refuses a request whose Host is %s with 403', async (_name, value) => { + expect((await host.http('GET', '/', { host: value() })).status).toBe(403); + expect((await host.api('GET', '/api/identity')).status).toBe(200); + const identity = await host.http('GET', '/api/identity', { host: value(), headers: [['Authorization', `Bearer ${host.token()}`]] }); + expect(identity.status).toBe(403); + }); + + it('refuses a request without Host with 403', async () => { + expect((await host.http('GET', '/', { host: null })).status).toBe(403); + }); + + it('refuses a request with two Host headers with 403', async () => { + const own = `127.0.0.1:${String(host.port)}`; + expect((await host.http('GET', '/', { headers: [['Host', own]] })).status).toBe(403); + expect((await host.http('GET', '/', { headers: [['Host', 'evil.example']] })).status).toBe(403); + }); + + it('refuses an upgrade with a foreign Host with 403', async () => { + expect((await host.upgrade(['wtd', `wtd.token.${host.token()}`], { host: 'evil.example' })).status).toBe(403); + }); + + it('sends the security headers with index.html', async () => { + const response = await host.http('GET', '/'); + expect(response.status).toBe(200); + expectSecurityHeaders(response); + }); + + it('sends the security headers with every other response', async () => { + const asset = readdirSync(join(WEB, 'assets'))[0]; + if (asset === undefined) throw new Error('no web asset'); + for (const response of [ + await host.http('GET', `/assets/${asset}`), + await host.http('GET', '/nonexistent'), + await host.api('GET', '/api/identity'), + await host.api('GET', '/api/identity', { token: null }), + await host.http('GET', '/', { host: 'evil.example' }), + ]) { + expectSecurityHeaders(response); + } + }); +}); + +describe('static bundle', () => { + beforeEach(async () => { + await host.startHub(); + }); + + it('serves index.html at / uncached', async () => { + const response = await host.http('GET', '/'); + expect(response.status).toBe(200); + expect(header(response, 'cache-control')).toEqual(['no-cache']); + expect(header(response, 'content-type')[0]).toMatch(/^text\/html/); + expect(response.body).toBe(readFileSync(join(WEB, 'index.html'), 'utf8')); + }); + + it('serves every other bundle file as immutable', async () => { + const assets = readdirSync(join(WEB, 'assets')); + expect(assets.length).toBeGreaterThan(0); + for (const asset of assets) { + const response = await host.http('GET', `/assets/${asset}`); + expect(response.status).toBe(200); + expect(header(response, 'cache-control')).toEqual(['public, max-age=31536000, immutable']); + if (asset.endsWith('.js')) { + expect(header(response, 'content-type')[0]).toMatch(/javascript/); + expect(response.body).toBe(readFileSync(join(WEB, 'assets', asset), 'utf8')); + } + } + }); + + it.each(['/nonexistent', '/index.htm', '/assets/', '/assets/nonexistent.js', '/api', '/ws/x'])('answers %s with 404', async (path) => { + expect((await host.http('GET', path)).status).toBe(404); + }); + + it.each(['/../package.json', '/%2e%2e/package.json', '/assets/../index.html', '/%2fetc%2fpasswd', '/..%2fpackage.json'])( + 'refuses traversal %s with 404', + async (path) => { + expect((await host.http('GET', path)).status).toBe(404); + }, + ); +}); + +describe('identity and shutdown', () => { + let hub: WtdProcess; + + beforeEach(async () => { + hub = await host.startHub(); + }); + + it('reports the package version and the record’s instance', async () => { + expect(await host.identity()).toEqual({ version: packageVersion(), instance: host.record()?.instance }); + }); + + it('refuses identity without the token with 401 and from another origin with 403', async () => { + expect((await host.api('GET', '/api/identity', { token: null })).status).toBe(401); + expect((await host.api('GET', '/api/identity', { token: '1'.repeat(64) })).status).toBe(401); + expect((await host.api('GET', '/api/identity', { origin: 'http://evil.example' })).status).toBe(403); + }); + + it('refuses shutdown without the token with 401 and keeps running', async () => { + expect((await host.api('POST', '/api/shutdown', { token: null })).status).toBe(401); + expect((await host.api('POST', '/api/shutdown', { token: '1'.repeat(64) })).status).toBe(401); + await sleep(300); + expect(await host.serving()).toBe(true); + }); + + it('refuses shutdown from another origin with 403 and keeps running', async () => { + expect((await host.api('POST', '/api/shutdown', { origin: 'http://evil.example' })).status).toBe(403); + await sleep(300); + expect(await host.serving()).toBe(true); + }); + + it('answers an authenticated shutdown and then exits 0, removing its record', async () => { + const response = await host.api('POST', '/api/shutdown'); + expect(response.status).toBeGreaterThanOrEqual(200); + expect(response.status).toBeLessThan(300); + expect(await waitExit(hub)).toEqual({ code: 0, signal: null }); + expect(existsSync(host.recordPath)).toBe(false); + }); +}); diff --git a/test/process/hub-server-websocket.test.ts b/test/process/hub-server-websocket.test.ts new file mode 100644 index 0000000..ab1bb6f --- /dev/null +++ b/test/process/hub-server-websocket.test.ts @@ -0,0 +1,339 @@ +import { existsSync, readFileSync, rmSync } from 'node:fs'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { encodeWsData, MAX_FRAME, BROWSER_PROTOCOL_VERSION } from '../../src/protocol/index.js'; +import { sleep, type WtdProcess } from '../support/daemon-host.js'; +import type { HubClient, HubMessageOf } from '../support/hub-client.js'; +import { ANY_TEXT, header, host, outcome, packageVersion, refusal, useHubServerHost, waitExit } from '../support/hub-server.js'; + +vi.setConfig({ testTimeout: 60_000, hookTimeout: 30_000 }); +useHubServerHost(); + +/** The preset lists of every `presets` message received so far. */ +const presetsOf = (client: HubClient): HubMessageOf<'presets'>['presets'][] => + client.messages.flatMap((m) => (m.t === 'presets' ? [m.presets] : [])); + +describe('WebSocket authentication', () => { + let hub: WtdProcess; + let token: string; + + beforeEach(async () => { + hub = await host.startHub(); + token = host.token(); + }); + + it('accepts the token and selects the subprotocol wtd', async () => { + const client = await host.open(['wtd', `wtd.token.${token}`]); + expect(client.protocol).toBe('wtd'); + client.close(); + const raw = await host.upgrade(['wtd', `wtd.token.${token}`]); + expect(raw.status).toBe(101); + expect(header(raw, 'sec-websocket-protocol')).toEqual(['wtd']); + }); + + it('accepts the credential before wtd', async () => { + expect((await host.upgrade([`wtd.token.${token}`, 'wtd'])).status).toBe(101); + }); + + it.each([ + ['no Origin', null], + ['a foreign Origin', 'http://evil.example'], + ['localhost as Origin', 'http://localhost'], + ['another port as Origin', 'http://127.0.0.1:1'], + ])('refuses an upgrade with %s with 403', async (_name, origin) => { + expect(await refusal(host.open(['wtd', `wtd.token.${token}`], origin))).toBe(403); + }); + + it('refuses an upgrade with two Origin headers with 403', async () => { + const response = await host.http('GET', '/ws', { + headers: [ + ['Upgrade', 'websocket'], + ['Connection', 'Upgrade'], + ['Sec-WebSocket-Version', '13'], + ['Sec-WebSocket-Key', 'dGhlIHNhbXBsZSBub25jZQ=='], + ['Sec-WebSocket-Protocol', `wtd, wtd.token.${token}`], + ['Origin', host.origin], + ['Origin', host.origin], + ], + headersOnly: true, + }); + expect(response.status).toBe(403); + }); + + it.each([ + ['a wrong token', () => ['wtd', `wtd.token.${'0'.repeat(64)}`]], + ['a credential with another prefix', () => ['wtd', `xwtd.token.${token}`]], + ['a credential with a suffix', () => ['wtd', `wtd.token.${token}x`]], + ['an uppercase token', () => ['wtd', `wtd.token.${token.toUpperCase()}`]], + ['an uppercase prefix', () => ['wtd', `WTD.token.${token}`]], + ['a short token', () => ['wtd', `wtd.token.${token.slice(1)}`]], + ['two credentials', () => ['wtd', `wtd.token.${token}`, `wtd.code.${'a'.repeat(64)}`]], + ['the token twice', () => ['wtd', `wtd.token.${token}`, `wtd.token.${token}`]], + ['no credential', () => ['wtd']], + ['a credential without wtd', () => [`wtd.token.${token}`]], + ])('refuses %s and starts no session', async (_name, protocols) => { + // Raw upgrade: the ws client itself refuses to offer a duplicated subprotocol. + expect((await host.upgrade(protocols())).status).toBe(401); + await sleep(500); + expect(host.daemonPids()).toEqual([]); + }); + + it('refuses an upgrade without Sec-WebSocket-Protocol with 401', async () => { + expect((await host.upgrade(null)).status).toBe(401); + }); + + it('accepts upgrades only at /ws', async () => { + for (const path of ['/', '/ws/', '/wsx', '/api/code']) { + const response = await host.http('GET', path, { + headers: [ + ['Upgrade', 'websocket'], + ['Connection', 'Upgrade'], + ['Sec-WebSocket-Version', '13'], + ['Sec-WebSocket-Key', 'dGhlIHNhbXBsZSBub25jZQ=='], + ['Sec-WebSocket-Protocol', `wtd, wtd.token.${token}`], + ['Origin', host.origin], + ], + headersOnly: true, + }); + expect(response.status, path).not.toBe(101); + } + }); + + it('accepts a code once', async () => { + const code = await host.code(); + const client = await host.open(['wtd', `wtd.code.${code}`]); + expect(client.protocol).toBe('wtd'); + expect(await refusal(host.open(['wtd', `wtd.code.${code}`]))).toBe(401); + }); + + it('keeps a code presented by an upgrade the WebSocket handshake rejects', async () => { + const code = await host.code(); + const response = await host.http('GET', '/ws', { + headers: [ + ['Upgrade', 'websocket'], + ['Connection', 'Upgrade'], + ['Sec-WebSocket-Version', '12'], + ['Sec-WebSocket-Key', 'dGhlIHNhbXBsZSBub25jZQ=='], + ['Sec-WebSocket-Protocol', `wtd, wtd.code.${code}`], + ['Origin', host.origin], + ], + headersOnly: true, + }); + expect(response.status).toBe(400); + const client = await host.open(['wtd', `wtd.code.${code}`]); + expect(client.protocol).toBe('wtd'); + }); + + it('accepts exactly one of two concurrent upgrades presenting the same code', async () => { + const code = await host.code(); + const results = await Promise.all([outcome(host.open(['wtd', `wtd.code.${code}`])), outcome(host.open(['wtd', `wtd.code.${code}`]))]); + expect(results.filter((r) => typeof r !== 'number')).toHaveLength(1); + expect(results.filter((r) => typeof r === 'number')).toEqual([401]); + }); + + it('refuses an unknown code with 401', async () => { + expect(await refusal(host.open(['wtd', `wtd.code.${'b'.repeat(64)}`]))).toBe(401); + }); + + it('keeps at most 16 codes outstanding, dropping the oldest', async () => { + const codes: string[] = []; + for (let i = 0; i < 17; i++) codes.push(await host.code()); + expect(await refusal(host.open(['wtd', `wtd.code.${codes[0] ?? ''}`]))).toBe(401); + const newest = await host.open(['wtd', `wtd.code.${codes[16] ?? ''}`]); + expect(newest.protocol).toBe('wtd'); + const second = await host.open(['wtd', `wtd.code.${codes[1] ?? ''}`]); + expect(second.protocol).toBe('wtd'); + }); + + it('never records the token or codes it was offered', async () => { + const used = await host.code(); + const client = await host.open(['wtd', `wtd.code.${used}`]); + await client.handshake(); + const unused = await host.code(); + await outcome(host.open(['wtd', `wtd.token.${'0'.repeat(64)}`])); + await outcome(host.open(['wtd', `wtd.code.${used}`])); + await outcome(host.open(['wtd', `wtd.token.${token}`], 'http://evil.example')); + await outcome(host.open(['wtd', `wtd.token.${token}`, `wtd.code.${unused}`])); + hub.kill('SIGTERM'); + await waitExit(hub); + const logs = [hub.stdout, hub.stderr, existsSync(host.hubLogPath) ? readFileSync(host.hubLogPath, 'utf8') : ''].join('\n'); + for (const secret of [token, used, unused]) expect(logs).not.toContain(secret); + }); + + it('closes a session sending a message of MAX_FRAME + 1 bytes', async () => { + const client = await host.session(); + client.sendBinary(new Uint8Array(MAX_FRAME + 1)); + await client.waitClosed(10_000); + expect(client.closeCode).toBe(1009); + }); + + it('accepts a message of MAX_FRAME bytes, then rejects it as malformed', async () => { + const client = await host.session(); + client.sendBinary(new Uint8Array(MAX_FRAME)); + expect(await client.waitFor('error', () => true, { timeout: 10_000 })).toMatchObject({ code: 'bad-message' }); + expect(client.closeCode).not.toBe(1009); + }); +}); + +describe('session handshake', () => { + let token: string; + + beforeEach(async () => { + await host.startHub(); + token = host.token(); + }); + + it('sends hello with the protocol, package version and instance', async () => { + const client = await host.open(['wtd', `wtd.token.${token}`]); + expect(await client.waitFor('hello')).toEqual({ + t: 'hello', + protocol: BROWSER_PROTOCOL_VERSION, + version: packageVersion(), + instance: host.record()?.instance, + }); + }); + + it('sends the token right after hello to a code session', async () => { + const client = await host.open(['wtd', `wtd.code.${await host.code()}`]); + await client.waitFor('token'); + expect(client.messages.slice(0, 2).map((m) => m.t)).toEqual(['hello', 'token']); + expect(client.messages[1]).toEqual({ t: 'token', token }); + }); + + it('sends no token to a token session', async () => { + const client = await host.open(['wtd', `wtd.token.${token}`]); + await client.waitFor('hello'); + await client.expectNone('token', () => true, 300); + const from = client.mark(); + client.sendHello(); + await client.waitFor('hosts', () => true, { from }); + expect(client.messages.some((m) => m.t === 'token')).toBe(false); + }); + + it('sends hosts after the browser’s hello', async () => { + const client = await host.open(['wtd', `wtd.token.${token}`]); + await client.waitFor('hello'); + await client.expectNone('hosts', () => true, 300); + const { hosts } = await client.handshake(); + expect(hosts.hosts.map((h) => h.idx)).toEqual([0]); + }); + + it('sends presets and then hosts after the browser’s hello', async () => { + const client = await host.open(['wtd', `wtd.token.${token}`]); + await client.waitFor('hello'); + await client.expectNone('presets', () => true, 300); + const from = client.mark(); + client.sendHello(); + await client.waitFor('hosts', () => true, { from }); + expect(client.messages.slice(from, from + 2).map((m) => m.t)).toEqual(['presets', 'hosts']); + }); + + it('sends the default presets and no repos without a configuration file', async () => { + rmSync(host.configPath); + const client = await host.session(); + expect(presetsOf(client)).toEqual([ + [ + { name: 'shell', command: null }, + { name: 'claude', command: 'claude' }, + { name: 'codex', command: 'codex' }, + ], + ]); + expect(client.hosts()[0]?.repos).toEqual([]); + expect(existsSync(host.configPath)).toBe(false); + }); + + it('sends the shell, claude and codex presets when the configuration has no presets', async () => { + host.writeConfig({ port: host.port }); + const client = await host.session(); + expect(presetsOf(client)).toEqual([ + [ + { name: 'shell', command: null }, + { name: 'claude', command: 'claude' }, + { name: 'codex', command: 'codex' }, + ], + ]); + }); + + it('sends exactly the configured presets in their order', async () => { + host.writeConfig({ + port: host.port, + presets: [ + { name: 'claude', command: 'claude' }, + { name: 'shell', command: null }, + ], + }); + const client = await host.session(); + expect(presetsOf(client)).toEqual([ + [ + { name: 'claude', command: 'claude' }, + { name: 'shell', command: null }, + ], + ]); + }); + + it('answers a message before hello with bad-message and closes the session', async () => { + const client = await host.open(['wtd', `wtd.token.${token}`]); + await client.waitFor('hello'); + client.sendHost(0, { t: 'setVisible', termIds: [] }); + expect(await client.waitFor('error')).toEqual({ t: 'error', req: null, host: null, code: 'bad-message', message: ANY_TEXT }); + await client.waitClosed(); + }); + + it('answers a hello of another protocol with version-mismatch and closes the session', async () => { + const client = await host.open(['wtd', `wtd.token.${token}`]); + await client.waitFor('hello'); + client.sendHello(BROWSER_PROTOCOL_VERSION + 1); + expect(await client.waitFor('error')).toEqual({ + t: 'error', + req: null, + host: null, + code: 'version-mismatch', + message: ANY_TEXT, + }); + await client.waitClosed(); + }); +}); + +describe('malformed browser traffic', () => { + let a: HubClient; + let b: HubClient; + + beforeEach(async () => { + await host.startHub(); + a = await host.session(); + b = await host.session(); + }); + + /** Asserts `a` got bad-message and closed while `b` still answers. */ + const expectOnlyAClosed = async (): Promise => { + expect(await a.waitFor('error')).toMatchObject({ req: null, host: null, code: 'bad-message' }); + await a.waitClosed(); + const reply = await b.hubRequest({ t: 'reinstallDaemon', host: 0 }); + expect(reply.m).toMatchObject({ t: 'error', code: 'internal' }); + expect(b.isClosed).toBe(false); + }; + + it('closes only the session that sent invalid JSON', async () => { + a.sendText('{"t":'); + await expectOnlyAClosed(); + }); + + it('closes a session sending an unknown message', async () => { + a.sendJson({ t: 'frobnicate' }); + await expectOnlyAClosed(); + }); + + it('closes a session sending a second hello', async () => { + a.sendHello(); + await expectOnlyAClosed(); + }); + + it.each(['output', 'snapshot'] as const)('closes a session sending a %s frame', async (kind) => { + a.sendBinary(encodeWsData(0, { kind, termId: 1, offset: 0, data: new TextEncoder().encode('x') })); + await expectOnlyAClosed(); + }); + + it('closes a session sending a binary message shorter than its header', async () => { + a.sendBinary(new Uint8Array([3])); + await expectOnlyAClosed(); + }); +}); diff --git a/test/process/hub-server.test.ts b/test/process/hub-server.test.ts deleted file mode 100644 index e99a132..0000000 --- a/test/process/hub-server.test.ts +++ /dev/null @@ -1,716 +0,0 @@ -import { - chmodSync, - existsSync, - mkdirSync, - readdirSync, - readFileSync, - readlinkSync, - rmSync, - statSync, - symlinkSync, - writeFileSync, -} from 'node:fs'; -import { join } from 'node:path'; -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { encodeWsData, MAX_FRAME, BROWSER_PROTOCOL_VERSION } from '../../src/protocol/index.js'; -import { sleep, type WtdProcess } from '../support/daemon-host.js'; -import { UpgradeRefused, type HubClient, type HubMessageOf } from '../support/hub-client.js'; -import { HubHost, type RawResponse } from '../support/hub-host.js'; -import { REPO_ROOT } from '../support/exec.js'; - -vi.setConfig({ testTimeout: 60_000, hookTimeout: 30_000 }); - -const CSP = - "default-src 'self'; connect-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; frame-ancestors 'none'; base-uri 'none'; object-src 'none'"; - -const HEX64 = /^[0-9a-f]{64}$/; -const ANY_TEXT: unknown = expect.any(String); -const ANY_WORD: unknown = expect.stringMatching(/^\S+$/); -const ANY_HEX64: unknown = expect.stringMatching(HEX64); -const WEB = join(REPO_ROOT, 'dist', 'web'); - -let host: HubHost; - -beforeEach(async () => { - host = await HubHost.createHub(); -}); - -afterEach(async () => { - await host.cleanup(); -}); - -const packageVersion = (): string => { - const pkg: unknown = JSON.parse(readFileSync(join(REPO_ROOT, 'package.json'), 'utf8')); - if (typeof pkg !== 'object' || pkg === null || !('version' in pkg) || typeof pkg.version !== 'string') throw new Error('no version'); - return pkg.version; -}; - -/** Asserts one line on stderr, without a stack trace. */ -const expectOneLine = (stderr: string): void => { - expect(stderr.trimEnd()).not.toBe(''); - expect(stderr.trimEnd()).not.toContain('\n'); - expect(stderr).not.toMatch(/\n\s+at /); -}; - -const header = (response: RawResponse, name: string): string[] => response.headers.get(name) ?? []; - -/** The preset lists of every `presets` message received so far. */ -const presetsOf = (client: HubClient): HubMessageOf<'presets'>['presets'][] => - client.messages.flatMap((m) => (m.t === 'presets' ? [m.presets] : [])); - -const expectSecurityHeaders = (response: RawResponse): void => { - expect(header(response, 'content-security-policy')).toEqual([CSP]); - expect(header(response, 'x-content-type-options')).toEqual(['nosniff']); - expect(header(response, 'referrer-policy')).toEqual(['no-referrer']); -}; - -/** Resolves the upgrade's outcome: the open client, or the refusal status. */ -const outcome = async (opening: Promise): Promise => { - try { - return await opening; - } catch (error) { - if (error instanceof UpgradeRefused) return error.status; - throw error; - } -}; - -const refusal = async (opening: Promise): Promise => { - const result = await outcome(opening); - if (typeof result !== 'number') { - result.close(); - throw new Error('upgrade unexpectedly accepted'); - } - return result; -}; - -/** TCP sockets in LISTEN state owned by `pid`, as `address:port`. */ -const listeningSockets = (pid: number): string[] => { - const inodes = new Set(); - for (const fd of readdirSync(`/proc/${String(pid)}/fd`)) { - try { - const match = /^socket:\[(\d+)\]$/.exec(readlinkSync(`/proc/${String(pid)}/fd/${fd}`)); - if (match?.[1] !== undefined) inodes.add(match[1]); - } catch { - // The descriptor closed while we looked. - } - } - const found: string[] = []; - for (const table of ['tcp', 'tcp6']) { - for (const line of readFileSync(`/proc/net/${table}`, 'utf8').split('\n').slice(1)) { - const fields = line.trim().split(/\s+/); - const [, local, , state] = fields; - if (local === undefined || state !== '0A' || !inodes.has(fields[9] ?? '')) continue; - const [address = '', port = ''] = local.split(':'); - const ip = - table === 'tcp' ? Array.from({ length: 4 }, (_, i) => parseInt(address.slice(6 - 2 * i, 8 - 2 * i), 16)).join('.') : `[${address}]`; - found.push(`${ip}:${String(parseInt(port, 16))}`); - } - } - return found; -}; - -/** Leaves the state directory owner-only, as the hub would create it. */ -const prepareStateDir = (): void => { - mkdirSync(host.stateDir, { recursive: true, mode: 0o700 }); - chmodSync(host.stateDir, 0o700); -}; - -const waitExit = async (process_: WtdProcess): Promise<{ code: number | null; signal: NodeJS.Signals | null }> => { - const exit = await Promise.race([process_.exited, sleep(10_000).then(() => null)]); - if (exit === null) throw new Error('process did not exit'); - return exit; -}; - -describe('loopback listener and hub record', () => { - it('listens on exactly one TCP socket, 127.0.0.1 at the configured port', async () => { - const hub = await host.startHub(); - expect(listeningSockets(hub.pid)).toEqual([`127.0.0.1:${String(host.port)}`]); - }); - - it('writes its record while running and removes it on SIGTERM', async () => { - const hub = await host.startHub(); - const record = host.record(); - expect(record).toEqual({ pid: hub.pid, port: host.port, instance: ANY_WORD }); - expect(statSync(host.recordPath).mode & 0o777).toBe(0o600); - hub.kill('SIGTERM'); - expect(await waitExit(hub)).toEqual({ code: 0, signal: null }); - expect(existsSync(host.recordPath)).toBe(false); - }); - - it('leaves a record naming another instance on exit', async () => { - const hub = await host.startHub(); - const other = { pid: hub.pid, port: host.port, instance: 'another_hub' }; - writeFileSync(host.recordPath, JSON.stringify(other)); - hub.kill('SIGTERM'); - await waitExit(hub); - expect(host.record()).toEqual(other); - }); -}); - -describe('persistent token', () => { - it('is 64 lowercase hex digits in an owner-only file', async () => { - await host.startHub(); - expect(host.token()).toMatch(HEX64); - expect(statSync(host.tokenPath).mode & 0o777).toBe(0o600); - }); - - it('persists across restarts', async () => { - const first = await host.startHub(); - const token = host.token(); - first.kill('SIGTERM'); - await waitExit(first); - await host.startHub(); - expect(host.token()).toBe(token); - expect((await host.api('GET', '/api/identity', { token })).status).toBe(200); - }); - - it('refuses to start when the token path is a symbolic link, naming it', async () => { - prepareStateDir(); - const target = join(host.dir, 'elsewhere'); - writeFileSync(target, 'a'.repeat(64), { mode: 0o600 }); - symlinkSync(target, host.tokenPath); - const hub = host.wtd(['hub']); - expect(await waitExit(hub)).toEqual({ code: 1, signal: null }); - expectOneLine(hub.stderr); - expect(hub.stderr).toContain(host.tokenPath); - expect(readFileSync(target, 'utf8')).toBe('a'.repeat(64)); - }); - - it('refuses to start when the token path is a directory, naming it', async () => { - prepareStateDir(); - mkdirSync(host.tokenPath); - const hub = host.wtd(['hub']); - expect(await waitExit(hub)).toEqual({ code: 1, signal: null }); - expectOneLine(hub.stderr); - expect(hub.stderr).toContain(host.tokenPath); - }); - - it('keeps a valid token with a loose mode and tightens it to 0600', async () => { - prepareStateDir(); - const token = '0123456789abcdef'.repeat(4); - writeFileSync(host.tokenPath, token, { mode: 0o644 }); - chmodSync(host.tokenPath, 0o644); - await host.startHub(); - expect(host.token()).toBe(token); - expect(statSync(host.tokenPath).mode & 0o777).toBe(0o600); - }); - - it.each([ - ['text', 'not a token'], - ['63 digits', 'a'.repeat(63)], - ['uppercase digits', 'A'.repeat(64)], - ['empty', ''], - ])('replaces a malformed token (%s) with a new valid one', async (_name, content) => { - prepareStateDir(); - writeFileSync(host.tokenPath, content, { mode: 0o600 }); - await host.startHub(); - expect(host.token()).toMatch(HEX64); - expect(statSync(host.tokenPath).mode & 0o777).toBe(0o600); - expect((await host.api('GET', '/api/identity')).status).toBe(200); - }); -}); - -describe('request checks and headers', () => { - beforeEach(async () => { - await host.startHub(); - }); - - it.each([ - ['localhost', () => `localhost:${String(host.port)}`], - ['a foreign name', () => 'evil.example'], - ['127.0.0.1 without the port', () => '127.0.0.1'], - ['another port', () => `127.0.0.1:${String(host.port + 1)}`], - ])('refuses a request whose Host is %s with 403', async (_name, value) => { - expect((await host.http('GET', '/', { host: value() })).status).toBe(403); - expect((await host.api('GET', '/api/identity')).status).toBe(200); - const identity = await host.http('GET', '/api/identity', { host: value(), headers: [['Authorization', `Bearer ${host.token()}`]] }); - expect(identity.status).toBe(403); - }); - - it('refuses a request without Host with 403', async () => { - expect((await host.http('GET', '/', { host: null })).status).toBe(403); - }); - - it('refuses a request with two Host headers with 403', async () => { - const own = `127.0.0.1:${String(host.port)}`; - expect((await host.http('GET', '/', { headers: [['Host', own]] })).status).toBe(403); - expect((await host.http('GET', '/', { headers: [['Host', 'evil.example']] })).status).toBe(403); - }); - - it('refuses an upgrade with a foreign Host with 403', async () => { - expect((await host.upgrade(['wtd', `wtd.token.${host.token()}`], { host: 'evil.example' })).status).toBe(403); - }); - - it('sends the security headers with index.html', async () => { - const response = await host.http('GET', '/'); - expect(response.status).toBe(200); - expectSecurityHeaders(response); - }); - - it('sends the security headers with every other response', async () => { - const asset = readdirSync(join(WEB, 'assets'))[0]; - if (asset === undefined) throw new Error('no web asset'); - for (const response of [ - await host.http('GET', `/assets/${asset}`), - await host.http('GET', '/nonexistent'), - await host.api('GET', '/api/identity'), - await host.api('GET', '/api/identity', { token: null }), - await host.http('GET', '/', { host: 'evil.example' }), - ]) { - expectSecurityHeaders(response); - } - }); -}); - -describe('static bundle', () => { - beforeEach(async () => { - await host.startHub(); - }); - - it('serves index.html at / uncached', async () => { - const response = await host.http('GET', '/'); - expect(response.status).toBe(200); - expect(header(response, 'cache-control')).toEqual(['no-cache']); - expect(header(response, 'content-type')[0]).toMatch(/^text\/html/); - expect(response.body).toBe(readFileSync(join(WEB, 'index.html'), 'utf8')); - }); - - it('serves every other bundle file as immutable', async () => { - const assets = readdirSync(join(WEB, 'assets')); - expect(assets.length).toBeGreaterThan(0); - for (const asset of assets) { - const response = await host.http('GET', `/assets/${asset}`); - expect(response.status).toBe(200); - expect(header(response, 'cache-control')).toEqual(['public, max-age=31536000, immutable']); - if (asset.endsWith('.js')) { - expect(header(response, 'content-type')[0]).toMatch(/javascript/); - expect(response.body).toBe(readFileSync(join(WEB, 'assets', asset), 'utf8')); - } - } - }); - - it.each(['/nonexistent', '/index.htm', '/assets/', '/assets/nonexistent.js', '/api', '/ws/x'])('answers %s with 404', async (path) => { - expect((await host.http('GET', path)).status).toBe(404); - }); - - it.each(['/../package.json', '/%2e%2e/package.json', '/assets/../index.html', '/%2fetc%2fpasswd', '/..%2fpackage.json'])( - 'refuses traversal %s with 404', - async (path) => { - expect((await host.http('GET', path)).status).toBe(404); - }, - ); -}); - -describe('one-time codes', () => { - beforeEach(async () => { - await host.startHub(); - }); - - it('issues a code response with Cache-Control no-store', async () => { - const response = await host.api('POST', '/api/code'); - expect(response.status).toBe(200); - expect(header(response, 'cache-control')).toEqual(['no-store']); - expect(JSON.parse(response.body)).toEqual({ code: ANY_HEX64 }); - }); - - it('issues a different code each time', async () => { - expect(await host.code()).not.toBe(await host.code()); - }); - - it('accepts the hub’s own Origin', async () => { - expect((await host.api('POST', '/api/code', { origin: host.origin })).status).toBe(200); - }); - - it.each([ - ['a wrong token', () => '0'.repeat(64)], - ['no token', () => null], - ['a malformed token', () => 'x'], - ])('refuses %s with 401', async (_name, token) => { - expect((await host.api('POST', '/api/code', { token: token() })).status).toBe(401); - }); - - it('refuses a cross-origin request with 403', async () => { - expect((await host.api('POST', '/api/code', { origin: 'http://evil.example' })).status).toBe(403); - }); - - it('accepts a body of exactly 1 KiB', async () => { - expect((await host.api('POST', '/api/code', { body: 'x'.repeat(1024) })).status).toBe(200); - }); - - it('refuses a body over 1 KiB', async () => { - const response = await host.api('POST', '/api/code', { body: 'x'.repeat(1025) }); - expect(response.status).toBeGreaterThanOrEqual(400); - expect(response.status).toBeLessThan(500); - }); -}); - -describe('identity and shutdown', () => { - let hub: WtdProcess; - - beforeEach(async () => { - hub = await host.startHub(); - }); - - it('reports the package version and the record’s instance', async () => { - expect(await host.identity()).toEqual({ version: packageVersion(), instance: host.record()?.instance }); - }); - - it('refuses identity without the token with 401 and from another origin with 403', async () => { - expect((await host.api('GET', '/api/identity', { token: null })).status).toBe(401); - expect((await host.api('GET', '/api/identity', { token: '1'.repeat(64) })).status).toBe(401); - expect((await host.api('GET', '/api/identity', { origin: 'http://evil.example' })).status).toBe(403); - }); - - it('refuses shutdown without the token with 401 and keeps running', async () => { - expect((await host.api('POST', '/api/shutdown', { token: null })).status).toBe(401); - expect((await host.api('POST', '/api/shutdown', { token: '1'.repeat(64) })).status).toBe(401); - await sleep(300); - expect(await host.serving()).toBe(true); - }); - - it('refuses shutdown from another origin with 403 and keeps running', async () => { - expect((await host.api('POST', '/api/shutdown', { origin: 'http://evil.example' })).status).toBe(403); - await sleep(300); - expect(await host.serving()).toBe(true); - }); - - it('answers an authenticated shutdown and then exits 0, removing its record', async () => { - const response = await host.api('POST', '/api/shutdown'); - expect(response.status).toBeGreaterThanOrEqual(200); - expect(response.status).toBeLessThan(300); - expect(await waitExit(hub)).toEqual({ code: 0, signal: null }); - expect(existsSync(host.recordPath)).toBe(false); - }); -}); - -describe('WebSocket authentication', () => { - let hub: WtdProcess; - let token: string; - - beforeEach(async () => { - hub = await host.startHub(); - token = host.token(); - }); - - it('accepts the token and selects the subprotocol wtd', async () => { - const client = await host.open(['wtd', `wtd.token.${token}`]); - expect(client.protocol).toBe('wtd'); - client.close(); - const raw = await host.upgrade(['wtd', `wtd.token.${token}`]); - expect(raw.status).toBe(101); - expect(header(raw, 'sec-websocket-protocol')).toEqual(['wtd']); - }); - - it('accepts the credential before wtd', async () => { - expect((await host.upgrade([`wtd.token.${token}`, 'wtd'])).status).toBe(101); - }); - - it.each([ - ['no Origin', null], - ['a foreign Origin', 'http://evil.example'], - ['localhost as Origin', 'http://localhost'], - ['another port as Origin', 'http://127.0.0.1:1'], - ])('refuses an upgrade with %s with 403', async (_name, origin) => { - expect(await refusal(host.open(['wtd', `wtd.token.${token}`], origin))).toBe(403); - }); - - it('refuses an upgrade with two Origin headers with 403', async () => { - const response = await host.http('GET', '/ws', { - headers: [ - ['Upgrade', 'websocket'], - ['Connection', 'Upgrade'], - ['Sec-WebSocket-Version', '13'], - ['Sec-WebSocket-Key', 'dGhlIHNhbXBsZSBub25jZQ=='], - ['Sec-WebSocket-Protocol', `wtd, wtd.token.${token}`], - ['Origin', host.origin], - ['Origin', host.origin], - ], - headersOnly: true, - }); - expect(response.status).toBe(403); - }); - - it.each([ - ['a wrong token', () => ['wtd', `wtd.token.${'0'.repeat(64)}`]], - ['a credential with another prefix', () => ['wtd', `xwtd.token.${token}`]], - ['a credential with a suffix', () => ['wtd', `wtd.token.${token}x`]], - ['an uppercase token', () => ['wtd', `wtd.token.${token.toUpperCase()}`]], - ['an uppercase prefix', () => ['wtd', `WTD.token.${token}`]], - ['a short token', () => ['wtd', `wtd.token.${token.slice(1)}`]], - ['two credentials', () => ['wtd', `wtd.token.${token}`, `wtd.code.${'a'.repeat(64)}`]], - ['the token twice', () => ['wtd', `wtd.token.${token}`, `wtd.token.${token}`]], - ['no credential', () => ['wtd']], - ['a credential without wtd', () => [`wtd.token.${token}`]], - ])('refuses %s and starts no session', async (_name, protocols) => { - // Raw upgrade: the ws client itself refuses to offer a duplicated subprotocol. - expect((await host.upgrade(protocols())).status).toBe(401); - await sleep(500); - expect(host.daemonPids()).toEqual([]); - }); - - it('refuses an upgrade without Sec-WebSocket-Protocol with 401', async () => { - expect((await host.upgrade(null)).status).toBe(401); - }); - - it('accepts upgrades only at /ws', async () => { - for (const path of ['/', '/ws/', '/wsx', '/api/code']) { - const response = await host.http('GET', path, { - headers: [ - ['Upgrade', 'websocket'], - ['Connection', 'Upgrade'], - ['Sec-WebSocket-Version', '13'], - ['Sec-WebSocket-Key', 'dGhlIHNhbXBsZSBub25jZQ=='], - ['Sec-WebSocket-Protocol', `wtd, wtd.token.${token}`], - ['Origin', host.origin], - ], - headersOnly: true, - }); - expect(response.status, path).not.toBe(101); - } - }); - - it('accepts a code once', async () => { - const code = await host.code(); - const client = await host.open(['wtd', `wtd.code.${code}`]); - expect(client.protocol).toBe('wtd'); - expect(await refusal(host.open(['wtd', `wtd.code.${code}`]))).toBe(401); - }); - - it('keeps a code presented by an upgrade the WebSocket handshake rejects', async () => { - const code = await host.code(); - const response = await host.http('GET', '/ws', { - headers: [ - ['Upgrade', 'websocket'], - ['Connection', 'Upgrade'], - ['Sec-WebSocket-Version', '12'], - ['Sec-WebSocket-Key', 'dGhlIHNhbXBsZSBub25jZQ=='], - ['Sec-WebSocket-Protocol', `wtd, wtd.code.${code}`], - ['Origin', host.origin], - ], - headersOnly: true, - }); - expect(response.status).toBe(400); - const client = await host.open(['wtd', `wtd.code.${code}`]); - expect(client.protocol).toBe('wtd'); - }); - - it('accepts exactly one of two concurrent upgrades presenting the same code', async () => { - const code = await host.code(); - const results = await Promise.all([outcome(host.open(['wtd', `wtd.code.${code}`])), outcome(host.open(['wtd', `wtd.code.${code}`]))]); - expect(results.filter((r) => typeof r !== 'number')).toHaveLength(1); - expect(results.filter((r) => typeof r === 'number')).toEqual([401]); - }); - - it('refuses an unknown code with 401', async () => { - expect(await refusal(host.open(['wtd', `wtd.code.${'b'.repeat(64)}`]))).toBe(401); - }); - - it('refuses a code issued more than 30 s earlier with 401', async () => { - const code = await host.code(); - await sleep(31_000); - expect(await refusal(host.open(['wtd', `wtd.code.${code}`]))).toBe(401); - }, 90_000); - - it('keeps at most 16 codes outstanding, dropping the oldest', async () => { - const codes: string[] = []; - for (let i = 0; i < 17; i++) codes.push(await host.code()); - expect(await refusal(host.open(['wtd', `wtd.code.${codes[0] ?? ''}`]))).toBe(401); - const newest = await host.open(['wtd', `wtd.code.${codes[16] ?? ''}`]); - expect(newest.protocol).toBe('wtd'); - const second = await host.open(['wtd', `wtd.code.${codes[1] ?? ''}`]); - expect(second.protocol).toBe('wtd'); - }); - - it('never records the token or codes it was offered', async () => { - const used = await host.code(); - const client = await host.open(['wtd', `wtd.code.${used}`]); - await client.handshake(); - const unused = await host.code(); - await outcome(host.open(['wtd', `wtd.token.${'0'.repeat(64)}`])); - await outcome(host.open(['wtd', `wtd.code.${used}`])); - await outcome(host.open(['wtd', `wtd.token.${token}`], 'http://evil.example')); - await outcome(host.open(['wtd', `wtd.token.${token}`, `wtd.code.${unused}`])); - hub.kill('SIGTERM'); - await waitExit(hub); - const logs = [hub.stdout, hub.stderr, existsSync(host.hubLogPath) ? readFileSync(host.hubLogPath, 'utf8') : ''].join('\n'); - for (const secret of [token, used, unused]) expect(logs).not.toContain(secret); - }); - - it('closes a session sending a message of MAX_FRAME + 1 bytes', async () => { - const client = await host.session(); - client.sendBinary(new Uint8Array(MAX_FRAME + 1)); - await client.waitClosed(10_000); - expect(client.closeCode).toBe(1009); - }); - - it('accepts a message of MAX_FRAME bytes, then rejects it as malformed', async () => { - const client = await host.session(); - client.sendBinary(new Uint8Array(MAX_FRAME)); - expect(await client.waitFor('error', () => true, { timeout: 10_000 })).toMatchObject({ code: 'bad-message' }); - expect(client.closeCode).not.toBe(1009); - }); -}); - -describe('session handshake', () => { - let token: string; - - beforeEach(async () => { - await host.startHub(); - token = host.token(); - }); - - it('sends hello with the protocol, package version and instance', async () => { - const client = await host.open(['wtd', `wtd.token.${token}`]); - expect(await client.waitFor('hello')).toEqual({ - t: 'hello', - protocol: BROWSER_PROTOCOL_VERSION, - version: packageVersion(), - instance: host.record()?.instance, - }); - }); - - it('sends the token right after hello to a code session', async () => { - const client = await host.open(['wtd', `wtd.code.${await host.code()}`]); - await client.waitFor('token'); - expect(client.messages.slice(0, 2).map((m) => m.t)).toEqual(['hello', 'token']); - expect(client.messages[1]).toEqual({ t: 'token', token }); - }); - - it('sends no token to a token session', async () => { - const client = await host.open(['wtd', `wtd.token.${token}`]); - await client.waitFor('hello'); - await client.expectNone('token', () => true, 300); - const from = client.mark(); - client.sendHello(); - await client.waitFor('hosts', () => true, { from }); - expect(client.messages.some((m) => m.t === 'token')).toBe(false); - }); - - it('sends hosts after the browser’s hello', async () => { - const client = await host.open(['wtd', `wtd.token.${token}`]); - await client.waitFor('hello'); - await client.expectNone('hosts', () => true, 300); - const { hosts } = await client.handshake(); - expect(hosts.hosts.map((h) => h.idx)).toEqual([0]); - }); - - it('sends presets and then hosts after the browser’s hello', async () => { - const client = await host.open(['wtd', `wtd.token.${token}`]); - await client.waitFor('hello'); - await client.expectNone('presets', () => true, 300); - const from = client.mark(); - client.sendHello(); - await client.waitFor('hosts', () => true, { from }); - expect(client.messages.slice(from, from + 2).map((m) => m.t)).toEqual(['presets', 'hosts']); - }); - - it('sends the default presets and no repos without a configuration file', async () => { - rmSync(host.configPath); - const client = await host.session(); - expect(presetsOf(client)).toEqual([ - [ - { name: 'shell', command: null }, - { name: 'claude', command: 'claude' }, - { name: 'codex', command: 'codex' }, - ], - ]); - expect(client.hosts()[0]?.repos).toEqual([]); - expect(existsSync(host.configPath)).toBe(false); - }); - - it('sends the shell, claude and codex presets when the configuration has no presets', async () => { - host.writeConfig({ port: host.port }); - const client = await host.session(); - expect(presetsOf(client)).toEqual([ - [ - { name: 'shell', command: null }, - { name: 'claude', command: 'claude' }, - { name: 'codex', command: 'codex' }, - ], - ]); - }); - - it('sends exactly the configured presets in their order', async () => { - host.writeConfig({ - port: host.port, - presets: [ - { name: 'claude', command: 'claude' }, - { name: 'shell', command: null }, - ], - }); - const client = await host.session(); - expect(presetsOf(client)).toEqual([ - [ - { name: 'claude', command: 'claude' }, - { name: 'shell', command: null }, - ], - ]); - }); - - it('answers a message before hello with bad-message and closes the session', async () => { - const client = await host.open(['wtd', `wtd.token.${token}`]); - await client.waitFor('hello'); - client.sendHost(0, { t: 'setVisible', termIds: [] }); - expect(await client.waitFor('error')).toEqual({ t: 'error', req: null, host: null, code: 'bad-message', message: ANY_TEXT }); - await client.waitClosed(); - }); - - it('answers a hello of another protocol with version-mismatch and closes the session', async () => { - const client = await host.open(['wtd', `wtd.token.${token}`]); - await client.waitFor('hello'); - client.sendHello(BROWSER_PROTOCOL_VERSION + 1); - expect(await client.waitFor('error')).toEqual({ - t: 'error', - req: null, - host: null, - code: 'version-mismatch', - message: ANY_TEXT, - }); - await client.waitClosed(); - }); -}); - -describe('malformed browser traffic', () => { - let a: HubClient; - let b: HubClient; - - beforeEach(async () => { - await host.startHub(); - a = await host.session(); - b = await host.session(); - }); - - /** Asserts `a` got bad-message and closed while `b` still answers. */ - const expectOnlyAClosed = async (): Promise => { - expect(await a.waitFor('error')).toMatchObject({ req: null, host: null, code: 'bad-message' }); - await a.waitClosed(); - const reply = await b.hubRequest({ t: 'reinstallDaemon', host: 0 }); - expect(reply.m).toMatchObject({ t: 'error', code: 'internal' }); - expect(b.isClosed).toBe(false); - }; - - it('closes only the session that sent invalid JSON', async () => { - a.sendText('{"t":'); - await expectOnlyAClosed(); - }); - - it('closes a session sending an unknown message', async () => { - a.sendJson({ t: 'frobnicate' }); - await expectOnlyAClosed(); - }); - - it('closes a session sending a second hello', async () => { - a.sendHello(); - await expectOnlyAClosed(); - }); - - it.each(['output', 'snapshot'] as const)('closes a session sending a %s frame', async (kind) => { - a.sendBinary(encodeWsData(0, { kind, termId: 1, offset: 0, data: new TextEncoder().encode('x') })); - await expectOnlyAClosed(); - }); - - it('closes a session sending a binary message shorter than its header', async () => { - a.sendBinary(new Uint8Array([3])); - await expectOnlyAClosed(); - }); -}); diff --git a/test/support/daemon-server.ts b/test/support/daemon-server.ts new file mode 100644 index 0000000..5638cb4 --- /dev/null +++ b/test/support/daemon-server.ts @@ -0,0 +1,15 @@ +import { afterEach, beforeEach } from 'vitest'; +import { DaemonHost } from './daemon-host.js'; + +export let host: DaemonHost; + +/** Registers the per-test setup and cleanup. */ +export const useDaemonServerHost = (): void => { + beforeEach(() => { + host = DaemonHost.create(); + }); + + afterEach(async () => { + await host.cleanup(); + }); +}; diff --git a/test/support/daemon-terminals.ts b/test/support/daemon-terminals.ts new file mode 100644 index 0000000..dfc666d --- /dev/null +++ b/test/support/daemon-terminals.ts @@ -0,0 +1,66 @@ +import { join } from 'node:path'; +import { afterEach, beforeEach } from 'vitest'; +import type { Terminal } from '../../src/protocol/index.js'; +import type { DaemonClient } from './daemon-client.js'; +import { addWorktree, DaemonHost, makeRepo, sleep, type WtdProcess } from './daemon-host.js'; + +export let host: DaemonHost; +export let daemon: WtdProcess; +export let repo: string; +export let wt: string; +export let a: DaemonClient; +export let b: DaemonClient; + +/** Registers the per-test setup and cleanup. */ +export const useDaemonTerminalsHost = (): void => { + beforeEach(async () => { + host = DaemonHost.create(); + repo = makeRepo(join(host.dir, 'repo')); + wt = addWorktree(repo, join(host.dir, 'wt'), 'wt'); + daemon = await host.start(); + a = await host.client(); + b = await host.client(); + await a.watch(repo); + await b.watch(repo); + }); + + afterEach(async () => { + await host.cleanup(); + }); +}; + +export const KiB = 1024; +export const MiB = 1024 * KiB; + +/** + * Creates a terminal in `worktree` and attaches `client` to it. A command runs only once attached, + * so all its output follows the snapshot; an interactive shell is waited for until it has run a + * command and printed its prompt, so login start-up output is over. + */ +export const started = async ( + client: DaemonClient, + command: string | null, + { worktree = wt, cols = 80, rows = 24 }: { worktree?: string; cols?: number; rows?: number } = {}, +): Promise => { + const term = await client.create(worktree, { command: command === null ? null : `read -r _; ${command}`, cols, rows }); + await client.attach(term.termId); + if (command === null) { + client.sendInput(term.termId, 'echo READY-$((6*7))\r'); + await client.waitOutput(term.termId, 'READY-42\r\n', 10_000); + await sleep(300); + } else { + client.sendInput(term.termId, '\r'); + } + return term; +}; + +/** Restarts the daemon with extra environment, re-watching from fresh clients. */ +export const restart = async (env: Record = {}): Promise => { + a.send({ t: 'shutdown' }); + await daemon.exited; + daemon = await host.start(env); + a = await host.client(); + b = await host.client(); + await a.watch(repo); + await b.watch(repo); +}; diff --git a/test/support/hub-router.ts b/test/support/hub-router.ts new file mode 100644 index 0000000..02f2b8a --- /dev/null +++ b/test/support/hub-router.ts @@ -0,0 +1,116 @@ +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { afterEach, beforeEach, expect } from 'vitest'; +import type { Terminal } from '../../src/protocol/index.js'; +import { addWorktree, makeRepo, sleep, waitUntil, type WtdProcess } from './daemon-host.js'; +import { currentNodeDir, FakeSsh, installRemote, type FakeRemote } from './fake-ssh.js'; +import type { HubClient } from './hub-client.js'; +import { HubHost } from './hub-host.js'; +import { REPO_ROOT } from './exec.js'; + +export let host: HubHost; +export let hub: WtdProcess; +export let repo: string; +export let wt: string; +export let ssh: FakeSsh; + +/** Registers the per-test setup and cleanup. */ +export const useHubRouterHost = (): void => { + beforeEach(async () => { + host = await HubHost.createHub(); + ssh = new FakeSsh(join(host.dir, 'ssh')); + host.sshProgram = ssh.program; + repo = makeRepo(join(host.dir, 'repo')); + wt = addWorktree(repo, join(host.dir, 'wt'), 'wt'); + host.writeRepos([repo]); + hub = await host.startHub(); + }); + + afterEach(async () => { + // Stop the hub first, so it starts no SSH runs while the fake remotes go away. + for (const pid of host.hubPids()) process.kill(pid, 'SIGKILL'); + await waitUntil(() => host.hubPids().length === 0, 'hubs to exit').catch(() => undefined); + await ssh.cleanup(); + await host.cleanup(); + }); +}; + +export const ANY_TEXT: unknown = expect.any(String); +export const ANY_INSTANCE: unknown = expect.stringMatching(/^[A-Za-z0-9_-]{1,64}$/); + +export const KiB = 1024; +export const MiB = 1024 * KiB; + +export const packageVersion = (): string => { + const pkg: unknown = JSON.parse(readFileSync(join(REPO_ROOT, 'package.json'), 'utf8')); + if (typeof pkg !== 'object' || pkg === null || !('version' in pkg) || typeof pkg.version !== 'string') throw new Error('no version'); + return pkg.version; +}; + +export const connected = (client: HubClient, from = 0, timeout = 10_000): Promise<{ instance: string | null }> => + client.waitHost(0, (h) => h.status === 'connected', { from, timeout }); + +/** Instance of host 0 once `connected`. */ +export const instanceOf = async (client: HubClient, from = 0): Promise => { + const { instance } = await connected(client, from); + if (instance === null) throw new Error('connected without an instance'); + return instance; +}; + +/** Creates a terminal, attaches, and for a shell waits until it ran a command. */ +export const started = async (client: HubClient, command: string | null, worktree = wt): Promise => { + const term = await client.create(0, worktree, { command }); + await client.attach(0, term.termId); + if (command === null) { + client.sendInput(0, term.termId, 'echo READY-$((6*7))\r'); + await client.waitOutput(0, term.termId, 'READY-42\r\n', 10_000); + await sleep(300); + } + return term; +}; + +/** A connected session watching the repo. */ +export const watching = async (): Promise => { + const client = await host.session(); + await connected(client); + await client.watch(0, repo); + return client; +}; + +export const REMOTE_TIMEOUT = 20_000; +export const INSTALL_TIMEOUT = 150_000; + +export interface RemoteHostConfig { + name: string; + ssh: string; + repos?: readonly string[]; + roots?: readonly string[]; +} + +/** Writes `config.json` with the local repo, `hosts` and any other keys. */ +export const configure = (hosts: readonly RemoteHostConfig[], extra: Record = {}): void => { + host.writeConfig({ port: host.port, repos: [repo], hosts, ...extra }); +}; + +/** A remote whose PATH offers this runner's Node, so the hub's installer finds one there. */ +export const nodeRemote = (alias: string): FakeRemote => + ssh.addHost(alias, { path: `${currentNodeDir(join(ssh.dir, `node-${alias}`))}:${ssh.pathWithoutNode}` }); + +/** The remote `box`, installed, holding one repo, configured as host 1. */ +export const installedBox = async (): Promise<{ remote: FakeRemote; remoteRepo: string }> => { + const remote = nodeRemote('box'); + await installRemote(host, ssh, 'box'); + const remoteRepo = makeRepo(join(remote.home, 'src', 'app')); + configure([{ name: 'box', ssh: 'box', repos: [remoteRepo] }]); + return { remote, remoteRepo }; +}; + +export const connectedHost = (client: HubClient, idx: number, from = 0, timeout = REMOTE_TIMEOUT): Promise<{ instance: string | null }> => + client.waitHost(idx, (h) => h.status === 'connected', { from, timeout }); + +/** Instance of host `idx` once `connected`. */ +export const remoteInstance = async (client: HubClient, idx: number, from = 0): Promise => { + const { instance } = await connectedHost(client, idx, from); + if (instance === null) throw new Error('connected without an instance'); + return instance; +}; diff --git a/test/support/hub-server.ts b/test/support/hub-server.ts new file mode 100644 index 0000000..b76874b --- /dev/null +++ b/test/support/hub-server.ts @@ -0,0 +1,59 @@ +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { afterEach, beforeEach, expect } from 'vitest'; +import { sleep, type WtdProcess } from './daemon-host.js'; +import { UpgradeRefused, type HubClient } from './hub-client.js'; +import { HubHost, type RawResponse } from './hub-host.js'; +import { REPO_ROOT } from './exec.js'; + +export let host: HubHost; + +/** Registers the per-test setup and cleanup. */ +export const useHubServerHost = (): void => { + beforeEach(async () => { + host = await HubHost.createHub(); + }); + + afterEach(async () => { + await host.cleanup(); + }); +}; + +export const HEX64 = /^[0-9a-f]{64}$/; +export const ANY_TEXT: unknown = expect.any(String); +export const ANY_WORD: unknown = expect.stringMatching(/^\S+$/); +export const ANY_HEX64: unknown = expect.stringMatching(HEX64); +export const WEB = join(REPO_ROOT, 'dist', 'web'); + +export const packageVersion = (): string => { + const pkg: unknown = JSON.parse(readFileSync(join(REPO_ROOT, 'package.json'), 'utf8')); + if (typeof pkg !== 'object' || pkg === null || !('version' in pkg) || typeof pkg.version !== 'string') throw new Error('no version'); + return pkg.version; +}; + +export const header = (response: RawResponse, name: string): string[] => response.headers.get(name) ?? []; + +/** Resolves the upgrade's outcome: the open client, or the refusal status. */ +export const outcome = async (opening: Promise): Promise => { + try { + return await opening; + } catch (error) { + if (error instanceof UpgradeRefused) return error.status; + throw error; + } +}; + +export const refusal = async (opening: Promise): Promise => { + const result = await outcome(opening); + if (typeof result !== 'number') { + result.close(); + throw new Error('upgrade unexpectedly accepted'); + } + return result; +}; + +export const waitExit = async (process_: WtdProcess): Promise<{ code: number | null; signal: NodeJS.Signals | null }> => { + const exit = await Promise.race([process_.exited, sleep(10_000).then(() => null)]); + if (exit === null) throw new Error('process did not exit'); + return exit; +}; diff --git a/vitest.config.ts b/vitest.config.ts index 0af0300..7ae7f7f 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -4,7 +4,15 @@ export default defineConfig({ test: { projects: [ { test: { name: 'unit', include: ['src/**/*.test.ts'] } }, - { test: { name: 'process', include: ['test/process/**/*.test.ts'], globalSetup: ['test/process/global-setup.ts'] } }, + { + test: { + name: 'process', + include: ['test/process/**/*.test.ts'], + globalSetup: ['test/process/global-setup.ts'], + testTimeout: 60_000, + hookTimeout: 30_000, + }, + }, { test: { name: 'architecture', include: ['test/architecture/**/*.test.ts'], testTimeout: 300_000, hookTimeout: 120_000 } }, { test: { name: 'integration', include: ['test/integration/**/*.test.ts'] } }, ], From e7d9e195bbabd4a736c7fc0c8f130301ce315836 Mon Sep 17 00:00:00 2001 From: Egor Kraev Date: Sun, 18 Oct 2026 18:39:51 +0200 Subject: [PATCH 4/6] Replace exported fixture lets with a const fx object; assert config.json mode Clears Sonar S6861 on the four split-test support modules and S4624 in nodeRemote; the owner-only config test now checks the 0600 mode. DEV-2110 tracks moving these fixtures to Vitest test.extend. --- .../process/daemon-server-connections.test.ts | 48 +-- test/process/daemon-server-startup.test.ts | 104 +++--- .../daemon-terminals-attention.test.ts | 105 +++--- .../daemon-terminals-lifecycle.test.ts | 316 +++++++++--------- test/process/daemon-terminals-output.test.ts | 200 +++++------ test/process/daemon-terminals-unseen.test.ts | 140 ++++---- test/process/hub-router-config.test.ts | 267 +++++++-------- test/process/hub-router-links.test.ts | 81 ++--- test/process/hub-router-reinstall.test.ts | 25 +- test/process/hub-router-remote.test.ts | 93 +++--- test/process/hub-router-routing.test.ts | 59 ++-- test/process/hub-server-codes.test.ts | 24 +- test/process/hub-server-http.test.ts | 149 +++++---- test/process/hub-server-websocket.test.ts | 123 +++---- test/support/daemon-server.ts | 11 +- test/support/daemon-terminals.ts | 51 +-- test/support/hub-router.ts | 53 +-- test/support/hub-server.ts | 11 +- 18 files changed, 944 insertions(+), 916 deletions(-) diff --git a/test/process/daemon-server-connections.test.ts b/test/process/daemon-server-connections.test.ts index b4e7237..b3fdd00 100644 --- a/test/process/daemon-server-connections.test.ts +++ b/test/process/daemon-server-connections.test.ts @@ -13,7 +13,7 @@ import { waitUntil, type WtdProcess, } from '../support/daemon-host.js'; -import { host, useDaemonServerHost } from '../support/daemon-server.js'; +import { fx, useDaemonServerHost } from '../support/daemon-server.js'; vi.setConfig({ testTimeout: 60_000, hookTimeout: 30_000 }); useDaemonServerHost(); @@ -22,9 +22,9 @@ const MiB = 1024 * 1024; describe('multiple clients', () => { it('serves concurrent clients independently', async () => { - const repo = makeRepo(join(host.dir, 'repo')); - await host.start(); - const clients = await Promise.all([host.client(), host.client(), host.client()]); + const repo = makeRepo(join(fx.host.dir, 'repo')); + await fx.host.start(); + const clients = await Promise.all([fx.host.client(), fx.host.client(), fx.host.client()]); const states = await Promise.all(clients.map((c) => c.watch(repo))); for (const state of states) expect(state.worktrees.map((w) => w.path)).toEqual([repo]); const [a, b] = clients; @@ -37,12 +37,12 @@ describe('multiple clients', () => { describe('bounded connection queue', () => { it('keeps memory bounded and the PTY running for others when a client stops reading', async () => { - const repo = makeRepo(join(host.dir, 'repo')); - const daemon = await host.start(); - const reader = await host.client(); + const repo = makeRepo(join(fx.host.dir, 'repo')); + const daemon = await fx.host.start(); + const reader = await fx.host.client(); await reader.watch(repo); const term = await reader.create(repo, { command: 'yes 0123456789abcdefghijklmnopqrstuvwxyz' }); - const stalled = await host.client(); + const stalled = await fx.host.client(); await stalled.watch(repo); stalled.autoAck = false; await stalled.attach(term.termId); @@ -71,10 +71,10 @@ describe('bounded connection queue', () => { }); it('closes a connection whose unread replies exceed 64 MiB', async () => { - const repo = makeRepo(join(host.dir, 'repo')); + const repo = makeRepo(join(fx.host.dir, 'repo')); fakeWorktrees(repo, 1000, 200); - const daemon = await host.start(); - const flooder = await host.client(); + const daemon = await fx.host.start(); + const flooder = await fx.host.client(); const first = await flooder.watch(repo); expect(first.worktrees).toHaveLength(1001); const replySize = JSON.stringify(first).length; @@ -91,7 +91,7 @@ describe('bounded connection queue', () => { await flooder.waitClosed(30_000); expect(flooder.messages.filter((m) => m.t === 'repoState').length).toBeLessThan(requests); expect(peak - baseline).toBeLessThan(160 * MiB); - const other = await host.client(); + const other = await fx.host.client(); expect((await other.watch(repo)).worktrees).toHaveLength(1001); }); }); @@ -119,9 +119,9 @@ describe('shutdown', () => { ]; it.each(stopBy)('%s ends every terminal, removes the socket and exits 0', async (_name, stop) => { - const repo = makeRepo(join(host.dir, 'repo')); - const daemon = await host.start(); - const client = await host.client(); + const repo = makeRepo(join(fx.host.dir, 'repo')); + const daemon = await fx.host.start(); + const client = await fx.host.client(); await client.watch(repo); for (const name of ['a', 'b']) { await client.create(repo, { command: `echo $$ > ${name}.pid; exec sleep 600` }); @@ -134,28 +134,28 @@ describe('shutdown', () => { }, 'terminal pids'); stop(daemon, client); expect(await daemon.exited).toEqual({ code: 0, signal: null }); - expect(existsSync(host.socket)).toBe(false); + expect(existsSync(fx.host.socket)).toBe(false); await waitUntil(() => pids.every((pid) => !alive(pid)), 'terminal processes to end', 8000); }); it('completes a state write requested just before shutdown', async () => { - const repo = makeRepo(join(host.dir, 'repo')); - const wt = addWorktree(repo, join(host.dir, 'wt'), 'wt'); - const daemon = await host.start(); - const client = await host.client(); + const repo = makeRepo(join(fx.host.dir, 'repo')); + const wt = addWorktree(repo, join(fx.host.dir, 'wt'), 'wt'); + const daemon = await fx.host.start(); + const client = await fx.host.client(); await client.watch(repo); client.sendJson({ t: 'setChecked', req: 50, worktree: wt, checked: true }); client.send({ t: 'shutdown' }); expect((await daemon.exited).code).toBe(0); - await host.start(); - expect((await (await host.client()).watch(repo)).checked).toEqual([wt]); + await fx.host.start(); + expect((await (await fx.host.client()).watch(repo)).checked).toEqual([wt]); }); it('ignores SIGHUP', async () => { - const daemon = await host.start(); + const daemon = await fx.host.start(); daemon.kill('SIGHUP'); await sleep(300); expect(alive(daemon.pid)).toBe(true); - expect(await canConnect(host.socket)).toBe(true); + expect(await canConnect(fx.host.socket)).toBe(true); }); }); diff --git a/test/process/daemon-server-startup.test.ts b/test/process/daemon-server-startup.test.ts index a462cdd..4a901d1 100644 --- a/test/process/daemon-server-startup.test.ts +++ b/test/process/daemon-server-startup.test.ts @@ -6,7 +6,7 @@ import { encodeStreamData, DAEMON_PROTOCOL_VERSION } from '../../src/protocol/in import type { DaemonClient } from '../support/daemon-client.js'; import { REPO_ROOT } from '../support/exec.js'; import { alive, canConnect, makeRepo, sleep, waitUntil } from '../support/daemon-host.js'; -import { host, useDaemonServerHost } from '../support/daemon-server.js'; +import { fx, useDaemonServerHost } from '../support/daemon-server.js'; vi.setConfig({ testTimeout: 60_000, hookTimeout: 30_000 }); useDaemonServerHost(); @@ -37,82 +37,82 @@ const staleSocket = (path: string): void => { describe('socket', () => { it('is owner-only inside owner-only directories', async () => { - await host.start(); - expect(statSync(host.socket).isSocket()).toBe(true); - expect(statSync(host.socket).mode & 0o777).toBe(0o600); - expect(statSync(host.runDir).mode & 0o777).toBe(0o700); - expect(statSync(host.stateDir).mode & 0o777).toBe(0o700); + await fx.host.start(); + expect(statSync(fx.host.socket).isSocket()).toBe(true); + expect(statSync(fx.host.socket).mode & 0o777).toBe(0o600); + expect(statSync(fx.host.runDir).mode & 0o777).toBe(0o700); + expect(statSync(fx.host.stateDir).mode & 0o777).toBe(0o700); }); it('is replaced when left behind by a killed daemon', async () => { - const first = await host.start(); - const before = await (await host.client()).waitFor('hello'); + const first = await fx.host.start(); + const before = await (await fx.host.client()).waitFor('hello'); first.kill('SIGKILL'); await first.exited; - expect(existsSync(host.socket)).toBe(true); - await host.start(); - const after = await (await host.client()).waitFor('hello'); + expect(existsSync(fx.host.socket)).toBe(true); + await fx.host.start(); + const after = await (await fx.host.client()).waitFor('hello'); expect(after.instance).not.toBe(before.instance); }); it('is replaced when stale before the first start', async () => { - mkdirSync(host.runDir, { recursive: true, mode: 0o700 }); - staleSocket(host.socket); - await host.start(); - expect((await host.client()).isClosed).toBe(false); + mkdirSync(fx.host.runDir, { recursive: true, mode: 0o700 }); + staleSocket(fx.host.socket); + await fx.host.start(); + expect((await fx.host.client()).isClosed).toBe(false); }); it('is not removed on exit when it is no longer the one the daemon bound', async () => { - const daemon = await host.start(); - renameSync(host.socket, `${host.socket}.moved`); - writeFileSync(host.socket, 'someone else'); + const daemon = await fx.host.start(); + renameSync(fx.host.socket, `${fx.host.socket}.moved`); + writeFileSync(fx.host.socket, 'someone else'); daemon.kill('SIGTERM'); expect((await daemon.exited).code).toBe(0); - expect(readFileSync(host.socket, 'utf8')).toBe('someone else'); + expect(readFileSync(fx.host.socket, 'utf8')).toBe('someone else'); }); }); describe('single instance', () => { it('refuses a second daemon, leaving the first serving', async () => { - await host.start(); - const before = await (await host.client()).waitFor('hello'); - const second = host.wtd(['daemon']); + await fx.host.start(); + const before = await (await fx.host.client()).waitFor('hello'); + const second = fx.host.wtd(['daemon']); expect((await second.exited).code).toBe(1); expect(second.stderr).toBe('wtd daemon: already running\n'); - expect(await (await host.client()).waitFor('hello')).toEqual(before); + expect(await (await fx.host.client()).waitFor('hello')).toEqual(before); }); it('does not displace a suspended starter holding the start lock', async () => { - mkdirSync(host.runDir, { recursive: true, mode: 0o700 }); - staleSocket(host.socket); - const staleInode = inode(host.socket); + mkdirSync(fx.host.runDir, { recursive: true, mode: 0o700 }); + staleSocket(fx.host.socket); + const staleInode = inode(fx.host.socket); const holder = spawn('sleep', ['60']); const holderPid = holder.pid; if (holderPid === undefined) throw new Error('no holder'); try { process.kill(holderPid, 'SIGSTOP'); const lock = JSON.stringify({ pid: holderPid, nonce: 'suspended' }); - writeFileSync(host.lock, lock, { mode: 0o600 }); - const second = host.wtd(['daemon']); + writeFileSync(fx.host.lock, lock, { mode: 0o600 }); + const second = fx.host.wtd(['daemon']); await sleep(1000); - expect(inode(host.socket)).toBe(staleInode); - expect(await canConnect(host.socket)).toBe(false); - expect(readFileSync(host.lock, 'utf8')).toBe(lock); + expect(inode(fx.host.socket)).toBe(staleInode); + expect(await canConnect(fx.host.socket)).toBe(false); + expect(readFileSync(fx.host.lock, 'utf8')).toBe(lock); const exit = await second.exited; expect(exit.code).toBe(1); expectOneLine(second.stderr); - expect(inode(host.socket)).toBe(staleInode); + expect(inode(fx.host.socket)).toBe(staleInode); } finally { holder.kill('SIGKILL'); } await waitUntil(() => !alive(holderPid), 'the lock holder to die'); - await host.start(); - expect(await canConnect(host.socket)).toBe(true); + await fx.host.start(); + expect(await canConnect(fx.host.socket)).toBe(true); }); it('prints one line and exits 1 when the socket path is too long', async () => { - const long = join(host.dir, 'x'.repeat(100)); - const daemon = host.wtd(['daemon'], { XDG_STATE_HOME: long }); + const long = join(fx.host.dir, 'x'.repeat(100)); + const daemon = fx.host.wtd(['daemon'], { XDG_STATE_HOME: long }); expect((await daemon.exited).code).toBe(1); expectOneLine(daemon.stderr); expect(daemon.stderr).toContain(join(long, 'worktree-term', 'run')); @@ -121,9 +121,9 @@ describe('single instance', () => { describe('handshake', () => { it('greets every connection with hello for protocol 6, the package version and one instance id', async () => { - await host.start(); - const a = await host.rawClient(); - const b = await host.rawClient(); + await fx.host.start(); + const a = await fx.host.rawClient(); + const b = await fx.host.rawClient(); const helloA = await a.waitFor('hello'); const helloB = await b.waitFor('hello'); expect(a.received[0]).toEqual({ kind: 'message', message: helloA }); @@ -135,17 +135,17 @@ describe('handshake', () => { }); it('answers a request before hello with bad-message and closes', async () => { - await host.start(); - const client = await host.rawClient(); + await fx.host.start(); + const client = await fx.host.rawClient(); await client.waitFor('hello'); - client.sendJson({ t: 'watchRepo', req: 1, repo: host.dir }); + client.sendJson({ t: 'watchRepo', req: 1, repo: fx.host.dir }); expect(await client.waitFor('error')).toEqual({ t: 'error', req: null, code: 'bad-message', message: ANY_TEXT }); await client.waitClosed(); }); it('answers a broken stream before hello with bad-message and closes', async () => { - await host.start(); - const client = await host.rawClient(); + await fx.host.start(); + const client = await fx.host.rawClient(); await client.waitFor('hello'); client.sendRaw(new Uint8Array([0, 0, 0, 1, 9, 0])); expect((await client.waitFor('error')).code).toBe('bad-message'); @@ -153,10 +153,10 @@ describe('handshake', () => { }); it('lets a mismatched client do nothing but shut the daemon down', async () => { - const daemon = await host.start(); - const client = await host.rawClient(); + const daemon = await fx.host.start(); + const client = await fx.host.rawClient(); await client.handshake(1); - client.sendJson({ t: 'watchRepo', req: 1, repo: host.dir }); + client.sendJson({ t: 'watchRepo', req: 1, repo: fx.host.dir }); expect(await client.waitFor('error')).toEqual({ t: 'error', req: null, code: 'version-mismatch', message: ANY_TEXT }); const from = client.mark(); client.sendJson({ t: 'frobnicate' }); @@ -164,7 +164,7 @@ describe('handshake', () => { expect(client.isClosed).toBe(false); client.send({ t: 'shutdown' }); expect((await daemon.exited).code).toBe(0); - expect(existsSync(host.socket)).toBe(false); + expect(existsSync(fx.host.socket)).toBe(false); }); }); @@ -205,12 +205,12 @@ describe('malformed traffic', () => { }, ], ])('closes only the connection that sent %s', async (_name, misbehave) => { - const repo = makeRepo(join(host.dir, 'repo')); - await host.start(); - const good = await host.client(); + const repo = makeRepo(join(fx.host.dir, 'repo')); + await fx.host.start(); + const good = await fx.host.client(); await good.watch(repo); const term = await good.create(repo, { command: 'sleep 60' }); - const bad = await host.client(); + const bad = await fx.host.client(); misbehave(bad); expect(await bad.waitFor('error')).toEqual({ t: 'error', req: null, code: 'bad-message', message: ANY_TEXT }); await bad.waitClosed(); diff --git a/test/process/daemon-terminals-attention.test.ts b/test/process/daemon-terminals-attention.test.ts index fd48e51..f403a82 100644 --- a/test/process/daemon-terminals-attention.test.ts +++ b/test/process/daemon-terminals-attention.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it, vi } from 'vitest'; import type { Terminal } from '../../src/protocol/index.js'; import type { DaemonClient, EventOf } from '../support/daemon-client.js'; import { sleep, waitUntil } from '../support/daemon-host.js'; -import { a, b, host, repo, started, useDaemonTerminalsHost, wt } from '../support/daemon-terminals.js'; +import { fx, started, useDaemonTerminalsHost } from '../support/daemon-terminals.js'; vi.setConfig({ testTimeout: 60_000, hookTimeout: 30_000 }); useDaemonTerminalsHost(); @@ -19,11 +19,16 @@ describe('activity', () => { /** The terminal entry a fresh watcher of the repo receives. */ const entryOf = async (termId: number): Promise => - (await (await host.client()).watch(repo)).terminals.find((t) => t.termId === termId); + (await (await fx.host.client()).watch(fx.repo)).terminals.find((t) => t.termId === termId); it('sets input on a bell, also in the terminal entry', async () => { - const term = await a.create(wt, { command: String.raw`sleep 0.3; printf '\a'; exec sleep 60` }); - expect(await a.waitFor('activity', isInput(term.termId))).toEqual({ t: 'activity', termId: term.termId, unseen: true, state: 'input' }); + const term = await fx.a.create(fx.wt, { command: String.raw`sleep 0.3; printf '\a'; exec sleep 60` }); + expect(await fx.a.waitFor('activity', isInput(term.termId))).toEqual({ + t: 'activity', + termId: term.termId, + unseen: true, + state: 'input', + }); expect(await entryOf(term.termId)).toMatchObject({ unseen: true, state: 'input' }); }); @@ -32,51 +37,51 @@ describe('activity', () => { ['OSC 777', String.raw`printf '\033]777;notify;t;b\a'`], ['OSC 99', String.raw`printf '\033]99;;hi\033\\'`], ])('sets input on an %s notification', async (_name, print) => { - const term = await a.create(wt, { command: `sleep 0.3; ${print}; exec sleep 60` }); - expect(await b.waitFor('activity', isInput(term.termId))).toMatchObject({ state: 'input' }); + const term = await fx.a.create(fx.wt, { command: `sleep 0.3; ${print}; exec sleep 60` }); + expect(await fx.b.waitFor('activity', isInput(term.termId))).toMatchObject({ state: 'input' }); }); it('does not set input on a ConEmu progress report', async () => { - const from = b.mark(); - const term = await started(a, String.raw`printf '\033]9;4;1;50\a'; sleep 0.3; printf progress-done; exec sleep 60`); - await a.waitOutput(term.termId, 'progress-done'); + const from = fx.b.mark(); + const term = await started(fx.a, String.raw`printf '\033]9;4;1;50\a'; sleep 0.3; printf progress-done; exec sleep 60`); + await fx.a.waitOutput(term.termId, 'progress-done'); await sleep(700); - expect(activityOf(b, term.termId, from).filter((m) => m.state === 'input')).toEqual([]); + expect(activityOf(fx.b, term.termId, from).filter((m) => m.state === 'input')).toEqual([]); }); it('sets input once for an OSC 777 notification split across output chunks', async () => { - const from = b.mark(); + const from = fx.b.mark(); const term = await started( - a, + fx.a, String.raw`printf '\033]777;noti'; sleep 0.3; printf 'fy;t;b\a'; sleep 0.3; printf split-done; exec sleep 60`, ); - await a.waitOutput(term.termId, 'split-done'); + await fx.a.waitOutput(term.termId, 'split-done'); await sleep(500); - expect(a.view(term.termId).chunks.some((c) => Buffer.from(c).toString().endsWith('\x1b]777;noti'))).toBe(true); - expect(activityOf(b, term.termId, from).filter((m) => m.state === 'input')).toHaveLength(1); + expect(fx.a.view(term.termId).chunks.some((c) => Buffer.from(c).toString().endsWith('\x1b]777;noti'))).toBe(true); + expect(activityOf(fx.b, term.termId, from).filter((m) => m.state === 'input')).toHaveLength(1); }); it('ignores a bell answering a tab within 1 s and honours one after', async () => { - const from = b.mark(); + const from = fx.b.mark(); const term = await started( - a, + fx.a, String.raw`printf 'ready\n'; IFS= read -r -n1 _; printf '\a'; printf rang; sleep 1.5; printf '\a'; exec sleep 60`, ); - await a.waitOutput(term.termId, 'ready'); + await fx.a.waitOutput(term.termId, 'ready'); await sleep(1200); - a.sendInput(term.termId, '\t'); - await a.waitOutput(term.termId, 'rang'); + fx.a.sendInput(term.termId, '\t'); + await fx.a.waitOutput(term.termId, 'rang'); await sleep(500); - expect(activityOf(b, term.termId, from).filter((m) => m.state === 'input')).toEqual([]); - await b.waitFor('activity', isInput(term.termId), { from }); + expect(activityOf(fx.b, term.termId, from).filter((m) => m.state === 'input')).toEqual([]); + await fx.b.waitFor('activity', isInput(term.termId), { from }); }); it('clears input when a key is typed', async () => { - const term = await a.create(wt, { command: String.raw`stty -echo; sleep 0.3; printf '\a'; exec sleep 60` }); - await b.waitFor('activity', isInput(term.termId)); - const from = b.mark(); - a.sendInput(term.termId, 'y'); - expect(await b.waitFor('activity', (m) => m.termId === term.termId, { from })).toEqual({ + const term = await fx.a.create(fx.wt, { command: String.raw`stty -echo; sleep 0.3; printf '\a'; exec sleep 60` }); + await fx.b.waitFor('activity', isInput(term.termId)); + const from = fx.b.mark(); + fx.a.sendInput(term.termId, 'y'); + expect(await fx.b.waitFor('activity', (m) => m.termId === term.termId, { from })).toEqual({ t: 'activity', termId: term.termId, unseen: true, @@ -85,37 +90,37 @@ describe('activity', () => { }); it('keeps input on focus reports and passes them to the program', async () => { - const term = await a.create(wt, { command: String.raw`stty -echo -icanon; sleep 0.3; printf '\a'; exec cat -v` }); - await a.attach(term.termId); - await b.waitFor('activity', isInput(term.termId)); - const from = b.mark(); - a.sendInput(term.termId, '\x1b[O\x1b[I'); - await a.waitOutput(term.termId, '^[[O^[[I'); + const term = await fx.a.create(fx.wt, { command: String.raw`stty -echo -icanon; sleep 0.3; printf '\a'; exec cat -v` }); + await fx.a.attach(term.termId); + await fx.b.waitFor('activity', isInput(term.termId)); + const from = fx.b.mark(); + fx.a.sendInput(term.termId, '\x1b[O\x1b[I'); + await fx.a.waitOutput(term.termId, '^[[O^[[I'); await sleep(500); - expect(activityOf(b, term.termId, from)).toEqual([]); + expect(activityOf(fx.b, term.termId, from)).toEqual([]); expect(await entryOf(term.termId)).toMatchObject({ state: 'input' }); }); it('keeps input when the program answers a focus report 5 s after the signal at once', async () => { - const term = await a.create(wt, { + const term = await fx.a.create(fx.wt, { command: String.raw`stty -echo -icanon; sleep 0.3; printf '\a'; head -c 3 >/dev/null; printf '\033(B\017'; exec sleep 60`, }); - await a.attach(term.termId); - await b.waitFor('activity', isInput(term.termId)); + await fx.a.attach(term.termId); + await fx.b.waitFor('activity', isInput(term.termId)); await sleep(5000); - const from = b.mark(); - a.sendInput(term.termId, '\x1b[I'); - await waitUntil(() => a.view(term.termId).bytes().includes('\x1b(B\x0f'), 'the reply to the focus report'); + const from = fx.b.mark(); + fx.a.sendInput(term.termId, '\x1b[I'); + await waitUntil(() => fx.a.view(term.termId).bytes().includes('\x1b(B\x0f'), 'the reply to the focus report'); await sleep(500); - expect(activityOf(b, term.termId, from)).toEqual([]); + expect(activityOf(fx.b, term.termId, from)).toEqual([]); expect(await entryOf(term.termId)).toMatchObject({ state: 'input' }); }); it('clears input on output 1.5 s after the signal', async () => { - const term = await a.create(wt, { command: String.raw`sleep 0.3; printf '\a'; sleep 1.5; printf later; exec sleep 60` }); - await b.waitFor('activity', isInput(term.termId)); - const from = b.mark(); - expect(await b.waitFor('activity', (m) => m.termId === term.termId, { from })).toEqual({ + const term = await fx.a.create(fx.wt, { command: String.raw`sleep 0.3; printf '\a'; sleep 1.5; printf later; exec sleep 60` }); + await fx.b.waitFor('activity', isInput(term.termId)); + const from = fx.b.mark(); + expect(await fx.b.waitFor('activity', (m) => m.termId === term.termId, { from })).toEqual({ t: 'activity', termId: term.termId, unseen: true, @@ -124,12 +129,12 @@ describe('activity', () => { }); it('keeps input on output 0.5 s after the signal', async () => { - const term = await started(a, String.raw`sleep 1.3; printf '\a'; sleep 0.5; printf soon; exec sleep 60`); - await b.waitFor('activity', isInput(term.termId)); - const from = b.mark(); - await a.waitOutput(term.termId, 'soon'); + const term = await started(fx.a, String.raw`sleep 1.3; printf '\a'; sleep 0.5; printf soon; exec sleep 60`); + await fx.b.waitFor('activity', isInput(term.termId)); + const from = fx.b.mark(); + await fx.a.waitOutput(term.termId, 'soon'); await sleep(1000); - expect(activityOf(b, term.termId, from)).toEqual([]); + expect(activityOf(fx.b, term.termId, from)).toEqual([]); expect(await entryOf(term.termId)).toMatchObject({ state: 'input' }); }); }); diff --git a/test/process/daemon-terminals-lifecycle.test.ts b/test/process/daemon-terminals-lifecycle.test.ts index 0c65f29..ee69009 100644 --- a/test/process/daemon-terminals-lifecycle.test.ts +++ b/test/process/daemon-terminals-lifecycle.test.ts @@ -4,7 +4,7 @@ import { describe, expect, it, vi } from 'vitest'; import type { Layout } from '../../src/protocol/index.js'; import type { DaemonClient, Event, RequestBody } from '../support/daemon-client.js'; import { addWorktree, alive, git, sleep, waitUntil } from '../support/daemon-host.js'; -import { a, b, daemon, host, MiB, repo, restart, started, useDaemonTerminalsHost, wt } from '../support/daemon-terminals.js'; +import { fx, MiB, restart, started, useDaemonTerminalsHost } from '../support/daemon-terminals.js'; vi.setConfig({ testTimeout: 60_000, hookTimeout: 30_000 }); useDaemonTerminalsHost(); @@ -28,23 +28,24 @@ const countByte = (data: Uint8Array, byte: number): number => { return n; }; -const stateFile = (): unknown => JSON.parse(readFileSync(host.statePath, 'utf8')); +const stateFile = (): unknown => JSON.parse(readFileSync(fx.host.statePath, 'utf8')); -const stateMentions = (path: string): boolean => existsSync(host.statePath) && JSON.stringify(stateFile()).includes(JSON.stringify(path)); +const stateMentions = (path: string): boolean => + existsSync(fx.host.statePath) && JSON.stringify(stateFile()).includes(JSON.stringify(path)); describe('terminal creation', () => { it('starts a login interactive shell in the worktree', async () => { - const term = await started(a, null); - a.sendInput(term.termId, 'pwd\r'); - await a.waitOutput(term.termId, `${wt}\r\n`); - expect(a.view(term.termId).text()).toContain(`${wt}\r\n`); + const term = await started(fx.a, null); + fx.a.sendInput(term.termId, 'pwd\r'); + await fx.a.waitOutput(term.termId, `${fx.wt}\r\n`); + expect(fx.a.view(term.termId).text()).toContain(`${fx.wt}\r\n`); }); it('runs a command in a login interactive shell with TERM and COLORTERM set', async () => { const command = `case $- in *i*) echo mode-interactive;; esac; shopt -q login_shell && echo mode-login; echo "env=$TERM/$COLORTERM"; exec sleep 60`; - const term = await started(a, command); - await a.waitOutput(term.termId, 'env=xterm-256color/truecolor'); - const text = a.view(term.termId).text(); + const term = await started(fx.a, command); + await fx.a.waitOutput(term.termId, 'env=xterm-256color/truecolor'); + const text = fx.a.view(term.termId).text(); expect(text).toContain('mode-interactive'); expect(text).toContain('mode-login'); }); @@ -54,58 +55,65 @@ describe('terminal creation', () => { ['relative', 'bash'], ])('uses /bin/sh when SHELL is %s', async (_name, shell) => { await restart({ SHELL: shell }); - const term = await started(a, 'echo "shell=$0"; exec sleep 60'); - await a.waitOutput(term.termId, 'shell=/bin/sh'); + const term = await started(fx.a, 'echo "shell=$0"; exec sleep 60'); + await fx.a.waitOutput(term.termId, 'shell=/bin/sh'); }); it('answers the requester with its req and every other watcher with req null', async () => { - const outsider = await host.client(); - const from = b.mark(); - const reply = await a.request({ t: 'createTerm', worktree: wt, preset: 'my-preset', command: 'exec sleep 60', cols: 100, rows: 30 }); - const term = { termId: 1, worktree: wt, preset: 'my-preset', cols: 100, rows: 30, exit: null, unseen: false, state: 'idle' }; + const outsider = await fx.host.client(); + const from = fx.b.mark(); + const reply = await fx.a.request({ + t: 'createTerm', + worktree: fx.wt, + preset: 'my-preset', + command: 'exec sleep 60', + cols: 100, + rows: 30, + }); + const term = { termId: 1, worktree: fx.wt, preset: 'my-preset', cols: 100, rows: 30, exit: null, unseen: false, state: 'idle' }; expect(reply).toEqual({ t: 'termCreated', req: ANY_REQ, term }); - expect(await b.waitFor('termCreated', () => true, { from })).toEqual({ t: 'termCreated', req: null, term }); + expect(await fx.b.waitFor('termCreated', () => true, { from })).toEqual({ t: 'termCreated', req: null, term }); await outsider.expectNone('termCreated', () => true, 300); }); it('counts terminal ids up from 1 without reusing them', async () => { const ids = []; - for (let i = 0; i < 3; i++) ids.push((await a.create(wt, { command: 'exec sleep 60' })).termId); + for (let i = 0; i < 3; i++) ids.push((await fx.a.create(fx.wt, { command: 'exec sleep 60' })).termId); expect(ids).toEqual([1, 2, 3]); - await a.ok({ t: 'closeTerm', termId: 3 }); - expect((await a.create(wt, { command: 'exec sleep 60' })).termId).toBe(4); + await fx.a.ok({ t: 'closeTerm', termId: 3 }); + expect((await fx.a.create(fx.wt, { command: 'exec sleep 60' })).termId).toBe(4); }); it('does not attach the creator', async () => { - const term = await a.create(wt, { command: 'echo visible-output; exec sleep 60' }); + const term = await fx.a.create(fx.wt, { command: 'echo visible-output; exec sleep 60' }); await sleep(500); - expect(a.received.filter((r) => r.kind !== 'message' && r.termId === term.termId)).toEqual([]); + expect(fx.a.received.filter((r) => r.kind !== 'message' && r.termId === term.termId)).toEqual([]); }); it('fails with unknown-worktree for a worktree of a repo this connection does not watch', async () => { - const outsider = await host.client(); - expect(await outsider.fails({ t: 'createTerm', worktree: wt, preset: 'shell', command: null, cols: 80, rows: 24 })).toBe( + const outsider = await fx.host.client(); + expect(await outsider.fails({ t: 'createTerm', worktree: fx.wt, preset: 'shell', command: null, cols: 80, rows: 24 })).toBe( 'unknown-worktree', ); }); it('fails with unknown-worktree for a path that is not in the list', async () => { - expect(await a.fails({ t: 'createTerm', worktree: join(repo, 'sub'), preset: 'shell', command: null, cols: 80, rows: 24 })).toBe( + expect(await fx.a.fails({ t: 'createTerm', worktree: join(fx.repo, 'sub'), preset: 'shell', command: null, cols: 80, rows: 24 })).toBe( 'unknown-worktree', ); }); it('fails with spawn-failed when the worktree directory is gone', async () => { - const gone = addWorktree(repo, join(host.dir, 'gone'), 'gone'); - await a.waitFor('worktreesChanged', (m) => m.worktrees.some((w) => w.path === gone)); + const gone = addWorktree(fx.repo, join(fx.host.dir, 'gone'), 'gone'); + await fx.a.waitFor('worktreesChanged', (m) => m.worktrees.some((w) => w.path === gone)); rmSync(gone, { recursive: true }); - await a.waitFor('worktreesChanged', (m) => m.worktrees.some((w) => w.path === gone && w.prunable)); - expect(await a.fails({ t: 'createTerm', worktree: gone, preset: 'shell', command: null, cols: 80, rows: 24 })).toBe('spawn-failed'); + await fx.a.waitFor('worktreesChanged', (m) => m.worktrees.some((w) => w.path === gone && w.prunable)); + expect(await fx.a.fails({ t: 'createTerm', worktree: gone, preset: 'shell', command: null, cols: 80, rows: 24 })).toBe('spawn-failed'); }); it('fails with spawn-failed when the shell does not exist', async () => { - await restart({ SHELL: join(host.dir, 'no-such-shell') }); - expect(await a.fails({ t: 'createTerm', worktree: wt, preset: 'shell', command: null, cols: 80, rows: 24 })).toBe('spawn-failed'); + await restart({ SHELL: join(fx.host.dir, 'no-such-shell') }); + expect(await fx.a.fails({ t: 'createTerm', worktree: fx.wt, preset: 'shell', command: null, cols: 80, rows: 24 })).toBe('spawn-failed'); }); }); @@ -115,21 +123,21 @@ describe('terminal access', () => { ['detach', { t: 'detach', termId: 99 }], ['closeTerm', { t: 'closeTerm', termId: 99 }], ] as const)('fails %s of an unknown terminal with unknown-term', async (_name, body) => { - expect(await a.fails(body)).toBe('unknown-term'); + expect(await fx.a.fails(body)).toBe('unknown-term'); }); it('answers resize and input for an unknown terminal with an uncorrelated unknown-term', async () => { - let from = a.mark(); - a.send({ t: 'resize', termId: 99, cols: 80, rows: 24 }); - expect(await a.waitFor('error', () => true, { from })).toMatchObject({ req: null, code: 'unknown-term' }); - from = a.mark(); - a.sendInput(99, 'x'); - expect(await a.waitFor('error', () => true, { from })).toMatchObject({ req: null, code: 'unknown-term' }); + let from = fx.a.mark(); + fx.a.send({ t: 'resize', termId: 99, cols: 80, rows: 24 }); + expect(await fx.a.waitFor('error', () => true, { from })).toMatchObject({ req: null, code: 'unknown-term' }); + from = fx.a.mark(); + fx.a.sendInput(99, 'x'); + expect(await fx.a.waitFor('error', () => true, { from })).toMatchObject({ req: null, code: 'unknown-term' }); }); it('fails requests for a terminal of a repo the connection does not watch with not-watched', async () => { - const term = await a.create(wt, { command: 'exec sleep 60' }); - const outsider = await host.client(); + const term = await fx.a.create(fx.wt, { command: 'exec sleep 60' }); + const outsider = await fx.host.client(); expect(await outsider.fails({ t: 'attach', termId: term.termId })).toBe('not-watched'); expect(await outsider.fails({ t: 'closeTerm', termId: term.termId })).toBe('not-watched'); let from = outsider.mark(); @@ -143,66 +151,66 @@ describe('terminal access', () => { describe('terminal lifetime', () => { it('keeps running when its only client disconnects, keeping the output meanwhile', async () => { - const term = await started(a, 'sleep 0.8; echo MARKER-AFTER-LEAVE; exec sleep 60'); - a.close(); + const term = await started(fx.a, 'sleep 0.8; echo MARKER-AFTER-LEAVE; exec sleep 60'); + fx.a.close(); await sleep(1500); - const c = await host.client(); - const state = await c.watch(repo); + const c = await fx.host.client(); + const state = await c.watch(fx.repo); expect(state.terminals.find((t) => t.termId === term.termId)?.exit).toBeNull(); const view = await c.attach(term.termId); expect(await view.screen.text()).toContain('MARKER-AFTER-LEAVE'); }); it('keeps running when its client unwatches the repo', async () => { - const term = await started(a, 'exec sleep 60'); - await a.ok({ t: 'unwatchRepo', repo }); - await b.expectNone('termExited', () => true, 300); - expect((await b.watch(repo)).terminals.map((t) => t.termId)).toEqual([term.termId]); + const term = await started(fx.a, 'exec sleep 60'); + await fx.a.ok({ t: 'unwatchRepo', repo: fx.repo }); + await fx.b.expectNone('termExited', () => true, 300); + expect((await fx.b.watch(fx.repo)).terminals.map((t) => t.termId)).toEqual([term.termId]); }); it('delivers a final burst before termExited', async () => { - const term = await started(a, "read go; head -c 1048576 /dev/zero | tr '\\0' x; exit 0"); - a.sendInput(term.termId, 'go\r'); - await a.waitFor('termExited', (m) => m.termId === term.termId, { timeout: 30_000 }); - const exitedAt = a.received.findIndex((r) => r.kind === 'message' && r.message.t === 'termExited'); - const before = a.received + const term = await started(fx.a, "read go; head -c 1048576 /dev/zero | tr '\\0' x; exit 0"); + fx.a.sendInput(term.termId, 'go\r'); + await fx.a.waitFor('termExited', (m) => m.termId === term.termId, { timeout: 30_000 }); + const exitedAt = fx.a.received.findIndex((r) => r.kind === 'message' && r.message.t === 'termExited'); + const before = fx.a.received .slice(0, exitedAt) .reduce((n, r) => n + (r.kind === 'output' && r.termId === term.termId ? countByte(r.data, 0x78) : 0), 0); expect(before).toBe(MiB); }); it('reports the exit code to every watcher and keeps the terminal until closed', async () => { - const term = await a.create(wt, { command: 'exit 3' }); + const term = await fx.a.create(fx.wt, { command: 'exit 3' }); const expected = { t: 'termExited', termId: term.termId, code: 3, signal: null }; - expect(await a.waitFor('termExited')).toEqual(expected); - expect(await b.waitFor('termExited')).toEqual(expected); - const state = await (await host.client()).watch(repo); + expect(await fx.a.waitFor('termExited')).toEqual(expected); + expect(await fx.b.waitFor('termExited')).toEqual(expected); + const state = await (await fx.host.client()).watch(fx.repo); expect(state.terminals.find((t) => t.termId === term.termId)?.exit).toEqual({ code: 3, signal: null }); - await a.ok({ t: 'closeTerm', termId: term.termId }); + await fx.a.ok({ t: 'closeTerm', termId: term.termId }); }); it('reports the name of a fatal signal', async () => { - const term = await a.create(wt, { command: 'kill -KILL $$' }); - expect(await a.waitFor('termExited', (m) => m.termId === term.termId)).toMatchObject({ signal: 'SIGKILL' }); + const term = await fx.a.create(fx.wt, { command: 'kill -KILL $$' }); + expect(await fx.a.waitFor('termExited', (m) => m.termId === term.termId)).toMatchObject({ signal: 'SIGKILL' }); }); it('ends the shell and its background job on close, then reports termClosed before done', async () => { - const term = await a.create(wt, { command: 'sleep 600 & echo $! > bg.pid; echo $$ > sh.pid; wait' }); - const shell = await pidIn(wt, 'sh.pid'); - const job = await pidIn(wt, 'bg.pid'); - const from = a.mark(); - await a.ok({ t: 'closeTerm', termId: term.termId }); - expect(repliesFrom(a, from).map((m) => m.t)).toEqual(['termClosed', 'done']); - expect(await b.waitFor('termClosed')).toEqual({ t: 'termClosed', termId: term.termId }); + const term = await fx.a.create(fx.wt, { command: 'sleep 600 & echo $! > bg.pid; echo $$ > sh.pid; wait' }); + const shell = await pidIn(fx.wt, 'sh.pid'); + const job = await pidIn(fx.wt, 'bg.pid'); + const from = fx.a.mark(); + await fx.a.ok({ t: 'closeTerm', termId: term.termId }); + expect(repliesFrom(fx.a, from).map((m) => m.t)).toEqual(['termClosed', 'done']); + expect(await fx.b.waitFor('termClosed')).toEqual({ t: 'termClosed', termId: term.termId }); await waitUntil(() => !alive(shell) && !alive(job), 'shell and job to end', 7000); - expect((await b.watch(repo)).terminals).toEqual([]); - expect(await a.fails({ t: 'closeTerm', termId: term.termId })).toBe('unknown-term'); + expect((await fx.b.watch(fx.repo)).terminals).toEqual([]); + expect(await fx.a.fails({ t: 'closeTerm', termId: term.termId })).toBe('unknown-term'); }); it('kills a shell that ignores SIGHUP 5 s after close', async () => { - const term = await a.create(wt, { command: "trap '' HUP; echo $$ > sh.pid; while :; do sleep 0.2; done" }); - const shell = await pidIn(wt, 'sh.pid'); - await a.ok({ t: 'closeTerm', termId: term.termId }); + const term = await fx.a.create(fx.wt, { command: "trap '' HUP; echo $$ > sh.pid; while :; do sleep 0.2; done" }); + const shell = await pidIn(fx.wt, 'sh.pid'); + await fx.a.ok({ t: 'closeTerm', termId: term.termId }); await sleep(3000); expect(alive(shell)).toBe(true); await waitUntil(() => !alive(shell), 'the shell to be killed', 5000); @@ -216,77 +224,81 @@ describe('checked state and layouts', () => { }); it('broadcasts a checkbox change to every watcher before answering done', async () => { - const from = a.mark(); - const fromB = b.mark(); - await a.ok({ t: 'setChecked', worktree: wt, checked: true }); - expect(a.messages.slice(from)).toEqual([ - { t: 'checkedChanged', worktree: wt, checked: true }, + const from = fx.a.mark(); + const fromB = fx.b.mark(); + await fx.a.ok({ t: 'setChecked', worktree: fx.wt, checked: true }); + expect(fx.a.messages.slice(from)).toEqual([ + { t: 'checkedChanged', worktree: fx.wt, checked: true }, { t: 'done', req: ANY_REQ }, ]); - expect(await b.waitFor('checkedChanged', () => true, { from: fromB })).toEqual({ t: 'checkedChanged', worktree: wt, checked: true }); - expect((await (await host.client()).watch(repo)).checked).toEqual([wt]); - expect(stateMentions(wt)).toBe(true); - expect(statSync(host.statePath).mode & 0o777).toBe(0o600); + expect(await fx.b.waitFor('checkedChanged', () => true, { from: fromB })).toEqual({ + t: 'checkedChanged', + worktree: fx.wt, + checked: true, + }); + expect((await (await fx.host.client()).watch(fx.repo)).checked).toEqual([fx.wt]); + expect(stateMentions(fx.wt)).toBe(true); + expect(statSync(fx.host.statePath).mode & 0o777).toBe(0o600); }); it.each<[string, (worktree: string) => RequestBody]>([ ['setChecked', (worktree) => ({ t: 'setChecked', worktree, checked: true })], ['setLayout', (worktree) => ({ t: 'setLayout', worktree, layout: { tabs: [], active: 0 } })], ])('fails %s for a worktree not in a watched list with unknown-worktree', async (_name, body) => { - const outsider = await host.client(); - expect(await outsider.fails(body(wt))).toBe('unknown-worktree'); - expect(await a.fails(body(join(repo, 'sub')))).toBe('unknown-worktree'); + const outsider = await fx.host.client(); + expect(await outsider.fails(body(fx.wt))).toBe('unknown-worktree'); + expect(await fx.a.fails(body(join(fx.repo, 'sub')))).toBe('unknown-worktree'); }); it('broadcasts a layout of the worktree terminals and reports it in repoState', async () => { - const one = await a.create(wt, { command: 'exec sleep 60' }); - const two = await a.create(wt, { command: 'exec sleep 60' }); + const one = await fx.a.create(fx.wt, { command: 'exec sleep 60' }); + const two = await fx.a.create(fx.wt, { command: 'exec sleep 60' }); const layout = twoPanes(one.termId, two.termId); - const from = a.mark(); - await a.ok({ t: 'setLayout', worktree: wt, layout }); - expect(repliesFrom(a, from)).toEqual([ - { t: 'layoutChanged', worktree: wt, layout }, + const from = fx.a.mark(); + await fx.a.ok({ t: 'setLayout', worktree: fx.wt, layout }); + expect(repliesFrom(fx.a, from)).toEqual([ + { t: 'layoutChanged', worktree: fx.wt, layout }, { t: 'done', req: ANY_REQ }, ]); - await b.waitFor('layoutChanged', (m) => m.worktree === wt); - expect((await (await host.client()).watch(repo)).layouts).toEqual([{ worktree: wt, layout }]); + await fx.b.waitFor('layoutChanged', (m) => m.worktree === fx.wt); + expect((await (await fx.host.client()).watch(fx.repo)).layouts).toEqual([{ worktree: fx.wt, layout }]); }); it('rejects a layout holding a terminal of another worktree, keeping the stored one', async () => { - const own = await a.create(wt, { command: 'exec sleep 60' }); - const other = await a.create(repo, { command: 'exec sleep 60' }); + const own = await fx.a.create(fx.wt, { command: 'exec sleep 60' }); + const other = await fx.a.create(fx.repo, { command: 'exec sleep 60' }); const kept: Layout = { tabs: [{ id: 'only', root: { term: own.termId } }], active: 0 }; - await a.ok({ t: 'setLayout', worktree: wt, layout: kept }); - expect(await a.fails({ t: 'setLayout', worktree: wt, layout: twoPanes(own.termId, other.termId) })).toBe('unknown-term'); - expect(await a.fails({ t: 'setLayout', worktree: wt, layout: twoPanes(own.termId, 999) })).toBe('unknown-term'); - expect((await (await host.client()).watch(repo)).layouts).toEqual([{ worktree: wt, layout: kept }]); + await fx.a.ok({ t: 'setLayout', worktree: fx.wt, layout: kept }); + expect(await fx.a.fails({ t: 'setLayout', worktree: fx.wt, layout: twoPanes(own.termId, other.termId) })).toBe('unknown-term'); + expect(await fx.a.fails({ t: 'setLayout', worktree: fx.wt, layout: twoPanes(own.termId, 999) })).toBe('unknown-term'); + expect((await (await fx.host.client()).watch(fx.repo)).layouts).toEqual([{ worktree: fx.wt, layout: kept }]); }); it('replaces a split by its other half when a terminal in it is closed', async () => { - const one = await a.create(wt, { command: 'exec sleep 60' }); - const two = await a.create(wt, { command: 'exec sleep 60' }); - const fromSet = b.mark(); - await a.ok({ t: 'setLayout', worktree: wt, layout: twoPanes(one.termId, two.termId) }); - await b.waitFor('layoutChanged', () => true, { from: fromSet }); - const from = b.mark(); - await a.ok({ t: 'closeTerm', termId: one.termId }); - expect(await b.waitFor('layoutChanged', () => true, { from })).toEqual({ + const one = await fx.a.create(fx.wt, { command: 'exec sleep 60' }); + const two = await fx.a.create(fx.wt, { command: 'exec sleep 60' }); + const fromSet = fx.b.mark(); + await fx.a.ok({ t: 'setLayout', worktree: fx.wt, layout: twoPanes(one.termId, two.termId) }); + await fx.b.waitFor('layoutChanged', () => true, { from: fromSet }); + const from = fx.b.mark(); + await fx.a.ok({ t: 'closeTerm', termId: one.termId }); + expect(await fx.b.waitFor('layoutChanged', () => true, { from })).toEqual({ t: 'layoutChanged', - worktree: wt, + worktree: fx.wt, layout: { tabs: [{ id: 'main', root: { term: two.termId } }], active: 0 }, }); }); it('removes a tab left empty when its last terminal is closed', async () => { - const one = await a.create(wt, { command: 'exec sleep 60' }); - const fromSet = b.mark(); - await a.ok({ t: 'setLayout', worktree: wt, layout: { tabs: [{ id: 'solo', root: { term: one.termId } }], active: 0 } }); - await b.waitFor('layoutChanged', () => true, { from: fromSet }); - const from = b.mark(); - await a.ok({ t: 'closeTerm', termId: one.termId }); - expect(await b.waitFor('layoutChanged', () => true, { from })).toEqual({ + const one = await fx.a.create(fx.wt, { command: 'exec sleep 60' }); + const fromSet = fx.b.mark(); + await fx.a.ok({ t: 'setLayout', worktree: fx.wt, layout: { tabs: [{ id: 'solo', root: { term: one.termId } }], active: 0 } }); + await fx.b.waitFor('layoutChanged', () => true, { from: fromSet }); + const from = fx.b.mark(); + await fx.a.ok({ t: 'closeTerm', termId: one.termId }); + expect(await fx.b.waitFor('layoutChanged', () => true, { from })).toEqual({ t: 'layoutChanged', - worktree: wt, + worktree: fx.wt, layout: { tabs: [], active: 0 }, }); }); @@ -294,21 +306,21 @@ describe('checked state and layouts', () => { it.skipIf(process.getuid?.() === 0)( 'answers internal for a change that cannot be persisted, keeping state and broadcasting nothing', async () => { - const one = await a.create(wt, { command: 'exec sleep 60' }); - await a.ok({ t: 'setChecked', worktree: repo, checked: true }); - const before = await (await host.client()).watch(repo); - chmodSync(host.stateDir, 0o500); + const one = await fx.a.create(fx.wt, { command: 'exec sleep 60' }); + await fx.a.ok({ t: 'setChecked', worktree: fx.repo, checked: true }); + const before = await (await fx.host.client()).watch(fx.repo); + chmodSync(fx.host.stateDir, 0o500); try { - const fromB = b.mark(); - expect(await a.fails({ t: 'setChecked', worktree: wt, checked: true })).toBe('internal'); + const fromB = fx.b.mark(); + expect(await fx.a.fails({ t: 'setChecked', worktree: fx.wt, checked: true })).toBe('internal'); const layout: Layout = { tabs: [{ id: 'solo', root: { term: one.termId } }], active: 0 }; - expect(await a.fails({ t: 'setLayout', worktree: wt, layout })).toBe('internal'); + expect(await fx.a.fails({ t: 'setLayout', worktree: fx.wt, layout })).toBe('internal'); await sleep(300); - expect(b.messages.slice(fromB).filter((m) => m.t === 'checkedChanged' || m.t === 'layoutChanged')).toEqual([]); - const after = await (await host.client()).watch(repo); + expect(fx.b.messages.slice(fromB).filter((m) => m.t === 'checkedChanged' || m.t === 'layoutChanged')).toEqual([]); + const after = await (await fx.host.client()).watch(fx.repo); expect({ checked: after.checked, layouts: after.layouts }).toEqual({ checked: before.checked, layouts: before.layouts }); } finally { - chmodSync(host.stateDir, 0o700); + chmodSync(fx.host.stateDir, 0o700); } }, ); @@ -316,54 +328,54 @@ describe('checked state and layouts', () => { describe('persistence and pruning', () => { it('keeps checked worktrees across a restart and drops layout terminals', async () => { - const term = await a.create(wt, { command: 'exec sleep 60' }); - await a.ok({ t: 'setChecked', worktree: wt, checked: true }); - await a.ok({ t: 'setLayout', worktree: wt, layout: { tabs: [{ id: 'x', root: { term: term.termId } }], active: 0 } }); + const term = await fx.a.create(fx.wt, { command: 'exec sleep 60' }); + await fx.a.ok({ t: 'setChecked', worktree: fx.wt, checked: true }); + await fx.a.ok({ t: 'setLayout', worktree: fx.wt, layout: { tabs: [{ id: 'x', root: { term: term.termId } }], active: 0 } }); await restart(); - const state = await (await host.client()).watch(repo); - expect(state.checked).toEqual([wt]); - expect(state.layouts.find((l) => l.worktree === wt)?.layout.tabs ?? []).toEqual([]); + const state = await (await fx.host.client()).watch(fx.repo); + expect(state.checked).toEqual([fx.wt]); + expect(state.layouts.find((l) => l.worktree === fx.wt)?.layout.tabs ?? []).toEqual([]); expect(state.terminals).toEqual([]); }); it('prunes a removed worktree without terminals', async () => { - const gone = addWorktree(repo, join(host.dir, 'gone'), 'gone'); - await a.waitFor('worktreesChanged', (m) => m.worktrees.some((w) => w.path === gone)); - await a.ok({ t: 'setChecked', worktree: gone, checked: true }); + const gone = addWorktree(fx.repo, join(fx.host.dir, 'gone'), 'gone'); + await fx.a.waitFor('worktreesChanged', (m) => m.worktrees.some((w) => w.path === gone)); + await fx.a.ok({ t: 'setChecked', worktree: gone, checked: true }); expect(stateMentions(gone)).toBe(true); - git(repo, 'worktree', 'remove', gone); - await a.waitFor('worktreesChanged', (m) => !m.worktrees.some((w) => w.path === gone)); + git(fx.repo, 'worktree', 'remove', gone); + await fx.a.waitFor('worktreesChanged', (m) => !m.worktrees.some((w) => w.path === gone)); await waitUntil(() => !stateMentions(gone), 'the entry to be pruned', 2000); - expect((await (await host.client()).watch(repo)).checked).toEqual([]); + expect((await (await fx.host.client()).watch(fx.repo)).checked).toEqual([]); }); it('keeps a removed worktree with a live terminal until the terminal is closed', async () => { - const gone = addWorktree(repo, join(host.dir, 'gone'), 'gone'); - await a.waitFor('worktreesChanged', (m) => m.worktrees.some((w) => w.path === gone)); - const term = await a.create(gone, { command: 'exec sleep 600' }); - await a.ok({ t: 'setChecked', worktree: gone, checked: true }); - git(repo, 'worktree', 'remove', '--force', gone); - await a.waitFor('worktreesChanged', (m) => !m.worktrees.some((w) => w.path === gone)); + const gone = addWorktree(fx.repo, join(fx.host.dir, 'gone'), 'gone'); + await fx.a.waitFor('worktreesChanged', (m) => m.worktrees.some((w) => w.path === gone)); + const term = await fx.a.create(gone, { command: 'exec sleep 600' }); + await fx.a.ok({ t: 'setChecked', worktree: gone, checked: true }); + git(fx.repo, 'worktree', 'remove', '--force', gone); + await fx.a.waitFor('worktreesChanged', (m) => !m.worktrees.some((w) => w.path === gone)); await sleep(500); expect(stateMentions(gone)).toBe(true); - const state = await (await host.client()).watch(repo); + const state = await (await fx.host.client()).watch(fx.repo); expect(state.terminals.map((t) => t.worktree)).toEqual([gone]); - await a.ok({ t: 'closeTerm', termId: term.termId }); + await fx.a.ok({ t: 'closeTerm', termId: term.termId }); await waitUntil(() => !stateMentions(gone), 'the entry to be pruned', 2000); }); it('moves a corrupt state file aside and starts with no checked worktrees', async () => { - await a.ok({ t: 'setChecked', worktree: wt, checked: true }); - a.send({ t: 'shutdown' }); - await daemon.exited; - writeFileSync(host.statePath, '{"version":1,"repos":['); - await host.start(); + await fx.a.ok({ t: 'setChecked', worktree: fx.wt, checked: true }); + fx.a.send({ t: 'shutdown' }); + await fx.daemon.exited; + writeFileSync(fx.host.statePath, '{"version":1,"repos":['); + await fx.host.start(); // The socket answers before the state is loaded; connections wait for it. const names = await waitUntil(() => { - const moved = readdirSync(host.stateDir).filter((n) => n.startsWith('state.json.corrupt-')); + const moved = readdirSync(fx.host.stateDir).filter((n) => n.startsWith('state.json.corrupt-')); return moved.length > 0 && moved; }, 'the corrupt state file moved aside'); expect(names).toHaveLength(1); - expect((await (await host.client()).watch(repo)).checked).toEqual([]); + expect((await (await fx.host.client()).watch(fx.repo)).checked).toEqual([]); }); }); diff --git a/test/process/daemon-terminals-output.test.ts b/test/process/daemon-terminals-output.test.ts index 2837d24..d298fb5 100644 --- a/test/process/daemon-terminals-output.test.ts +++ b/test/process/daemon-terminals-output.test.ts @@ -4,7 +4,7 @@ import { describe, expect, it, vi } from 'vitest'; import { FLOW_HIGH, MAX_FRAME } from '../../src/protocol/index.js'; import { replay } from '../support/daemon-client.js'; import { sleep, waitUntil } from '../support/daemon-host.js'; -import { a, b, host, KiB, MiB, repo, started, useDaemonTerminalsHost, wt } from '../support/daemon-terminals.js'; +import { fx, KiB, MiB, started, useDaemonTerminalsHost } from '../support/daemon-terminals.js'; vi.setConfig({ testTimeout: 60_000, hookTimeout: 30_000 }); useDaemonTerminalsHost(); @@ -24,35 +24,35 @@ exec sleep 600 `; it('restores the same screen, cursor, modes and scrollback for a client attaching mid-output', async () => { - writeFileSync(join(wt, 'gen.sh'), GENERATOR); - const term = await started(a, 'bash ./gen.sh'); - a.sendInput(term.termId, 'go\r'); - await a.waitOutput(term.termId, 'PHASE-ONE', 20_000); - await b.attach(term.termId); - a.sendInput(term.termId, 'go\r'); - await a.waitOutput(term.termId, 'DONE-MARK', 20_000); - await b.waitOutput(term.termId, 'DONE-MARK', 20_000); - const seenByA = await a.view(term.termId).screen.state(); - const seenByB = await b.view(term.termId).screen.state(); + writeFileSync(join(fx.wt, 'gen.sh'), GENERATOR); + const term = await started(fx.a, 'bash ./gen.sh'); + fx.a.sendInput(term.termId, 'go\r'); + await fx.a.waitOutput(term.termId, 'PHASE-ONE', 20_000); + await fx.b.attach(term.termId); + fx.a.sendInput(term.termId, 'go\r'); + await fx.a.waitOutput(term.termId, 'DONE-MARK', 20_000); + await fx.b.waitOutput(term.termId, 'DONE-MARK', 20_000); + const seenByA = await fx.a.view(term.termId).screen.state(); + const seenByB = await fx.b.view(term.termId).screen.state(); expect(seenByA.buffer).toBe('alternate'); expect(seenByB).toEqual(seenByA); }); it('continues output exactly at the snapshot offset however attaches interleave with output', async () => { - const term = await started(a, 'read go; seq 1 400000; echo SEQ-DONE; exec sleep 600'); - a.sendInput(term.termId, 'go\r'); + const term = await started(fx.a, 'read go; seq 1 400000; echo SEQ-DONE; exec sleep 600'); + fx.a.sendInput(term.termId, 'go\r'); for (let i = 0; i < 8; i++) { - await b.attach(term.termId, 20_000); + await fx.b.attach(term.termId, 20_000); await sleep(40); } - await a.waitOutput(term.termId, 'SEQ-DONE', 30_000); + await fx.a.waitOutput(term.termId, 'SEQ-DONE', 30_000); // B's last attach may come after the output ended, leaving SEQ-DONE in its snapshot. - await waitUntil(async () => (await b.view(term.termId).screen.text()).includes('SEQ-DONE'), 'SEQ-DONE on B', 30_000); - const reference = a.view(term.termId); + await waitUntil(async () => (await fx.b.view(term.termId).screen.text()).includes('SEQ-DONE'), 'SEQ-DONE on B', 30_000); + const reference = fx.a.view(term.termId); const [first] = reference.attachments; if (first === undefined) throw new Error('A has no snapshot'); const all = reference.bytes(); - const view = b.view(term.termId); + const view = fx.b.view(term.termId); expect(view.gaps).toEqual([]); expect(reference.gaps).toEqual([]); for (const attachment of view.attachments) { @@ -75,9 +75,9 @@ exec sleep 600 it('shrinks the scrollback of a snapshot that would exceed MAX_FRAME', async () => { const awk = `awk 'BEGIN { for (l = 0; l < 5100; l++) { s = ""; for (c = 0; c < 500; c++) s = s "\\033[31ma\\033[32mb"; print s } }'; echo BIG-DONE`; - const term = await a.create(wt, { command: awk, cols: 1000, rows: 50 }); - await a.waitFor('termExited', (m) => m.termId === term.termId, { timeout: 90_000 }); - const view = await a.attach(term.termId, 60_000); + const term = await fx.a.create(fx.wt, { command: awk, cols: 1000, rows: 50 }); + await fx.a.waitFor('termExited', (m) => m.termId === term.termId, { timeout: 90_000 }); + const view = await fx.a.attach(term.termId, 60_000); const snapshot = view.attachments[0]?.snapshot; if (snapshot === undefined) throw new Error('no snapshot'); expect(snapshot.length).toBeGreaterThan(MiB); @@ -86,44 +86,48 @@ exec sleep 600 }, 180_000); it('re-attaches with a new snapshot when attaching while attached', async () => { - const term = await started(a, 'echo hello; exec sleep 60'); - await a.waitOutput(term.termId, 'hello'); - await a.attach(term.termId); - expect(a.view(term.termId).attachments).toHaveLength(2); - expect(await a.view(term.termId).screen.text()).toContain('hello'); + const term = await started(fx.a, 'echo hello; exec sleep 60'); + await fx.a.waitOutput(term.termId, 'hello'); + await fx.a.attach(term.termId); + expect(fx.a.view(term.termId).attachments).toHaveLength(2); + expect(await fx.a.view(term.termId).screen.text()).toContain('hello'); }); it('detaches whether or not attached, and stops output after detaching', async () => { - const term = await a.create(wt, { command: null }); - await a.ok({ t: 'detach', termId: term.termId }); - await a.attach(term.termId); - await a.ok({ t: 'detach', termId: term.termId }); - const count = a.received.length; - b.sendInput(term.termId, 'echo after-detach\r'); + const term = await fx.a.create(fx.wt, { command: null }); + await fx.a.ok({ t: 'detach', termId: term.termId }); + await fx.a.attach(term.termId); + await fx.a.ok({ t: 'detach', termId: term.termId }); + const count = fx.a.received.length; + fx.b.sendInput(term.termId, 'echo after-detach\r'); await sleep(500); - expect(a.received.slice(count).filter((r) => r.kind !== 'message')).toEqual([]); + expect(fx.a.received.slice(count).filter((r) => r.kind !== 'message')).toEqual([]); }); it('attaches to an exited terminal', async () => { - const term = await a.create(wt, { command: 'echo bye-bye; exit 0' }); - await a.waitFor('termExited', (m) => m.termId === term.termId); - const view = await a.attach(term.termId); + const term = await fx.a.create(fx.wt, { command: 'echo bye-bye; exit 0' }); + await fx.a.waitFor('termExited', (m) => m.termId === term.termId); + const view = await fx.a.attach(term.termId); expect(await view.screen.text()).toContain('bye-bye'); }); }); describe('output flow control', () => { it('evicts a client that never acks while an acking client receives every byte', async () => { - const term = await started(b, 'read go; seq 1 1500000; echo FLOOD-DONE'); - a.autoAck = false; - await a.attach(term.termId); + const term = await started(fx.b, 'read go; seq 1 1500000; echo FLOOD-DONE'); + fx.a.autoAck = false; + await fx.a.attach(term.termId); const goAt = Date.now(); - b.sendInput(term.termId, 'go\r'); - expect(await a.waitFor('detached', () => true, { timeout: 20_000 })).toEqual({ t: 'detached', termId: term.termId, reason: 'lagging' }); + fx.b.sendInput(term.termId, 'go\r'); + expect(await fx.a.waitFor('detached', () => true, { timeout: 20_000 })).toEqual({ + t: 'detached', + termId: term.termId, + reason: 'lagging', + }); expect(Date.now() - goAt).toBeGreaterThanOrEqual(1900); - expect(a.view(term.termId).bytes().length).toBeLessThan(FLOW_HIGH + 512 * KiB); - await b.waitFor('termExited', (m) => m.termId === term.termId, { timeout: 60_000 }); - const view = b.view(term.termId); + expect(fx.a.view(term.termId).bytes().length).toBeLessThan(FLOW_HIGH + 512 * KiB); + await fx.b.waitFor('termExited', (m) => m.termId === term.termId, { timeout: 60_000 }); + const view = fx.b.view(term.termId); expect(view.gaps).toEqual([]); const numbers = view .text() @@ -135,97 +139,97 @@ describe('output flow control', () => { }); it('lets an unwatched flood complete and shows its final screen to a later attach', async () => { - const term = await a.create(wt, { command: 'seq 1 2000000; echo NOBODY-DONE' }); - await a.waitFor('termExited', (m) => m.termId === term.termId, { timeout: 60_000 }); - const text = await (await a.attach(term.termId)).screen.text(); + const term = await fx.a.create(fx.wt, { command: 'seq 1 2000000; echo NOBODY-DONE' }); + await fx.a.waitFor('termExited', (m) => m.termId === term.termId, { timeout: 60_000 }); + const text = await (await fx.a.attach(term.termId)).screen.text(); expect(text).toContain('2000000'); expect(text).toContain('NOBODY-DONE'); }); it('closes a connection that acks beyond the output sent to it, leaving the terminal running', async () => { - const term = await started(a, 'echo some-output; exec sleep 60'); - await a.waitOutput(term.termId, 'some-output'); - a.send({ t: 'ack', termId: term.termId, offset: a.view(term.termId).position + MiB }); - expect(await a.waitFor('error')).toMatchObject({ req: null, code: 'bad-message' }); - await a.waitClosed(); - expect((await b.watch(repo)).terminals.find((t) => t.termId === term.termId)?.exit).toBeNull(); + const term = await started(fx.a, 'echo some-output; exec sleep 60'); + await fx.a.waitOutput(term.termId, 'some-output'); + fx.a.send({ t: 'ack', termId: term.termId, offset: fx.a.view(term.termId).position + MiB }); + expect(await fx.a.waitFor('error')).toMatchObject({ req: null, code: 'bad-message' }); + await fx.a.waitClosed(); + expect((await fx.b.watch(fx.repo)).terminals.find((t) => t.termId === term.termId)?.exit).toBeNull(); }); it('ignores an ack at or below the current ack position', async () => { - const term = await started(a, 'echo some-output; exec sleep 60'); - await a.waitOutput(term.termId, 'some-output'); - const position = a.view(term.termId).position; - a.send({ t: 'ack', termId: term.termId, offset: position }); - a.send({ t: 'ack', termId: term.termId, offset: position }); - a.send({ t: 'ack', termId: term.termId, offset: 0 }); - await a.expectNone('error', () => true, 400); - expect(a.isClosed).toBe(false); + const term = await started(fx.a, 'echo some-output; exec sleep 60'); + await fx.a.waitOutput(term.termId, 'some-output'); + const position = fx.a.view(term.termId).position; + fx.a.send({ t: 'ack', termId: term.termId, offset: position }); + fx.a.send({ t: 'ack', termId: term.termId, offset: position }); + fx.a.send({ t: 'ack', termId: term.termId, offset: 0 }); + await fx.a.expectNone('error', () => true, 400); + expect(fx.a.isClosed).toBe(false); }); }); describe('input', () => { it('echoes a command round trip', async () => { - const term = await started(a, null); - a.sendInput(term.termId, "echo h''i\r"); - await a.waitOutput(term.termId, 'hi\r\n'); - expect(a.view(term.termId).text()).toContain('hi\r\n'); + const term = await started(fx.a, null); + fx.a.sendInput(term.termId, "echo h''i\r"); + await fx.a.waitOutput(term.termId, 'hi\r\n'); + expect(fx.a.view(term.termId).text()).toContain('hi\r\n'); }); it('writes input frames to the PTY in order', async () => { - const term = await started(a, 'stty -echo; cat > in.txt'); + const term = await started(fx.a, 'stty -echo; cat > in.txt'); const lines = Array.from({ length: 300 }, (_, i) => `line ${String(i)}`); - for (const line of lines) a.sendInput(term.termId, `${line}\n`); - a.sendInput(term.termId, '\x04'); - await a.waitFor('termExited', (m) => m.termId === term.termId, { timeout: 10_000 }); - expect(readFileSync(join(wt, 'in.txt'), 'utf8')).toBe(lines.map((l) => `${l}\n`).join('')); + for (const line of lines) fx.a.sendInput(term.termId, `${line}\n`); + fx.a.sendInput(term.termId, '\x04'); + await fx.a.waitFor('termExited', (m) => m.termId === term.termId, { timeout: 10_000 }); + expect(readFileSync(join(fx.wt, 'in.txt'), 'utf8')).toBe(lines.map((l) => `${l}\n`).join('')); }); it('discards input to an exited terminal without an error', async () => { - const term = await a.create(wt, { command: 'exit 0' }); - await a.waitFor('termExited', (m) => m.termId === term.termId); - a.sendInput(term.termId, 'ignored\r'); - await a.expectNone('error', () => true, 400); - expect(a.isClosed).toBe(false); + const term = await fx.a.create(fx.wt, { command: 'exit 0' }); + await fx.a.waitFor('termExited', (m) => m.termId === term.termId); + fx.a.sendInput(term.termId, 'ignored\r'); + await fx.a.expectNone('error', () => true, 400); + expect(fx.a.isClosed).toBe(false); }); it('answers input beyond 1 MiB unaccepted with busy while still processing acks', async () => { - const stuck = await started(a, 'stty raw -echo; echo READY; exec sleep 600'); - await a.waitOutput(stuck.termId, 'READY'); - const flood = await started(a, 'yes flood-line'); - const before = a.view(flood.termId).position; - const from = a.mark(); + const stuck = await started(fx.a, 'stty raw -echo; echo READY; exec sleep 600'); + await fx.a.waitOutput(stuck.termId, 'READY'); + const flood = await started(fx.a, 'yes flood-line'); + const before = fx.a.view(flood.termId).position; + const from = fx.a.mark(); const frame = new Uint8Array(64 * KiB).fill(0x61); - for (let i = 0; i < 24; i++) a.sendInput(stuck.termId, frame); - await a.waitFor('error', (m) => m.code === 'busy', { from }); - await waitUntil(() => a.view(flood.termId).position - before > 4 * FLOW_HIGH, 'flood output past the flow window', 10_000); - const busy = a.messages.slice(from).filter((m) => m.t === 'error'); + for (let i = 0; i < 24; i++) fx.a.sendInput(stuck.termId, frame); + await fx.a.waitFor('error', (m) => m.code === 'busy', { from }); + await waitUntil(() => fx.a.view(flood.termId).position - before > 4 * FLOW_HIGH, 'flood output past the flow window', 10_000); + const busy = fx.a.messages.slice(from).filter((m) => m.t === 'error'); expect(busy.every((m) => m.req === null && m.code === 'busy')).toBe(true); expect(busy.length).toBeGreaterThanOrEqual(1); expect(busy.length).toBeLessThanOrEqual(8); - expect(a.isClosed).toBe(false); + expect(fx.a.isClosed).toBe(false); }); }); describe('resize', () => { it('sets the size the program sees, the last resize winning', async () => { - const term = await started(a, null); - a.resize(term.termId, 90, 20); - a.resize(term.termId, 100, 30); - a.sendInput(term.termId, 'stty size\r'); - await a.waitOutput(term.termId, '30 100\r\n'); - const state = await (await host.client()).watch(repo); + const term = await started(fx.a, null); + fx.a.resize(term.termId, 90, 20); + fx.a.resize(term.termId, 100, 30); + fx.a.sendInput(term.termId, 'stty size\r'); + await fx.a.waitOutput(term.termId, '30 100\r\n'); + const state = await (await fx.host.client()).watch(fx.repo); expect(state.terminals.find((t) => t.termId === term.termId)).toMatchObject({ cols: 100, rows: 30 }); }); it('resizes the mirror, so snapshots have the new size', async () => { - const term = await started(a, null); - a.resize(term.termId, 100, 30); - a.sendInput(term.termId, `printf '%099d|\\n' 7\r`); - await a.waitOutput(term.termId, '7|\r\n'); - const c = await host.client(); - await c.watch(repo); + const term = await started(fx.a, null); + fx.a.resize(term.termId, 100, 30); + fx.a.sendInput(term.termId, `printf '%099d|\\n' 7\r`); + await fx.a.waitOutput(term.termId, '7|\r\n'); + const c = await fx.host.client(); + await c.watch(fx.repo); const view = await c.attach(term.termId); expect(await view.screen.text()).toContain(`${'0'.repeat(98)}7|`); - expect(await view.screen.state()).toEqual(await a.view(term.termId).screen.state()); + expect(await view.screen.state()).toEqual(await fx.a.view(term.termId).screen.state()); }); }); diff --git a/test/process/daemon-terminals-unseen.test.ts b/test/process/daemon-terminals-unseen.test.ts index 58f6d74..da04bdc 100644 --- a/test/process/daemon-terminals-unseen.test.ts +++ b/test/process/daemon-terminals-unseen.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it, vi } from 'vitest'; import type { Terminal } from '../../src/protocol/index.js'; import type { DaemonClient, EventOf } from '../support/daemon-client.js'; import { sleep } from '../support/daemon-host.js'; -import { a, b, host, repo, started, useDaemonTerminalsHost, wt } from '../support/daemon-terminals.js'; +import { fx, started, useDaemonTerminalsHost } from '../support/daemon-terminals.js'; vi.setConfig({ testTimeout: 60_000, hookTimeout: 30_000 }); useDaemonTerminalsHost(); @@ -19,11 +19,11 @@ describe('activity', () => { /** The terminal entry a fresh watcher of the repo receives. */ const entryOf = async (termId: number): Promise => - (await (await host.client()).watch(repo)).terminals.find((t) => t.termId === termId); + (await (await fx.host.client()).watch(fx.repo)).terminals.find((t) => t.termId === termId); it('flags output while hidden as unseen and working to every watcher', async () => { - const term = await a.create(wt, { command: 'sleep 0.3; echo out; exec sleep 60' }); - for (const client of [a, b]) { + const term = await fx.a.create(fx.wt, { command: 'sleep 0.3; echo out; exec sleep 60' }); + for (const client of [fx.a, fx.b]) { expect(await client.waitFor('activity', (m) => m.termId === term.termId && m.unseen && m.state === 'working')).toEqual({ t: 'activity', termId: term.termId, @@ -34,122 +34,122 @@ describe('activity', () => { }); it('keeps unseen false while one of two clients shows the terminal', async () => { - const term = await a.create(wt, { command: 'read -r _; echo shown-output; exec sleep 60' }); - a.send({ t: 'setVisible', termIds: [term.termId] }); - await a.attach(term.termId); - const from = b.mark(); - a.sendInput(term.termId, '\r'); - await a.waitOutput(term.termId, 'shown-output'); + const term = await fx.a.create(fx.wt, { command: 'read -r _; echo shown-output; exec sleep 60' }); + fx.a.send({ t: 'setVisible', termIds: [term.termId] }); + await fx.a.attach(term.termId); + const from = fx.b.mark(); + fx.a.sendInput(term.termId, '\r'); + await fx.a.waitOutput(term.termId, 'shown-output'); await sleep(300); - expect(activityOf(b, term.termId, from).filter((m) => m.unseen)).toEqual([]); + expect(activityOf(fx.b, term.termId, from).filter((m) => m.unseen)).toEqual([]); expect(await entryOf(term.termId)).toMatchObject({ unseen: false, state: 'working' }); }); it('clears unseen when shown, keeps the state, and sends only changes', async () => { - const term = await started(a, null); - a.sendInput(term.termId, "sleep 1.2; printf '\\a'\r"); - await b.waitFor('activity', (m) => isInput(term.termId)(m) && m.unseen, { timeout: 10_000 }); - let from = b.mark(); - a.send({ t: 'setVisible', termIds: [term.termId] }); - expect(await b.waitFor('activity', () => true, { from })).toEqual({ + const term = await started(fx.a, null); + fx.a.sendInput(term.termId, "sleep 1.2; printf '\\a'\r"); + await fx.b.waitFor('activity', (m) => isInput(term.termId)(m) && m.unseen, { timeout: 10_000 }); + let from = fx.b.mark(); + fx.a.send({ t: 'setVisible', termIds: [term.termId] }); + expect(await fx.b.waitFor('activity', () => true, { from })).toEqual({ t: 'activity', termId: term.termId, unseen: false, state: 'input', }); - from = b.mark(); - a.sendInput(term.termId, 'echo while-visible\r'); - await a.waitOutput(term.termId, 'while-visible\r\n'); + from = fx.b.mark(); + fx.a.sendInput(term.termId, 'echo while-visible\r'); + await fx.a.waitOutput(term.termId, 'while-visible\r\n'); await sleep(400); - expect(activityOf(b, term.termId, from)).toEqual([{ t: 'activity', termId: term.termId, unseen: false, state: 'working' }]); - a.send({ t: 'setVisible', termIds: [] }); - from = b.mark(); - a.sendInput(term.termId, 'echo hidden-again\r'); - expect(await b.waitFor('activity', (m) => m.unseen, { from })).toEqual({ + expect(activityOf(fx.b, term.termId, from)).toEqual([{ t: 'activity', termId: term.termId, unseen: false, state: 'working' }]); + fx.a.send({ t: 'setVisible', termIds: [] }); + from = fx.b.mark(); + fx.a.sendInput(term.termId, 'echo hidden-again\r'); + expect(await fx.b.waitFor('activity', (m) => m.unseen, { from })).toEqual({ t: 'activity', termId: term.termId, unseen: true, state: 'working', }); - a.sendInput(term.termId, 'echo more\r'); - await b.expectNone('activity', () => true, 400); + fx.a.sendInput(term.termId, 'echo more\r'); + await fx.b.expectNone('activity', () => true, 400); }); it('flags an exit while hidden as unseen', async () => { - const term = await a.create(wt, { command: 'read -r _; exec sleep 1' }); - a.send({ t: 'setVisible', termIds: [term.termId] }); - await a.attach(term.termId); - a.sendInput(term.termId, '\r'); + const term = await fx.a.create(fx.wt, { command: 'read -r _; exec sleep 1' }); + fx.a.send({ t: 'setVisible', termIds: [term.termId] }); + await fx.a.attach(term.termId); + fx.a.sendInput(term.termId, '\r'); await sleep(400); - a.send({ t: 'setVisible', termIds: [] }); - const from = b.mark(); - const position = a.view(term.termId).position; - await b.waitFor('termExited', (m) => m.termId === term.termId, { from }); - expect(await b.waitFor('activity', (m) => m.termId === term.termId && m.unseen, { from })).toEqual({ + fx.a.send({ t: 'setVisible', termIds: [] }); + const from = fx.b.mark(); + const position = fx.a.view(term.termId).position; + await fx.b.waitFor('termExited', (m) => m.termId === term.termId, { from }); + expect(await fx.b.waitFor('activity', (m) => m.termId === term.termId && m.unseen, { from })).toEqual({ t: 'activity', termId: term.termId, unseen: true, state: 'working', }); - expect(a.view(term.termId).position).toBe(position); + expect(fx.a.view(term.termId).position).toBe(position); }); it('reports working, then idle 3 s after the last output', async () => { - const from = b.mark(); - const term = await started(a, 'echo quiet-line; exec sleep 60'); - await a.waitOutput(term.termId, 'quiet-line'); + const from = fx.b.mark(); + const term = await started(fx.a, 'echo quiet-line; exec sleep 60'); + await fx.a.waitOutput(term.termId, 'quiet-line'); const outputAt = Date.now(); - await b.waitFor('activity', (m) => m.termId === term.termId && m.state === 'idle', { from, timeout: 8000 }); + await fx.b.waitFor('activity', (m) => m.termId === term.termId && m.state === 'idle', { from, timeout: 8000 }); const idleAt = Date.now(); expect(idleAt - outputAt).toBeGreaterThanOrEqual(2800); expect(idleAt - outputAt).toBeLessThanOrEqual(4500); - const states = activityOf(b, term.termId, from) + const states = activityOf(fx.b, term.termId, from) .map((m) => m.state) .filter((state, i, all) => i === 0 || state !== all[i - 1]); expect(states.slice(-2)).toEqual(['working', 'idle']); }); it('withdraws visibility when the showing client disconnects', async () => { - const term = await started(b, null); - await b.waitFor('activity', (m) => m.termId === term.termId && m.unseen); - const c = await host.client(); - await c.watch(repo); - let from = b.mark(); + const term = await started(fx.b, null); + await fx.b.waitFor('activity', (m) => m.termId === term.termId && m.unseen); + const c = await fx.host.client(); + await c.watch(fx.repo); + let from = fx.b.mark(); c.send({ t: 'setVisible', termIds: [term.termId] }); - await b.waitFor('activity', (m) => !m.unseen, { from }); + await fx.b.waitFor('activity', (m) => !m.unseen, { from }); c.close(); await c.waitClosed(); await sleep(200); - from = b.mark(); - b.sendInput(term.termId, 'echo after-leave\r'); - expect(await b.waitFor('activity', (m) => m.unseen, { from })).toMatchObject({ termId: term.termId, unseen: true }); + from = fx.b.mark(); + fx.b.sendInput(term.termId, 'echo after-leave\r'); + expect(await fx.b.waitFor('activity', (m) => m.unseen, { from })).toMatchObject({ termId: term.termId, unseen: true }); }); it('withdraws visibility when the showing client unwatches the repo', async () => { - const term = await started(b, null); - await b.waitFor('activity', (m) => m.termId === term.termId && m.unseen); - let from = b.mark(); - a.send({ t: 'setVisible', termIds: [term.termId] }); - await b.waitFor('activity', (m) => !m.unseen, { from }); - await a.ok({ t: 'unwatchRepo', repo }); - from = b.mark(); - b.sendInput(term.termId, 'echo after-unwatch\r'); - expect(await b.waitFor('activity', (m) => m.unseen, { from })).toMatchObject({ termId: term.termId, unseen: true }); + const term = await started(fx.b, null); + await fx.b.waitFor('activity', (m) => m.termId === term.termId && m.unseen); + let from = fx.b.mark(); + fx.a.send({ t: 'setVisible', termIds: [term.termId] }); + await fx.b.waitFor('activity', (m) => !m.unseen, { from }); + await fx.a.ok({ t: 'unwatchRepo', repo: fx.repo }); + from = fx.b.mark(); + fx.b.sendInput(term.termId, 'echo after-unwatch\r'); + expect(await fx.b.waitFor('activity', (m) => m.unseen, { from })).toMatchObject({ termId: term.termId, unseen: true }); }); it('ignores visibility from a connection that does not watch the repo, and unknown ids', async () => { - const term = await started(b, null); - await b.waitFor('activity', (m) => m.termId === term.termId && m.unseen); - const from = b.mark(); - b.send({ t: 'setVisible', termIds: [term.termId] }); - await b.waitFor('activity', (m) => !m.unseen, { from }); - b.send({ t: 'setVisible', termIds: [] }); - const outsider = await host.client(); + const term = await started(fx.b, null); + await fx.b.waitFor('activity', (m) => m.termId === term.termId && m.unseen); + const from = fx.b.mark(); + fx.b.send({ t: 'setVisible', termIds: [term.termId] }); + await fx.b.waitFor('activity', (m) => !m.unseen, { from }); + fx.b.send({ t: 'setVisible', termIds: [] }); + const outsider = await fx.host.client(); outsider.send({ t: 'setVisible', termIds: [term.termId, 9999] }); await sleep(200); - const later = b.mark(); - b.sendInput(term.termId, 'echo hidden\r'); - expect(await b.waitFor('activity', (m) => m.unseen, { from: later })).toMatchObject({ termId: term.termId, unseen: true }); + const later = fx.b.mark(); + fx.b.sendInput(term.termId, 'echo hidden\r'); + expect(await fx.b.waitFor('activity', (m) => m.unseen, { from: later })).toMatchObject({ termId: term.termId, unseen: true }); await outsider.expectNone('error', () => true, 200); }); }); diff --git a/test/process/hub-router-config.test.ts b/test/process/hub-router-config.test.ts index 9a492c3..b856ce3 100644 --- a/test/process/hub-router-config.test.ts +++ b/test/process/hub-router-config.test.ts @@ -1,23 +1,11 @@ -import { mkdirSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'; +import { mkdirSync, readFileSync, rmSync, statSync, symlinkSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; import { describe, expect, it, vi } from 'vitest'; import { makeRepo, sleep } from '../support/daemon-host.js'; import { FakeDaemon } from '../support/fake-daemon.js'; import { installRemote } from '../support/fake-ssh.js'; import type { HubClient, HubMessageOf } from '../support/hub-client.js'; -import { - configure, - connected, - connectedHost, - host, - installedBox, - nodeRemote, - repo, - ssh, - useHubRouterHost, - watching, - wt, -} from '../support/hub-router.js'; +import { configure, connected, connectedHost, fx, installedBox, nodeRemote, useHubRouterHost, watching } from '../support/hub-router.js'; vi.setConfig({ testTimeout: 60_000, hookTimeout: 30_000 }); useHubRouterHost(); @@ -32,45 +20,45 @@ const presetsOf = (client: HubClient): HubMessageOf<'presets'>['presets'][] => client.messages.flatMap((m) => (m.t === 'presets' ? [m.presets] : [])); /** Reads `config.json` as JSON. */ -const configFile = (): unknown => JSON.parse(readFileSync(host.configPath, 'utf8')); +const configFile = (): unknown => JSON.parse(readFileSync(fx.host.configPath, 'utf8')); describe('configuration snapshots', () => { it('lists no repos when the configuration has none', async () => { - host.writeConfig({ port: host.port }); - const client = await host.session(); + fx.host.writeConfig({ port: fx.host.port }); + const client = await fx.host.session(); expect(client.hosts()[0]?.repos).toEqual([]); }); it('lists the configured repos for host 0', async () => { - const client = await host.session(); - expect(client.hosts()[0]?.repos).toEqual([repo]); + const client = await fx.host.session(); + expect(client.hosts()[0]?.repos).toEqual([fx.repo]); }); it('expands ~/ against the home directory without resolving symbolic links', async () => { - symlinkSync(repo, join(host.home, 'linked')); - host.writeConfig({ port: host.port, repos: ['~/linked'] }); - const client = await host.session(); - expect(client.hosts()[0]?.repos).toEqual([join(host.home, 'linked')]); + symlinkSync(fx.repo, join(fx.host.home, 'linked')); + fx.host.writeConfig({ port: fx.host.port, repos: ['~/linked'] }); + const client = await fx.host.session(); + expect(client.hosts()[0]?.repos).toEqual([join(fx.host.home, 'linked')]); }); it('sends the configured presets exactly, in their order', async () => { - host.presets = [CLAUDE, SHELL]; - host.writeRepos([repo]); - const client = await host.session(); + fx.host.presets = [CLAUDE, SHELL]; + fx.host.writeRepos([fx.repo]); + const client = await fx.host.session(); expect(presetsOf(client)).toEqual([[CLAUDE, SHELL]]); }); it('gives a new session an edited configuration, leaving an open session unchanged', async () => { - const open = await host.session(); + const open = await fx.host.session(); expect(presetsOf(open)).toEqual([DEFAULT_PRESETS]); - const other = makeRepo(join(host.dir, 'other')); - host.presets = [SHELL, CLAUDE]; - host.writeRepos([repo, other]); - const fresh = await host.session(); - expect(fresh.hosts()[0]?.repos).toEqual([repo, other]); + const other = makeRepo(join(fx.host.dir, 'other')); + fx.host.presets = [SHELL, CLAUDE]; + fx.host.writeRepos([fx.repo, other]); + const fresh = await fx.host.session(); + expect(fresh.hosts()[0]?.repos).toEqual([fx.repo, other]); expect(presetsOf(fresh)).toEqual([[SHELL, CLAUDE]]); await open.expectNone('hosts', (m) => m.hosts.some((h) => h.repos.includes(other)), 1000); - expect(open.hosts()[0]?.repos).toEqual([repo]); + expect(open.hosts()[0]?.repos).toEqual([fx.repo]); expect(presetsOf(open)).toEqual([DEFAULT_PRESETS]); }); @@ -85,36 +73,36 @@ describe('configuration snapshots', () => { ], ['a preset whose command is empty', [{ name: 'a', command: '' }]], ])('gives a new session an internal error and the last valid repos and presets after an edit to %s', async (_name, presets) => { - host.presets = [CLAUDE, SHELL]; - host.writeRepos([repo]); - expect(presetsOf(await host.session())).toEqual([[CLAUDE, SHELL]]); - host.writeConfig({ port: host.port, repos: [join(host.dir, 'x')], presets }); - const client = await host.open(['wtd', `wtd.token.${host.token()}`]); + fx.host.presets = [CLAUDE, SHELL]; + fx.host.writeRepos([fx.repo]); + expect(presetsOf(await fx.host.session())).toEqual([[CLAUDE, SHELL]]); + fx.host.writeConfig({ port: fx.host.port, repos: [join(fx.host.dir, 'x')], presets }); + const client = await fx.host.open(['wtd', `wtd.token.${fx.host.token()}`]); await client.handshake(); const error = await client.waitFor('error'); expect(error).toMatchObject({ req: null, host: null, code: 'internal' }); expect(error.message).toContain('presets'); - expect(client.hosts()[0]?.repos).toEqual([repo]); + expect(client.hosts()[0]?.repos).toEqual([fx.repo]); expect(presetsOf(client)).toEqual([[CLAUDE, SHELL]]); }); }); describe('repo discovery through the hub', () => { it('discovers repos below the host’s configured roots, at depth 3', async () => { - const one = makeRepo(join(host.home, 'src', 'one')); - const deep = makeRepo(join(host.home, 'src', 'a', 'b', 'deep')); - makeRepo(join(host.home, 'src', 'a', 'b', 'c', 'too-deep')); - makeRepo(join(host.home, 'elsewhere', 'outside')); - host.writeConfig({ port: host.port, repos: [repo], roots: ['~/src'] }); - const client = await host.session(); + const one = makeRepo(join(fx.host.home, 'src', 'one')); + const deep = makeRepo(join(fx.host.home, 'src', 'a', 'b', 'deep')); + makeRepo(join(fx.host.home, 'src', 'a', 'b', 'c', 'too-deep')); + makeRepo(join(fx.host.home, 'elsewhere', 'outside')); + fx.host.writeConfig({ port: fx.host.port, repos: [fx.repo], roots: ['~/src'] }); + const client = await fx.host.session(); await connected(client); const reply = await client.hubRequest({ t: 'discoverRepos', host: 0 }); expect(reply).toEqual({ from: 'hub', m: { t: 'reposDiscovered', req: 1, host: 0, repos: [deep, one] } }); }); it('discovers below the home directory by default', async () => { - const mine = makeRepo(join(host.home, 'GitHub', 'mine')); - const client = await host.session(); + const mine = makeRepo(join(fx.host.home, 'GitHub', 'mine')); + const client = await fx.host.session(); await connected(client); const reply = await client.hubRequest({ t: 'discoverRepos', host: 0 }); expect(reply.m.t === 'reposDiscovered' ? reply.m.repos : []).toContain(mine); @@ -122,20 +110,20 @@ describe('repo discovery through the hub', () => { it('discovers on a remote host against the remote home', async () => { const remote = nodeRemote('box'); - await installRemote(host, ssh, 'box'); + await installRemote(fx.host, fx.ssh, 'box'); const found = makeRepo(join(remote.home, 'code', 'found')); configure([{ name: 'box', ssh: 'box', roots: ['~/code'] }]); - const client = await host.session(); + const client = await fx.host.session(); await connectedHost(client, 1); const reply = await client.hubRequest({ t: 'discoverRepos', host: 1 }); expect(reply).toEqual({ from: 'hub', m: { t: 'reposDiscovered', req: 1, host: 1, repos: [found] } }); }); it('keeps a hub-level request apart from a daemon request with the same id', async () => { - const client = await host.session(); + const client = await fx.host.session(); await connected(client); const from = client.mark(); - client.sendJson({ t: 'host', host: 0, m: { t: 'watchRepo', req: 7, repo } }); + client.sendJson({ t: 'host', host: 0, m: { t: 'watchRepo', req: 7, repo: fx.repo } }); client.sendJson({ t: 'discoverRepos', req: 7, host: 0 }); await client.waitFor('reposDiscovered', (m) => m.req === 7, { from }); await client.waitEvent(0, 'done', (m) => m.req === 7, { from }); @@ -148,9 +136,9 @@ describe('repo discovery through the hub', () => { }); it('answers discovery for a host that is not connected as routing does', async () => { - ssh.failHost('box', 'ssh: Could not resolve hostname box'); + fx.ssh.failHost('box', 'ssh: Could not resolve hostname box'); configure([{ name: 'box', ssh: 'box' }]); - const client = await host.session(); + const client = await fx.host.session(); await client.waitHost(1, (h) => h.status === 'reconnecting' || h.status === 'down'); expect(await client.hubRequest({ t: 'discoverRepos', host: 1 })).toMatchObject({ from: 'hub', @@ -163,8 +151,8 @@ describe('repo discovery through the hub', () => { }); it('answers discovery for an outdated host with version-mismatch', async () => { - await FakeDaemon.listen(host.socket, { protocol: 99 }); - const client = await host.session(); + await FakeDaemon.listen(fx.host.socket, { protocol: 99 }); + const client = await fx.host.session(); await client.waitHost(0, (h) => h.status === 'outdated'); expect(await client.hubRequest({ t: 'discoverRepos', host: 0 })).toMatchObject({ m: { t: 'error', code: 'version-mismatch' } }); }); @@ -172,64 +160,68 @@ describe('repo discovery through the hub', () => { describe('adding repos', () => { it('adds a repo to config.json, answers done, then lists it in hosts', async () => { - const added = makeRepo(join(host.dir, 'added')); - const client = await host.session(); + const added = makeRepo(join(fx.host.dir, 'added')); + const client = await fx.host.session(); await connected(client); const from = client.mark(); const reply = await client.hubRequest({ t: 'addRepo', host: 0, repo: added }); expect(reply).toEqual({ from: 'hub', m: { t: 'done', req: 1 } }); const listed = await client.waitHost(0, (h) => h.repos.includes(added), { from }); - expect(listed.repos).toEqual([repo, added]); + expect(listed.repos).toEqual([fx.repo, added]); const doneAt = client.messages.findIndex((m, i) => i >= from && m.t === 'done'); const hostsAt = client.messages.findIndex((m, i) => i >= from && m.t === 'hosts' && m.hosts[0]?.repos.includes(added) === true); expect(doneAt).toBeLessThan(hostsAt); - expect(configFile()).toEqual({ port: host.port, repos: [repo, added] }); + expect(configFile()).toEqual({ port: fx.host.port, repos: [fx.repo, added] }); }); it('refuses a directory that is not a repository’s main worktree with not-a-repo, leaving config.json unchanged', async () => { - const plain = join(host.dir, 'plain'); + const plain = join(fx.host.dir, 'plain'); mkdirSync(plain); - const before = readFileSync(host.configPath, 'utf8'); - const client = await host.session(); + const before = readFileSync(fx.host.configPath, 'utf8'); + const client = await fx.host.session(); await connected(client); expect(await client.hubRequest({ t: 'addRepo', host: 0, repo: plain })).toMatchObject({ from: 'hub', m: { t: 'error', req: 1, host: 0, code: 'not-a-repo' }, }); - expect(await client.hubRequest({ t: 'addRepo', host: 0, repo: wt })).toMatchObject({ m: { t: 'error', code: 'not-a-repo' } }); - expect(readFileSync(host.configPath, 'utf8')).toBe(before); + expect(await client.hubRequest({ t: 'addRepo', host: 0, repo: fx.wt })).toMatchObject({ m: { t: 'error', code: 'not-a-repo' } }); + expect(readFileSync(fx.host.configPath, 'utf8')).toBe(before); }); it('answers done without an edit for a repo already listed, as given or by a ~/ form', async () => { - symlinkSync(repo, join(host.home, 'linked')); - host.writeConfig({ port: host.port, repos: [repo, '~/linked'] }); - const before = readFileSync(host.configPath, 'utf8'); - const client = await host.session(); + symlinkSync(fx.repo, join(fx.host.home, 'linked')); + fx.host.writeConfig({ port: fx.host.port, repos: [fx.repo, '~/linked'] }); + const before = readFileSync(fx.host.configPath, 'utf8'); + const client = await fx.host.session(); await connected(client); - expect((await client.hubRequest({ t: 'addRepo', host: 0, repo })).m).toEqual({ t: 'done', req: 1 }); - expect((await client.hubRequest({ t: 'addRepo', host: 0, repo: join(host.home, 'linked') })).m).toEqual({ t: 'done', req: 2 }); - expect(readFileSync(host.configPath, 'utf8')).toBe(before); + expect((await client.hubRequest({ t: 'addRepo', host: 0, repo: fx.repo })).m).toEqual({ t: 'done', req: 1 }); + expect((await client.hubRequest({ t: 'addRepo', host: 0, repo: join(fx.host.home, 'linked') })).m).toEqual({ t: 'done', req: 2 }); + expect(readFileSync(fx.host.configPath, 'utf8')).toBe(before); }); it('lists a repo added to a remote host in every open session', async () => { const { remote, remoteRepo } = await installedBox(); const second = makeRepo(join(remote.home, 'src', 'second')); - const a = await host.session(); - const b = await host.session(); + const a = await fx.host.session(); + const b = await fx.host.session(); await connectedHost(a, 1); await connectedHost(b, 1); const fromB = b.mark(); expect((await a.hubRequest({ t: 'addRepo', host: 1, repo: second })).m).toEqual({ t: 'done', req: 1 }); expect((await a.waitHost(1, (h) => h.repos.includes(second))).repos).toEqual([remoteRepo, second]); expect((await b.waitHost(1, (h) => h.repos.includes(second), { from: fromB })).repos).toEqual([remoteRepo, second]); - expect(b.hosts()[0]?.repos).toEqual([repo]); - expect(configFile()).toEqual({ port: host.port, repos: [repo], hosts: [{ name: 'box', ssh: 'box', repos: [remoteRepo, second] }] }); + expect(b.hosts()[0]?.repos).toEqual([fx.repo]); + expect(configFile()).toEqual({ + port: fx.host.port, + repos: [fx.repo], + hosts: [{ name: 'box', ssh: 'box', repos: [remoteRepo, second] }], + }); }); it('answers adding to a host that is not connected as routing does', async () => { - ssh.failHost('box', 'ssh: Could not resolve hostname box'); + fx.ssh.failHost('box', 'ssh: Could not resolve hostname box'); configure([{ name: 'box', ssh: 'box' }]); - const client = await host.session(); + const client = await fx.host.session(); await client.waitHost(1, (h) => h.status === 'reconnecting' || h.status === 'down'); expect(await client.hubRequest({ t: 'addRepo', host: 1, repo: '/srv/x' })).toMatchObject({ m: { t: 'error', host: 1, code: 'host-unavailable' }, @@ -239,54 +231,54 @@ describe('adding repos', () => { describe('removing repos', () => { it('removes a repo listed by a ~/ form, answers done, then drops it from hosts', async () => { - const app = makeRepo(join(host.home, 'app')); - host.writeConfig({ port: host.port, repos: [repo, '~/app'] }); - const client = await host.session(); + const app = makeRepo(join(fx.host.home, 'app')); + fx.host.writeConfig({ port: fx.host.port, repos: [fx.repo, '~/app'] }); + const client = await fx.host.session(); await connected(client); - expect(client.hosts()[0]?.repos).toEqual([repo, app]); + expect(client.hosts()[0]?.repos).toEqual([fx.repo, app]); const from = client.mark(); expect((await client.hubRequest({ t: 'removeRepo', host: 0, repo: app })).m).toEqual({ t: 'done', req: 1 }); - expect((await client.waitHost(0, (h) => !h.repos.includes(app), { from })).repos).toEqual([repo]); - expect(configFile()).toEqual({ port: host.port, repos: [repo] }); + expect((await client.waitHost(0, (h) => !h.repos.includes(app), { from })).repos).toEqual([fx.repo]); + expect(configFile()).toEqual({ port: fx.host.port, repos: [fx.repo] }); }); it('refuses a repo with a running terminal with busy, leaving config.json unchanged', async () => { const client = await watching(); - await client.create(0, wt, { command: 'exec sleep 60' }); - const before = readFileSync(host.configPath, 'utf8'); - expect(await client.hubRequest({ t: 'removeRepo', host: 0, repo })).toMatchObject({ + await client.create(0, fx.wt, { command: 'exec sleep 60' }); + const before = readFileSync(fx.host.configPath, 'utf8'); + expect(await client.hubRequest({ t: 'removeRepo', host: 0, repo: fx.repo })).toMatchObject({ from: 'hub', m: { t: 'error', req: 3, host: 0, code: 'busy' }, }); - expect(readFileSync(host.configPath, 'utf8')).toBe(before); + expect(readFileSync(fx.host.configPath, 'utf8')).toBe(before); }); it('refuses a repo with an exited terminal not yet closed, and removes it once closed', async () => { const client = await watching(); - const term = await client.create(0, repo, { command: 'exit 0' }); + const term = await client.create(0, fx.repo, { command: 'exit 0' }); await client.waitEvent(0, 'termExited', (m) => m.termId === term.termId, { timeout: 10_000 }); - expect((await client.hubRequest({ t: 'removeRepo', host: 0, repo })).m).toMatchObject({ t: 'error', code: 'busy' }); + expect((await client.hubRequest({ t: 'removeRepo', host: 0, repo: fx.repo })).m).toMatchObject({ t: 'error', code: 'busy' }); await client.ok(0, { t: 'closeTerm', termId: term.termId }); - expect((await client.hubRequest({ t: 'removeRepo', host: 0, repo })).m).toMatchObject({ t: 'done' }); - expect(configFile()).toEqual({ port: host.port, repos: [] }); + expect((await client.hubRequest({ t: 'removeRepo', host: 0, repo: fx.repo })).m).toMatchObject({ t: 'done' }); + expect(configFile()).toEqual({ port: fx.host.port, repos: [] }); }); it('removes a listed repo whose directory no longer exists', async () => { - const gone = makeRepo(join(host.dir, 'gone')); - host.writeRepos([repo, gone]); + const gone = makeRepo(join(fx.host.dir, 'gone')); + fx.host.writeRepos([fx.repo, gone]); rmSync(gone, { recursive: true, force: true }); - const client = await host.session(); + const client = await fx.host.session(); await connected(client); expect((await client.hubRequest({ t: 'removeRepo', host: 0, repo: gone })).m).toEqual({ t: 'done', req: 1 }); - expect(configFile()).toEqual({ port: host.port, repos: [repo] }); + expect(configFile()).toEqual({ port: fx.host.port, repos: [fx.repo] }); }); it('answers done without an edit for a repo it does not list', async () => { - const before = readFileSync(host.configPath, 'utf8'); - const client = await host.session(); + const before = readFileSync(fx.host.configPath, 'utf8'); + const client = await fx.host.session(); await connected(client); - expect((await client.hubRequest({ t: 'removeRepo', host: 0, repo: join(host.dir, 'never') })).m).toEqual({ t: 'done', req: 1 }); - expect(readFileSync(host.configPath, 'utf8')).toBe(before); + expect((await client.hubRequest({ t: 'removeRepo', host: 0, repo: join(fx.host.dir, 'never') })).m).toEqual({ t: 'done', req: 1 }); + expect(readFileSync(fx.host.configPath, 'utf8')).toBe(before); }); }); @@ -294,10 +286,10 @@ describe('editing presets', () => { const HTOP = { name: 'htop', command: 'htop' }; it('adds a preset to config.json, answers done, then sends the presets to every open session', async () => { - host.presets = [SHELL]; - host.writeRepos([repo]); - const a = await host.session(); - const b = await host.session(); + fx.host.presets = [SHELL]; + fx.host.writeRepos([fx.repo]); + const a = await fx.host.session(); + const b = await fx.host.session(); const [fromA, fromB] = [a.mark(), b.mark()]; expect(await a.hubRequest({ t: 'addPreset', preset: HTOP })).toEqual({ from: 'hub', m: { t: 'done', req: 1 } }); for (const [client, from] of [ @@ -309,93 +301,94 @@ describe('editing presets', () => { const doneAt = a.messages.findIndex((m, i) => i >= fromA && m.t === 'done'); const presetsAt = a.messages.findIndex((m, i) => i >= fromA && m.t === 'presets'); expect(doneAt).toBeLessThan(presetsAt); - expect(configFile()).toEqual({ port: host.port, repos: [repo], presets: [SHELL, HTOP] }); + expect(configFile()).toEqual({ port: fx.host.port, repos: [fx.repo], presets: [SHELL, HTOP] }); }); it('removes a preset, writing the defaults without it when config.json had none', async () => { - host.writeConfig({ port: host.port, repos: [repo] }); - const client = await host.session(); + fx.host.writeConfig({ port: fx.host.port, repos: [fx.repo] }); + const client = await fx.host.session(); const from = client.mark(); expect((await client.hubRequest({ t: 'removePreset', name: 'claude' })).m).toEqual({ t: 'done', req: 1 }); const expected = [SHELL, { name: 'codex', command: 'codex' }]; expect((await client.waitFor('presets', () => true, { from })).presets).toEqual(expected); - expect(configFile()).toEqual({ port: host.port, repos: [repo], presets: expected }); + expect(configFile()).toEqual({ port: fx.host.port, repos: [fx.repo], presets: expected }); }); it('moves a preset in config.json, answers done, then sends the new order to every open session', async () => { - host.presets = [SHELL, CLAUDE, HTOP]; - host.writeRepos([repo]); - const a = await host.session(); - const b = await host.session(); + fx.host.presets = [SHELL, CLAUDE, HTOP]; + fx.host.writeRepos([fx.repo]); + const a = await fx.host.session(); + const b = await fx.host.session(); const fromB = b.mark(); expect((await a.hubRequest({ t: 'movePreset', name: 'htop', to: 0 })).m).toEqual({ t: 'done', req: 1 }); expect((await b.waitFor('presets', () => true, { from: fromB })).presets).toEqual([HTOP, SHELL, CLAUDE]); - expect(configFile()).toEqual({ port: host.port, repos: [repo], presets: [HTOP, SHELL, CLAUDE] }); + expect(configFile()).toEqual({ port: fx.host.port, repos: [fx.repo], presets: [HTOP, SHELL, CLAUDE] }); }); it('answers done without sending presets for a name it does not list', async () => { - host.presets = [SHELL]; - host.writeRepos([repo]); - const before = readFileSync(host.configPath, 'utf8'); - const client = await host.session(); + fx.host.presets = [SHELL]; + fx.host.writeRepos([fx.repo]); + const before = readFileSync(fx.host.configPath, 'utf8'); + const client = await fx.host.session(); expect((await client.hubRequest({ t: 'removePreset', name: 'nope' })).m).toEqual({ t: 'done', req: 1 }); await client.expectNone('presets', () => true, 300); - expect(readFileSync(host.configPath, 'utf8')).toBe(before); + expect(readFileSync(fx.host.configPath, 'utf8')).toBe(before); }); it.each([ ['adding a taken name', { t: 'addPreset', preset: { name: 'shell', command: 'zsh' } }, 'a preset named shell exists'], ['removing the last preset', { t: 'removePreset', name: 'shell' }, 'the last preset cannot be removed'], ] as const)('refuses %s with internal, leaving config.json unchanged', async (_name, body, text) => { - host.presets = [SHELL]; - host.writeRepos([repo]); - const before = readFileSync(host.configPath, 'utf8'); - const client = await host.session(); + fx.host.presets = [SHELL]; + fx.host.writeRepos([fx.repo]); + const before = readFileSync(fx.host.configPath, 'utf8'); + const client = await fx.host.session(); const reply = await client.hubRequest(body); expect(reply).toMatchObject({ from: 'hub', m: { t: 'error', req: 1, host: null, code: 'internal' } }); expect(reply.m.t === 'error' ? reply.m.message : '').toContain(text); - expect(readFileSync(host.configPath, 'utf8')).toBe(before); + expect(readFileSync(fx.host.configPath, 'utf8')).toBe(before); }); }); describe('configuration edits', () => { it('refuses to edit an invalid config.json, naming the problem and leaving it unchanged', async () => { - const added = makeRepo(join(host.dir, 'added')); - const client = await host.session(); + const added = makeRepo(join(fx.host.dir, 'added')); + const client = await fx.host.session(); await connected(client); - writeFileSync(host.configPath, JSON.stringify({ port: host.port, theme: 'dark' })); + writeFileSync(fx.host.configPath, JSON.stringify({ port: fx.host.port, theme: 'dark' })); const reply = await client.hubRequest({ t: 'addRepo', host: 0, repo: added }); expect(reply).toMatchObject({ from: 'hub', m: { t: 'error', req: 1, code: 'internal' } }); expect(reply.m.t === 'error' ? reply.m.message : '').toContain('theme'); - expect(configFile()).toEqual({ port: host.port, theme: 'dark' }); + expect(configFile()).toEqual({ port: fx.host.port, theme: 'dark' }); }); it('refuses to edit a host the file no longer lists, leaving it unchanged', async () => { const { remote } = await installedBox(); const other = makeRepo(join(remote.home, 'src', 'other')); - const client = await host.session(); + const client = await fx.host.session(); await connectedHost(client, 1); - host.writeConfig({ port: host.port, repos: [repo] }); - const before = readFileSync(host.configPath, 'utf8'); + fx.host.writeConfig({ port: fx.host.port, repos: [fx.repo] }); + const before = readFileSync(fx.host.configPath, 'utf8'); expect((await client.hubRequest({ t: 'addRepo', host: 1, repo: other })).m).toMatchObject({ t: 'error', code: 'internal' }); - expect(readFileSync(host.configPath, 'utf8')).toBe(before); + expect(readFileSync(fx.host.configPath, 'utf8')).toBe(before); }); it('creates config.json owner-only to record a repo when there was none', async () => { - rmSync(host.configPath); - const added = makeRepo(join(host.dir, 'added')); - const client = await host.session(); + rmSync(fx.host.configPath); + const added = makeRepo(join(fx.host.dir, 'added')); + const client = await fx.host.session(); await connected(client); expect((await client.hubRequest({ t: 'addRepo', host: 0, repo: added })).m).toEqual({ t: 'done', req: 1 }); expect(configFile()).toEqual({ repos: [added] }); + expect(statSync(fx.host.configPath).mode & 0o777).toBe(0o600); }); it('changes nothing else in an open session’s snapshot', async () => { - const added = makeRepo(join(host.dir, 'added')); - const client = await host.session(); + const added = makeRepo(join(fx.host.dir, 'added')); + const client = await fx.host.session(); await connected(client); - host.presets = [CLAUDE, SHELL]; - host.writeRepos([repo]); + fx.host.presets = [CLAUDE, SHELL]; + fx.host.writeRepos([fx.repo]); const from = client.mark(); expect((await client.hubRequest({ t: 'addRepo', host: 0, repo: added })).m).toEqual({ t: 'done', req: 1 }); await client.waitHost(0, (h) => h.repos.includes(added), { from }); diff --git a/test/process/hub-router-links.test.ts b/test/process/hub-router-links.test.ts index cd56af2..ff7183e 100644 --- a/test/process/hub-router-links.test.ts +++ b/test/process/hub-router-links.test.ts @@ -3,26 +3,15 @@ import { describe, expect, it, vi } from 'vitest'; import { DAEMON_PROTOCOL_VERSION } from '../../src/protocol/index.js'; import { alive, sleep, waitUntil } from '../support/daemon-host.js'; import { FakeDaemon } from '../support/fake-daemon.js'; -import { - ANY_INSTANCE, - connected, - host, - hub, - instanceOf, - packageVersion, - repo, - started, - useHubRouterHost, - watching, -} from '../support/hub-router.js'; +import { ANY_INSTANCE, connected, fx, instanceOf, packageVersion, started, useHubRouterHost, watching } from '../support/hub-router.js'; vi.setConfig({ testTimeout: 60_000, hookTimeout: 30_000 }); useHubRouterHost(); describe('daemon links and host status', () => { it('starts the daemon and reports host 0 connected with its instance', async () => { - expect(host.daemonPids()).toEqual([]); - const client = await host.session(); + expect(fx.host.daemonPids()).toEqual([]); + const client = await fx.host.session(); const entry = await connected(client); expect(entry).toEqual({ idx: 0, @@ -33,17 +22,17 @@ describe('daemon links and host status', () => { daemonVersion: packageVersion(), instance: ANY_INSTANCE, login: null, - repos: [repo], + repos: [fx.repo], }); - expect(host.daemonPids()).toHaveLength(1); - const daemon = await host.client(); + expect(fx.host.daemonPids()).toHaveLength(1); + const daemon = await fx.host.client(); expect((await daemon.handshake()).instance).toBe(entry.instance); }); it('reports reconnecting, then connected with a new instance, when the daemon is killed', async () => { - const client = await host.session(); + const client = await fx.host.session(); const first = await instanceOf(client); - const [pid] = host.daemonPids(); + const [pid] = fx.host.daemonPids(); if (pid === undefined) throw new Error('no daemon'); const from = client.mark(); process.kill(pid, 'SIGKILL'); @@ -56,8 +45,8 @@ describe('daemon links and host status', () => { ['an older', 3], ['a newer', 99], ])('reports a daemon of %s protocol as outdated with its version and instance, keeping the link', async (_name, protocol) => { - const fake = await FakeDaemon.listen(host.socket, { protocol, version: '9.9.9', instance: 'old_D-1' }); - const client = await host.session(); + const fake = await FakeDaemon.listen(fx.host.socket, { protocol, version: '9.9.9', instance: 'old_D-1' }); + const client = await fx.host.session(); expect(await client.waitHost(0, (h) => h.status === 'outdated')).toMatchObject({ daemonVersion: '9.9.9', instance: 'old_D-1', @@ -69,8 +58,8 @@ describe('daemon links and host status', () => { }); it('reports reconnecting, then down after 3 failures, and keeps retrying', async () => { - const fake = await FakeDaemon.listen(host.socket, { protocol: DAEMON_PROTOCOL_VERSION, accept: 'close' }); - const client = await host.session(); + const fake = await FakeDaemon.listen(fx.host.socket, { protocol: DAEMON_PROTOCOL_VERSION, accept: 'close' }); + const client = await fx.host.session(); await client.waitHost(0, (h) => h.status === 'reconnecting'); await client.waitHost(0, (h) => h.status === 'down', { timeout: 10_000 }); expect(fake.connections).toBeGreaterThanOrEqual(3); @@ -85,8 +74,8 @@ describe('daemon restart', () => { ['an older', 3], ['a newer', 99], ])('restarts an outdated daemon of %s protocol into one speaking the hub’s protocol', async (_name, protocol) => { - const fake = await FakeDaemon.listen(host.socket, { protocol }); - const client = await host.session(); + const fake = await FakeDaemon.listen(fx.host.socket, { protocol }); + const client = await fx.host.session(); await client.waitHost(0, (h) => h.status === 'outdated'); const from = client.mark(); const reply = await client.hubRequest({ t: 'restartDaemon', host: 0 }, 15_000); @@ -95,18 +84,18 @@ describe('daemon restart', () => { await fake.closed; expect(await instanceOf(client, from)).not.toBe('fake_instance'); // A daemon the session's reconnect started alongside the restart refuses to start and exits. - await waitUntil(() => host.daemonPids().length === 1, 'one daemon'); - const direct = await host.client(); + await waitUntil(() => fx.host.daemonPids().length === 1, 'one daemon'); + const direct = await fx.host.client(); expect((await direct.handshake()).protocol).toBe(DAEMON_PROTOCOL_VERSION); }); it('restarts a newer daemon whose messages after hello are not valid for the hub’s protocol', async () => { - const fake = await FakeDaemon.listen(host.socket, { + const fake = await FakeDaemon.listen(fx.host.socket, { protocol: DAEMON_PROTOCOL_VERSION + 1, instance: 'newer_1', afterHello: ['{"t":"frobnicated","widgets":[1,2,3]}', '{"t":"repoState","repo":7}'], }); - const client = await host.session(); + const client = await fx.host.session(); await client.waitHost(0, (h) => h.status === 'outdated'); const from = client.mark(); const reply = await client.hubRequest({ t: 'restartDaemon', host: 0 }, 15_000); @@ -119,12 +108,12 @@ describe('daemon restart', () => { }); it('shares one restart between concurrent requests from two sessions', async () => { - const a = await host.session(); - const b = await host.session(); + const a = await fx.host.session(); + const b = await fx.host.session(); const old = await instanceOf(a); await connected(b); const oldPid = await waitUntil(() => { - const pids = host.daemonPids(); + const pids = fx.host.daemonPids(); return pids.length === 1 ? pids[0] : undefined; }, 'one daemon'); const fromA = a.mark(); @@ -141,14 +130,14 @@ describe('daemon restart', () => { expect(newA).not.toBe(old); expect(newB).toBe(newA); // Both sessions reached the new daemon, so every reconnect has dialled; daemons they started alongside it exit. - await waitUntil(() => host.daemonPids().length === 1, 'one daemon'); + await waitUntil(() => fx.host.daemonPids().length === 1, 'one daemon'); await sleep(500); - expect(host.daemonPids()).toHaveLength(1); + expect(fx.host.daemonPids()).toHaveLength(1); }); it('answers internal when the daemon does not go away within 10 s', async () => { - const fake = await FakeDaemon.listen(host.socket, { protocol: 99, onShutdown: 'ignore' }); - const client = await host.session(); + const fake = await FakeDaemon.listen(fx.host.socket, { protocol: 99, onShutdown: 'ignore' }); + const client = await fx.host.session(); await client.waitHost(0, (h) => h.status === 'outdated'); const sentAt = Date.now(); const reply = await client.hubRequest({ t: 'restartDaemon', host: 0 }, 20_000); @@ -158,8 +147,8 @@ describe('daemon restart', () => { }); it('answers host-unavailable for a host that is not connected or outdated', async () => { - const fake = await FakeDaemon.listen(host.socket, { protocol: DAEMON_PROTOCOL_VERSION, accept: 'close' }); - const client = await host.session(); + const fake = await FakeDaemon.listen(fx.host.socket, { protocol: DAEMON_PROTOCOL_VERSION, accept: 'close' }); + const client = await fx.host.session(); await client.waitHost(0, (h) => h.status === 'reconnecting' || h.status === 'down'); const reply = await client.hubRequest({ t: 'restartDaemon', host: 0 }); expect(reply).toMatchObject({ from: 'hub', m: { t: 'error', req: 1, host: 0, code: 'host-unavailable' } }); @@ -171,30 +160,30 @@ describe('session end and hub shutdown', () => { it('keeps terminals running when a session closes', async () => { const first = await watching(); const term = await started(first, null); - const [daemonPid] = host.daemonPids(); + const [daemonPid] = fx.host.daemonPids(); first.close(); await sleep(500); const second = await watching(); const state = second.daemonEvents(0, 'repoState').at(-1); expect(state?.terminals.find((t) => t.termId === term.termId)?.exit).toBeNull(); - expect(host.daemonPids()).toEqual([daemonPid]); + expect(fx.host.daemonPids()).toEqual([daemonPid]); }); it('closes sessions with 1001 on SIGTERM and exits 0, leaving the daemon running', async () => { const client = await watching(); - const [daemonPid] = host.daemonPids(); - hub.kill('SIGTERM'); + const [daemonPid] = fx.host.daemonPids(); + fx.hub.kill('SIGTERM'); await client.waitClosed(); expect(client.closeCode).toBe(1001); - expect(await hub.exited).toEqual({ code: 0, signal: null }); + expect(await fx.hub.exited).toEqual({ code: 0, signal: null }); expect(alive(daemonPid ?? 0)).toBe(true); }); it('closes sessions with 1001 on an authenticated shutdown request', async () => { - const client = await host.session(); - await host.api('POST', '/api/shutdown'); + const client = await fx.host.session(); + await fx.host.api('POST', '/api/shutdown'); await client.waitClosed(); expect(client.closeCode).toBe(1001); - expect(await hub.exited).toEqual({ code: 0, signal: null }); + expect(await fx.hub.exited).toEqual({ code: 0, signal: null }); }); }); diff --git a/test/process/hub-router-reinstall.test.ts b/test/process/hub-router-reinstall.test.ts index dc92b7a..db9c7f7 100644 --- a/test/process/hub-router-reinstall.test.ts +++ b/test/process/hub-router-reinstall.test.ts @@ -6,7 +6,7 @@ import { FakeDaemon } from '../support/fake-daemon.js'; import { configure, connectedHost, - host, + fx, INSTALL_TIMEOUT, installedBox, instanceOf, @@ -14,7 +14,6 @@ import { packageVersion, REMOTE_TIMEOUT, remoteInstance, - ssh, useHubRouterHost, } from '../support/hub-router.js'; @@ -24,7 +23,7 @@ useHubRouterHost(); describe('remote daemon restart and reinstall', () => { it('restarts a connected remote daemon into a new instance', async () => { const { remote } = await installedBox(); - const client = await host.session(); + const client = await fx.host.session(); const old = await remoteInstance(client, 1); const [oldPid] = remote.daemonPids(); const from = client.mark(); @@ -41,7 +40,7 @@ describe('remote daemon restart and reinstall', () => { const { remote } = await installedBox(); const releases = remote.releases(); const fake = await FakeDaemon.listen(remote.socket, { protocol: 4, version: '0.0.4', instance: 'old_remote' }); - const client = await host.session(); + const client = await fx.host.session(); expect(await client.waitHost(1, (h) => h.status === 'outdated', { timeout: REMOTE_TIMEOUT })).toMatchObject({ daemonVersion: '0.0.4', instance: 'old_remote', @@ -64,7 +63,7 @@ describe('remote daemon restart and reinstall', () => { async () => { const remote = nodeRemote('box'); configure([{ name: 'box', ssh: 'box' }]); - const client = await host.session(); + const client = await fx.host.session(); await client.waitHost(1, (h) => h.status === 'down', { timeout: 10_000 }); const from = client.mark(); const reply = await client.hubRequest({ t: 'reinstallDaemon', host: 1 }, INSTALL_TIMEOUT); @@ -79,9 +78,9 @@ describe('remote daemon restart and reinstall', () => { it( 'reports an installation failure as internal, naming the install step and the missing Node', async () => { - ssh.addHost('box'); + fx.ssh.addHost('box'); configure([{ name: 'box', ssh: 'box' }]); - const client = await host.session(); + const client = await fx.host.session(); await client.waitHost(1, (h) => h.status === 'down', { timeout: 10_000 }); const reply = await client.hubRequest({ t: 'reinstallDaemon', host: 1 }, INSTALL_TIMEOUT); expect(reply).toMatchObject({ from: 'hub', m: { t: 'error', req: 1, host: 1, code: 'internal' } }); @@ -97,8 +96,8 @@ describe('remote daemon restart and reinstall', () => { async () => { const { remote } = await installedBox(); await FakeDaemon.listen(remote.socket, { protocol: 4, version: '0.0.4', instance: 'old_remote' }); - const a = await host.session(); - const b = await host.session(); + const a = await fx.host.session(); + const b = await fx.host.session(); await a.waitHost(1, (h) => h.status === 'outdated', { timeout: REMOTE_TIMEOUT }); await b.waitHost(1, (h) => h.status === 'outdated', { timeout: REMOTE_TIMEOUT }); const fromA = a.mark(); @@ -120,18 +119,18 @@ describe('remote daemon restart and reinstall', () => { ); it('refuses to reinstall the local host, touching no daemon', async () => { - const client = await host.session(); + const client = await fx.host.session(); const instance = await instanceOf(client); - const [pid] = host.daemonPids(); + const [pid] = fx.host.daemonPids(); const reply = await client.hubRequest({ t: 'reinstallDaemon', host: 0 }); expect(reply).toMatchObject({ from: 'hub', m: { t: 'error', req: 1, host: 0, code: 'internal' } }); await sleep(500); - expect(host.daemonPids()).toEqual([pid]); + expect(fx.host.daemonPids()).toEqual([pid]); expect(client.hosts()[0]).toMatchObject({ status: 'connected', instance }); }); it('answers a reinstall for an unknown host with unknown-host', async () => { - const client = await host.session(); + const client = await fx.host.session(); const reply = await client.hubRequest({ t: 'reinstallDaemon', host: 7 }); expect(reply).toMatchObject({ from: 'hub', m: { t: 'error', req: 1, code: 'unknown-host' } }); }); diff --git a/test/process/hub-router-remote.test.ts b/test/process/hub-router-remote.test.ts index c8cd9fa..9ce9aaa 100644 --- a/test/process/hub-router-remote.test.ts +++ b/test/process/hub-router-remote.test.ts @@ -7,15 +7,12 @@ import { ANY_INSTANCE, configure, connectedHost, - host, - hub, + fx, installedBox, MiB, packageVersion, REMOTE_TIMEOUT, remoteInstance, - repo, - ssh, useHubRouterHost, } from '../support/hub-router.js'; @@ -36,7 +33,7 @@ const statusesOf = (client: HubClient, idx: number, from = 0): string[] => /** The SSH runs of `box` that ran the connect command. */ const connectRuns = (): number[] => - ssh + fx.ssh .calls() .filter((call) => call.alias === 'box' && call.command.includes('connect')) .map((call) => call.pid); @@ -50,15 +47,15 @@ const needsLogin = async (client: HubClient, from = 0): Promise<{ entry: HostEnt describe('remote hosts', () => { it('lists configured remote hosts after the local host, in configuration order', async () => { - ssh.failHost('b-alias', 'ssh: Could not resolve hostname b-alias'); - ssh.failHost('a-alias', 'ssh: Could not resolve hostname a-alias'); + fx.ssh.failHost('b-alias', 'ssh: Could not resolve hostname b-alias'); + fx.ssh.failHost('a-alias', 'ssh: Could not resolve hostname a-alias'); configure([ { name: 'b', ssh: 'b-alias' }, { name: 'a', ssh: 'a-alias', repos: ['/srv/a'] }, ]); - const client = await host.session(); + const client = await fx.host.session(); expect(client.hosts().map((h) => [h.idx, h.name, h.remote, h.repos])).toEqual([ - [0, hostname().slice(0, 64) || 'local', false, [repo]], + [0, hostname().slice(0, 64) || 'local', false, [fx.repo]], [1, 'b', true, []], [2, 'a', true, ['/srv/a']], ]); @@ -66,7 +63,7 @@ describe('remote hosts', () => { it('connects a remote host through its SSH command, reporting its name, index and the remote daemon’s instance', async () => { const { remote, remoteRepo } = await installedBox(); - const client = await host.session(); + const client = await fx.host.session(); const entry = await connectedHost(client, 1); expect(entry).toEqual({ idx: 1, @@ -81,7 +78,7 @@ describe('remote hosts', () => { }); expect(remote.daemonPids()).toHaveLength(1); expect((await remote.hello()).instance).toBe(entry.instance); - const link = ssh.calls().find((call) => call.alias === 'box' && call.command.includes('connect')); + const link = fx.ssh.calls().find((call) => call.alias === 'box' && call.command.includes('connect')); expect(link?.command).toBe('"$HOME/.local/bin/wtd" connect'); expect(link?.args.slice(0, 12)).toEqual([ '-o', @@ -93,7 +90,7 @@ describe('remote hosts', () => { '-o', 'ControlPersist=10m', '-o', - `ControlPath=${host.runDir}/ssh-%C`, + `ControlPath=${fx.host.runDir}/ssh-%C`, '-o', 'ServerAliveInterval=15', ]); @@ -101,7 +98,7 @@ describe('remote hosts', () => { it('round-trips requests, terminals and output through a remote host', async () => { const { remoteRepo } = await installedBox(); - const client = await host.session(); + const client = await fx.host.session(); await connectedHost(client, 1); const state = await client.watch(1, remoteRepo); expect(state.worktrees.map((w) => w.path)).toEqual([remoteRepo]); @@ -114,10 +111,10 @@ describe('remote hosts', () => { it('reports reconnecting, then connected to the same daemon, when the SSH process is killed', async () => { await installedBox(); - const client = await host.session(); + const client = await fx.host.session(); const first = await remoteInstance(client, 1); const pids = await waitUntil(() => { - const live = ssh.livePids('box'); + const live = fx.ssh.livePids('box'); return live.length > 0 ? live : undefined; }, 'the hub’s SSH process'); const from = client.mark(); @@ -127,9 +124,9 @@ describe('remote hosts', () => { }); it('reports a host whose SSH command keeps failing as down, naming SSH’s last error line', async () => { - ssh.failHost('box', 'ssh: Could not resolve hostname box'); + fx.ssh.failHost('box', 'ssh: Could not resolve hostname box'); configure([{ name: 'box', ssh: 'box' }]); - const client = await host.session(); + const client = await fx.host.session(); expect(await client.waitHost(1, (h) => h.status === 'reconnecting')).toMatchObject({ reason: 'ssh: Could not resolve hostname box', instance: null, @@ -143,48 +140,48 @@ describe('remote hosts', () => { }); it('reports a remote without wtd as down, with a reason', async () => { - ssh.addHost('box'); + fx.ssh.addHost('box'); configure([{ name: 'box', ssh: 'box' }]); - const client = await host.session(); + const client = await fx.host.session(); const entry = await client.waitHost(1, (h) => h.status === 'down', { timeout: 10_000 }); expect(entry.reason).toEqual(expect.stringMatching(/\S/)); }); it('keeps its memory bounded and the reason short when SSH writes 8 MiB to standard error without a newline', async () => { - const before = residentBytes(hub.pid); - ssh.spewHost('box', 8 * MiB); + const before = residentBytes(fx.hub.pid); + fx.ssh.spewHost('box', 8 * MiB); configure([{ name: 'box', ssh: 'box' }]); - const client = await host.session(); + const client = await fx.host.session(); const entry = await client.waitHost(1, (h) => h.status === 'down', { timeout: 30_000 }); expect(entry.reason?.length ?? 0).toBeGreaterThan(0); expect(entry.reason?.length ?? 0).toBeLessThanOrEqual(1024); - expect(residentBytes(hub.pid) - before).toBeLessThan(16 * MiB); + expect(residentBytes(fx.hub.pid) - before).toBeLessThan(16 * MiB); }); it('ends an SSH process that writes over 1 MiB to standard error instead of leaving its link stalled', async () => { await installedBox(); - ssh.noisyHost('box', 2 * MiB); - const client = await host.session(); + fx.ssh.noisyHost('box', 2 * MiB); + const client = await fx.host.session(); const entry = await client.waitHost(1, (h) => h.status === 'reconnecting' || h.status === 'down', { timeout: REMOTE_TIMEOUT }); expect(entry.reason).toMatch(/^x{1,1024}$/); }); it('answers requests to a down remote host with host-unavailable', async () => { - ssh.failHost('box', 'ssh: Could not resolve hostname box'); + fx.ssh.failHost('box', 'ssh: Could not resolve hostname box'); configure([{ name: 'box', ssh: 'box' }]); - const client = await host.session(); + const client = await fx.host.session(); await client.waitHost(1, (h) => h.status === 'reconnecting' || h.status === 'down'); const reply = await client.request(1, { t: 'watchRepo', repo: '/srv/app' }); expect(reply).toMatchObject({ from: 'hub', m: { t: 'error', req: 1, host: 1, code: 'host-unavailable' } }); }); it('kills SSH processes of a closed session that ignore SIGTERM', async () => { - ssh.addHost('box'); - ssh.hangHost('box'); + fx.ssh.addHost('box'); + fx.ssh.hangHost('box'); configure([{ name: 'box', ssh: 'box' }]); - const client = await host.session(); + const client = await fx.host.session(); const pids = await waitUntil(() => { - const live = ssh.livePids('box'); + const live = fx.ssh.livePids('box'); return live.length > 0 && live; }, 'an SSH process for box'); client.close(); @@ -194,9 +191,9 @@ describe('remote hosts', () => { it('ends the SSH processes of a closed session', async () => { await installedBox(); - const client = await host.session(); + const client = await fx.host.session(); await connectedHost(client, 1); - const pids = ssh.livePids('box'); + const pids = fx.ssh.livePids('box'); expect(pids.length).toBeGreaterThan(0); client.close(); await waitUntil(() => pids.every((pid) => !alive(pid)), 'the session’s SSH processes to end', 5000); @@ -206,17 +203,17 @@ describe('remote hosts', () => { describe('Tailscale SSH check login', () => { it('reports needs-login with the URL within 1 s of the banner, then connects over the same SSH process once approved', async () => { await installedBox(); - ssh.loginHost('box'); + fx.ssh.loginHost('box'); const before = connectRuns().length; - const client = await host.session(); + const client = await fx.host.session(); await waitUntil(() => connectRuns().length > before, 'the hub’s SSH process for box'); const entry = await client.waitHost(1, (h) => h.status === 'needs-login', { timeout: 5000 }); const receivedAt = Date.now(); expect(entry).toMatchObject({ status: 'needs-login', login: LOGIN_URL, reason: null, daemonVersion: null, instance: null }); const url = entry.login ?? ''; - expect(receivedAt - ssh.loginWrittenAt(url)).toBeLessThanOrEqual(1000); + expect(receivedAt - fx.ssh.loginWrittenAt(url)).toBeLessThanOrEqual(1000); expect(connectRuns()).toHaveLength(before + 1); - ssh.approveLogin(url); + fx.ssh.approveLogin(url); expect(await connectedHost(client, 1)).toMatchObject({ login: null, reason: null }); expect(connectRuns()).toHaveLength(before + 1); expect(alive(loginRun(url))).toBe(true); @@ -225,11 +222,11 @@ describe('Tailscale SSH check login', () => { it('reports reconnecting with SSH’s reason and a null login when the check expires, then the next run’s URL', async () => { await installedBox(); - ssh.loginHost('box'); - const client = await host.session(); + fx.ssh.loginHost('box'); + const client = await fx.host.session(); const { url: first } = await needsLogin(client); const from = client.mark(); - ssh.expireLogin(first, 'Tailscale SSH check timed out'); + fx.ssh.expireLogin(first, 'Tailscale SSH check timed out'); expect(await client.waitHost(1, (h) => h.status === 'reconnecting', { from })).toMatchObject({ reason: 'Tailscale SSH check timed out', login: null, @@ -240,8 +237,8 @@ describe('Tailscale SSH check login', () => { it('answers a request to a needs-login host with host-unavailable', async () => { await installedBox(); - ssh.loginHost('box'); - const client = await host.session(); + fx.ssh.loginHost('box'); + const client = await fx.host.session(); await needsLogin(client); const reply = await client.request(1, { t: 'watchRepo', repo: '/srv/app' }); expect(reply).toMatchObject({ from: 'hub', m: { t: 'error', req: 1, host: 1, code: 'host-unavailable' } }); @@ -249,20 +246,20 @@ describe('Tailscale SSH check login', () => { it('redials every other needs-login session once one session’s link greets, leaving connected sessions alone', async () => { await installedBox(); - const c = await host.session(); + const c = await fx.host.session(); const cEntry = await connectedHost(c, 1); - ssh.loginHost('box'); - const a = await host.session(); + fx.ssh.loginHost('box'); + const a = await fx.host.session(); const { url: aUrl } = await needsLogin(a); - const b = await host.session(); + const b = await fx.host.session(); const { url: bUrl } = await needsLogin(b); const bRun = loginRun(bUrl); expect(bRun).not.toBe(loginRun(aUrl)); const runsBefore = connectRuns().length; const fromB = b.mark(); const fromC = c.mark(); - ssh.skipLogin('box'); - ssh.approveLogin(aUrl); + fx.ssh.skipLogin('box'); + fx.ssh.approveLogin(aUrl); await connectedHost(a, 1); expect(await connectedHost(b, 1, fromB)).toMatchObject({ login: null }); expect(statusesOf(b, 1, fromB)).toEqual(['connecting', 'connected']); diff --git a/test/process/hub-router-routing.test.ts b/test/process/hub-router-routing.test.ts index 94622d0..a2a412c 100644 --- a/test/process/hub-router-routing.test.ts +++ b/test/process/hub-router-routing.test.ts @@ -4,7 +4,7 @@ import { describe, expect, it, vi } from 'vitest'; import { FLOW_HIGH, FrameKind, DAEMON_PROTOCOL_VERSION, type Terminal } from '../../src/protocol/index.js'; import { alive, DaemonHost, residentBytes, sleep, waitUntil } from '../support/daemon-host.js'; import { FakeDaemon, SocketProxy } from '../support/fake-daemon.js'; -import { ANY_TEXT, connected, host, hub, MiB, repo, started, useHubRouterHost, watching, wt } from '../support/hub-router.js'; +import { ANY_TEXT, connected, fx, MiB, started, useHubRouterHost, watching } from '../support/hub-router.js'; vi.setConfig({ testTimeout: 60_000, hookTimeout: 30_000 }); useHubRouterHost(); @@ -14,10 +14,10 @@ describe('routing', () => { const backend = DaemonHost.create(); try { await backend.start(); - const proxy = await SocketProxy.listen(host.socket, backend.socket); - const client = await host.session(); + const proxy = await SocketProxy.listen(fx.host.socket, backend.socket); + const client = await fx.host.session(); await connected(client); - await client.watch(0, repo); + await client.watch(0, fx.repo); const term = await started(client, null); client.sendInput(0, term.termId, 'echo round-$((40+2))-trip\r'); await client.waitOutput(0, term.termId, 'round-42-trip'); @@ -44,16 +44,16 @@ describe('routing', () => { const backend = DaemonHost.create(); try { await backend.start(); - const proxy = await SocketProxy.listen(host.socket, backend.socket); - const client = await host.session(); + const proxy = await SocketProxy.listen(fx.host.socket, backend.socket); + const client = await fx.host.session(); await connected(client); client.autoAck = false; - await client.watch(0, repo); + await client.watch(0, fx.repo); const term = await started(client, 'seq 1 50000; echo SEQ-DONE; exec sleep 60'); await client.waitOutput(0, term.termId, 'SEQ-DONE', 10_000); await sleep(500); const sent = (): unknown[] => proxy.controlTextsToDaemon().map((text): unknown => JSON.parse(text)); - expect(sent()).toContainEqual({ t: 'watchRepo', req: 1, repo }); + expect(sent()).toContainEqual({ t: 'watchRepo', req: 1, repo: fx.repo }); expect(sent().filter((m) => typeof m === 'object' && m !== null && 't' in m && m.t === 'ack')).toEqual([]); const position = client.view(0, term.termId).position; client.sendHost(0, { t: 'ack', termId: term.termId, offset: position }); @@ -68,10 +68,10 @@ describe('routing', () => { }); it('answers a request to a reconnecting host with host-unavailable and drops its fire-and-forget messages', async () => { - await FakeDaemon.listen(host.socket, { protocol: DAEMON_PROTOCOL_VERSION, accept: 'close' }); - const client = await host.session(); + await FakeDaemon.listen(fx.host.socket, { protocol: DAEMON_PROTOCOL_VERSION, accept: 'close' }); + const client = await fx.host.session(); await client.waitHost(0, (h) => h.status === 'reconnecting' || h.status === 'down'); - const reply = await client.request(0, { t: 'watchRepo', repo }); + const reply = await client.request(0, { t: 'watchRepo', repo: fx.repo }); expect(reply).toEqual({ from: 'hub', m: { t: 'error', req: 1, host: 0, code: 'host-unavailable', message: ANY_TEXT } }); const from = client.mark(); client.sendHost(0, { t: 'ack', termId: 1, offset: 0 }); @@ -84,17 +84,17 @@ describe('routing', () => { }); it('answers a request to an outdated host with version-mismatch', async () => { - await FakeDaemon.listen(host.socket, { protocol: 99 }); - const client = await host.session(); + await FakeDaemon.listen(fx.host.socket, { protocol: 99 }); + const client = await fx.host.session(); await client.waitHost(0, (h) => h.status === 'outdated'); - const reply = await client.request(0, { t: 'watchRepo', repo }); + const reply = await client.request(0, { t: 'watchRepo', repo: fx.repo }); expect(reply).toMatchObject({ from: 'hub', m: { t: 'error', req: 1, host: 0, code: 'version-mismatch' } }); }); it('answers a message for an unknown host with unknown-host', async () => { - const client = await host.session(); + const client = await fx.host.session(); await connected(client); - const reply = await client.request(5, { t: 'watchRepo', repo }); + const reply = await client.request(5, { t: 'watchRepo', repo: fx.repo }); expect(reply).toMatchObject({ from: 'hub', m: { t: 'error', req: 1, code: 'unknown-host' } }); const from = client.mark(); client.sendHost(5, { t: 'setVisible', termIds: [] }); @@ -103,13 +103,13 @@ describe('routing', () => { }); it('keeps sessions with the same request ids apart', async () => { - const a = await host.session(); - const b = await host.session(); + const a = await fx.host.session(); + const b = await fx.host.session(); await connected(a); await connected(b); - await a.watch(0, repo); - await b.watch(0, repo); - const term = await b.create(0, wt, { command: 'exec sleep 60' }); + await a.watch(0, fx.repo); + await b.watch(0, fx.repo); + const term = await b.create(0, fx.wt, { command: 'exec sleep 60' }); await a.waitEvent(0, 'termCreated', (m) => m.term.termId === term.termId); expect(a.daemonEvents(0, 'done').map((m) => m.req)).toEqual([1]); expect(b.daemonEvents(0, 'done').map((m) => m.req)).toEqual([1]); @@ -120,14 +120,14 @@ describe('routing', () => { it('relays a snapshot near the frame limit intact', async () => { const client = await watching(); const awk = `awk 'BEGIN { for (l = 0; l < 5100; l++) { s = ""; for (c = 0; c < 500; c++) s = s "\\033[31ma\\033[32mb"; print s } }'; echo BIG-DONE`; - const term = await client.create(0, wt, { command: awk, cols: 1000, rows: 50 }); + const term = await client.create(0, fx.wt, { command: awk, cols: 1000, rows: 50 }); await client.waitEvent(0, 'termExited', (m) => m.termId === term.termId, { timeout: 90_000 }); const view = await client.attach(0, term.termId, 60_000); const snapshot = view.attachments[0]?.snapshot; if (snapshot === undefined) throw new Error('no snapshot'); expect(snapshot.length).toBeGreaterThan(8 * MiB); - const direct = await host.client(); - await direct.watch(repo); + const direct = await fx.host.client(); + await direct.watch(fx.repo); const reference = (await direct.attach(term.termId, 60_000)).attachments[0]?.snapshot; expect(Buffer.from(snapshot).equals(Buffer.from(reference ?? new Uint8Array()))).toBe(true); expect(await view.screen.text()).toContain('BIG-DONE'); @@ -155,22 +155,23 @@ describe('back-pressure', () => { const stalled = await watching(); const shell = await started(stalled, null); const floods: Terminal[] = []; - for (let i = 0; i < 20; i++) floods.push(await stalled.create(0, wt, { command: 'yes flood-line-with-some-padding-to-fill-frames' })); + for (let i = 0; i < 20; i++) + floods.push(await stalled.create(0, fx.wt, { command: 'yes flood-line-with-some-padding-to-fill-frames' })); await sleep(1000); - const baseline = residentBytes(hub.pid); + const baseline = residentBytes(fx.hub.pid); for (const flood of floods) await stalled.attach(0, flood.termId, 10_000); stalled.pauseReading(); - const marker = join(host.dir, 'typed-while-stalled'); + const marker = join(fx.host.dir, 'typed-while-stalled'); let peak = baseline; const tickerBefore = reader.view(0, ticker.termId).bytes().length; for (let i = 0; i < 10; i++) { await sleep(500); - peak = Math.max(peak, residentBytes(hub.pid)); + peak = Math.max(peak, residentBytes(fx.hub.pid)); if (i === 2) stalled.sendInput(0, shell.termId, `touch ${marker}\r`); } expect(peak - baseline).toBeLessThan(64 * MiB); expect(reader.view(0, ticker.termId).bytes().length).toBeGreaterThan(tickerBefore); await waitUntil(() => existsSync(marker), 'input from the stalled session'); - expect(alive(hub.pid)).toBe(true); + expect(alive(fx.hub.pid)).toBe(true); }); }); diff --git a/test/process/hub-server-codes.test.ts b/test/process/hub-server-codes.test.ts index 780f485..891ef5f 100644 --- a/test/process/hub-server-codes.test.ts +++ b/test/process/hub-server-codes.test.ts @@ -1,28 +1,28 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { sleep } from '../support/daemon-host.js'; -import { ANY_HEX64, header, host, refusal, useHubServerHost } from '../support/hub-server.js'; +import { ANY_HEX64, fx, header, refusal, useHubServerHost } from '../support/hub-server.js'; vi.setConfig({ testTimeout: 60_000, hookTimeout: 30_000 }); useHubServerHost(); describe('one-time codes', () => { beforeEach(async () => { - await host.startHub(); + await fx.host.startHub(); }); it('issues a code response with Cache-Control no-store', async () => { - const response = await host.api('POST', '/api/code'); + const response = await fx.host.api('POST', '/api/code'); expect(response.status).toBe(200); expect(header(response, 'cache-control')).toEqual(['no-store']); expect(JSON.parse(response.body)).toEqual({ code: ANY_HEX64 }); }); it('issues a different code each time', async () => { - expect(await host.code()).not.toBe(await host.code()); + expect(await fx.host.code()).not.toBe(await fx.host.code()); }); it('accepts the hub’s own Origin', async () => { - expect((await host.api('POST', '/api/code', { origin: host.origin })).status).toBe(200); + expect((await fx.host.api('POST', '/api/code', { origin: fx.host.origin })).status).toBe(200); }); it.each([ @@ -30,19 +30,19 @@ describe('one-time codes', () => { ['no token', () => null], ['a malformed token', () => 'x'], ])('refuses %s with 401', async (_name, token) => { - expect((await host.api('POST', '/api/code', { token: token() })).status).toBe(401); + expect((await fx.host.api('POST', '/api/code', { token: token() })).status).toBe(401); }); it('refuses a cross-origin request with 403', async () => { - expect((await host.api('POST', '/api/code', { origin: 'http://evil.example' })).status).toBe(403); + expect((await fx.host.api('POST', '/api/code', { origin: 'http://evil.example' })).status).toBe(403); }); it('accepts a body of exactly 1 KiB', async () => { - expect((await host.api('POST', '/api/code', { body: 'x'.repeat(1024) })).status).toBe(200); + expect((await fx.host.api('POST', '/api/code', { body: 'x'.repeat(1024) })).status).toBe(200); }); it('refuses a body over 1 KiB', async () => { - const response = await host.api('POST', '/api/code', { body: 'x'.repeat(1025) }); + const response = await fx.host.api('POST', '/api/code', { body: 'x'.repeat(1025) }); expect(response.status).toBeGreaterThanOrEqual(400); expect(response.status).toBeLessThan(500); }); @@ -50,12 +50,12 @@ describe('one-time codes', () => { describe('WebSocket authentication', () => { beforeEach(async () => { - await host.startHub(); + await fx.host.startHub(); }); it('refuses a code issued more than 30 s earlier with 401', async () => { - const code = await host.code(); + const code = await fx.host.code(); await sleep(31_000); - expect(await refusal(host.open(['wtd', `wtd.code.${code}`]))).toBe(401); + expect(await refusal(fx.host.open(['wtd', `wtd.code.${code}`]))).toBe(401); }, 90_000); }); diff --git a/test/process/hub-server-http.test.ts b/test/process/hub-server-http.test.ts index af920d6..e6c2a40 100644 --- a/test/process/hub-server-http.test.ts +++ b/test/process/hub-server-http.test.ts @@ -3,7 +3,7 @@ import { join } from 'node:path'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { sleep, type WtdProcess } from '../support/daemon-host.js'; import type { RawResponse } from '../support/hub-host.js'; -import { ANY_WORD, header, HEX64, host, packageVersion, useHubServerHost, waitExit, WEB } from '../support/hub-server.js'; +import { ANY_WORD, fx, header, HEX64, packageVersion, useHubServerHost, waitExit, WEB } from '../support/hub-server.js'; vi.setConfig({ testTimeout: 60_000, hookTimeout: 30_000 }); useHubServerHost(); @@ -52,82 +52,82 @@ const listeningSockets = (pid: number): string[] => { /** Leaves the state directory owner-only, as the hub would create it. */ const prepareStateDir = (): void => { - mkdirSync(host.stateDir, { recursive: true, mode: 0o700 }); - chmodSync(host.stateDir, 0o700); + mkdirSync(fx.host.stateDir, { recursive: true, mode: 0o700 }); + chmodSync(fx.host.stateDir, 0o700); }; describe('loopback listener and hub record', () => { it('listens on exactly one TCP socket, 127.0.0.1 at the configured port', async () => { - const hub = await host.startHub(); - expect(listeningSockets(hub.pid)).toEqual([`127.0.0.1:${String(host.port)}`]); + const hub = await fx.host.startHub(); + expect(listeningSockets(hub.pid)).toEqual([`127.0.0.1:${String(fx.host.port)}`]); }); it('writes its record while running and removes it on SIGTERM', async () => { - const hub = await host.startHub(); - const record = host.record(); - expect(record).toEqual({ pid: hub.pid, port: host.port, instance: ANY_WORD }); - expect(statSync(host.recordPath).mode & 0o777).toBe(0o600); + const hub = await fx.host.startHub(); + const record = fx.host.record(); + expect(record).toEqual({ pid: hub.pid, port: fx.host.port, instance: ANY_WORD }); + expect(statSync(fx.host.recordPath).mode & 0o777).toBe(0o600); hub.kill('SIGTERM'); expect(await waitExit(hub)).toEqual({ code: 0, signal: null }); - expect(existsSync(host.recordPath)).toBe(false); + expect(existsSync(fx.host.recordPath)).toBe(false); }); it('leaves a record naming another instance on exit', async () => { - const hub = await host.startHub(); - const other = { pid: hub.pid, port: host.port, instance: 'another_hub' }; - writeFileSync(host.recordPath, JSON.stringify(other)); + const hub = await fx.host.startHub(); + const other = { pid: hub.pid, port: fx.host.port, instance: 'another_hub' }; + writeFileSync(fx.host.recordPath, JSON.stringify(other)); hub.kill('SIGTERM'); await waitExit(hub); - expect(host.record()).toEqual(other); + expect(fx.host.record()).toEqual(other); }); }); describe('persistent token', () => { it('is 64 lowercase hex digits in an owner-only file', async () => { - await host.startHub(); - expect(host.token()).toMatch(HEX64); - expect(statSync(host.tokenPath).mode & 0o777).toBe(0o600); + await fx.host.startHub(); + expect(fx.host.token()).toMatch(HEX64); + expect(statSync(fx.host.tokenPath).mode & 0o777).toBe(0o600); }); it('persists across restarts', async () => { - const first = await host.startHub(); - const token = host.token(); + const first = await fx.host.startHub(); + const token = fx.host.token(); first.kill('SIGTERM'); await waitExit(first); - await host.startHub(); - expect(host.token()).toBe(token); - expect((await host.api('GET', '/api/identity', { token })).status).toBe(200); + await fx.host.startHub(); + expect(fx.host.token()).toBe(token); + expect((await fx.host.api('GET', '/api/identity', { token })).status).toBe(200); }); it('refuses to start when the token path is a symbolic link, naming it', async () => { prepareStateDir(); - const target = join(host.dir, 'elsewhere'); + const target = join(fx.host.dir, 'elsewhere'); writeFileSync(target, 'a'.repeat(64), { mode: 0o600 }); - symlinkSync(target, host.tokenPath); - const hub = host.wtd(['hub']); + symlinkSync(target, fx.host.tokenPath); + const hub = fx.host.wtd(['hub']); expect(await waitExit(hub)).toEqual({ code: 1, signal: null }); expectOneLine(hub.stderr); - expect(hub.stderr).toContain(host.tokenPath); + expect(hub.stderr).toContain(fx.host.tokenPath); expect(readFileSync(target, 'utf8')).toBe('a'.repeat(64)); }); it('refuses to start when the token path is a directory, naming it', async () => { prepareStateDir(); - mkdirSync(host.tokenPath); - const hub = host.wtd(['hub']); + mkdirSync(fx.host.tokenPath); + const hub = fx.host.wtd(['hub']); expect(await waitExit(hub)).toEqual({ code: 1, signal: null }); expectOneLine(hub.stderr); - expect(hub.stderr).toContain(host.tokenPath); + expect(hub.stderr).toContain(fx.host.tokenPath); }); it('keeps a valid token with a loose mode and tightens it to 0600', async () => { prepareStateDir(); const token = '0123456789abcdef'.repeat(4); - writeFileSync(host.tokenPath, token, { mode: 0o644 }); - chmodSync(host.tokenPath, 0o644); - await host.startHub(); - expect(host.token()).toBe(token); - expect(statSync(host.tokenPath).mode & 0o777).toBe(0o600); + writeFileSync(fx.host.tokenPath, token, { mode: 0o644 }); + chmodSync(fx.host.tokenPath, 0o644); + await fx.host.startHub(); + expect(fx.host.token()).toBe(token); + expect(statSync(fx.host.tokenPath).mode & 0o777).toBe(0o600); }); it.each([ @@ -137,47 +137,50 @@ describe('persistent token', () => { ['empty', ''], ])('replaces a malformed token (%s) with a new valid one', async (_name, content) => { prepareStateDir(); - writeFileSync(host.tokenPath, content, { mode: 0o600 }); - await host.startHub(); - expect(host.token()).toMatch(HEX64); - expect(statSync(host.tokenPath).mode & 0o777).toBe(0o600); - expect((await host.api('GET', '/api/identity')).status).toBe(200); + writeFileSync(fx.host.tokenPath, content, { mode: 0o600 }); + await fx.host.startHub(); + expect(fx.host.token()).toMatch(HEX64); + expect(statSync(fx.host.tokenPath).mode & 0o777).toBe(0o600); + expect((await fx.host.api('GET', '/api/identity')).status).toBe(200); }); }); describe('request checks and headers', () => { beforeEach(async () => { - await host.startHub(); + await fx.host.startHub(); }); it.each([ - ['localhost', () => `localhost:${String(host.port)}`], + ['localhost', () => `localhost:${String(fx.host.port)}`], ['a foreign name', () => 'evil.example'], ['127.0.0.1 without the port', () => '127.0.0.1'], - ['another port', () => `127.0.0.1:${String(host.port + 1)}`], + ['another port', () => `127.0.0.1:${String(fx.host.port + 1)}`], ])('refuses a request whose Host is %s with 403', async (_name, value) => { - expect((await host.http('GET', '/', { host: value() })).status).toBe(403); - expect((await host.api('GET', '/api/identity')).status).toBe(200); - const identity = await host.http('GET', '/api/identity', { host: value(), headers: [['Authorization', `Bearer ${host.token()}`]] }); + expect((await fx.host.http('GET', '/', { host: value() })).status).toBe(403); + expect((await fx.host.api('GET', '/api/identity')).status).toBe(200); + const identity = await fx.host.http('GET', '/api/identity', { + host: value(), + headers: [['Authorization', `Bearer ${fx.host.token()}`]], + }); expect(identity.status).toBe(403); }); it('refuses a request without Host with 403', async () => { - expect((await host.http('GET', '/', { host: null })).status).toBe(403); + expect((await fx.host.http('GET', '/', { host: null })).status).toBe(403); }); it('refuses a request with two Host headers with 403', async () => { - const own = `127.0.0.1:${String(host.port)}`; - expect((await host.http('GET', '/', { headers: [['Host', own]] })).status).toBe(403); - expect((await host.http('GET', '/', { headers: [['Host', 'evil.example']] })).status).toBe(403); + const own = `127.0.0.1:${String(fx.host.port)}`; + expect((await fx.host.http('GET', '/', { headers: [['Host', own]] })).status).toBe(403); + expect((await fx.host.http('GET', '/', { headers: [['Host', 'evil.example']] })).status).toBe(403); }); it('refuses an upgrade with a foreign Host with 403', async () => { - expect((await host.upgrade(['wtd', `wtd.token.${host.token()}`], { host: 'evil.example' })).status).toBe(403); + expect((await fx.host.upgrade(['wtd', `wtd.token.${fx.host.token()}`], { host: 'evil.example' })).status).toBe(403); }); it('sends the security headers with index.html', async () => { - const response = await host.http('GET', '/'); + const response = await fx.host.http('GET', '/'); expect(response.status).toBe(200); expectSecurityHeaders(response); }); @@ -186,11 +189,11 @@ describe('request checks and headers', () => { const asset = readdirSync(join(WEB, 'assets'))[0]; if (asset === undefined) throw new Error('no web asset'); for (const response of [ - await host.http('GET', `/assets/${asset}`), - await host.http('GET', '/nonexistent'), - await host.api('GET', '/api/identity'), - await host.api('GET', '/api/identity', { token: null }), - await host.http('GET', '/', { host: 'evil.example' }), + await fx.host.http('GET', `/assets/${asset}`), + await fx.host.http('GET', '/nonexistent'), + await fx.host.api('GET', '/api/identity'), + await fx.host.api('GET', '/api/identity', { token: null }), + await fx.host.http('GET', '/', { host: 'evil.example' }), ]) { expectSecurityHeaders(response); } @@ -199,11 +202,11 @@ describe('request checks and headers', () => { describe('static bundle', () => { beforeEach(async () => { - await host.startHub(); + await fx.host.startHub(); }); it('serves index.html at / uncached', async () => { - const response = await host.http('GET', '/'); + const response = await fx.host.http('GET', '/'); expect(response.status).toBe(200); expect(header(response, 'cache-control')).toEqual(['no-cache']); expect(header(response, 'content-type')[0]).toMatch(/^text\/html/); @@ -214,7 +217,7 @@ describe('static bundle', () => { const assets = readdirSync(join(WEB, 'assets')); expect(assets.length).toBeGreaterThan(0); for (const asset of assets) { - const response = await host.http('GET', `/assets/${asset}`); + const response = await fx.host.http('GET', `/assets/${asset}`); expect(response.status).toBe(200); expect(header(response, 'cache-control')).toEqual(['public, max-age=31536000, immutable']); if (asset.endsWith('.js')) { @@ -225,13 +228,13 @@ describe('static bundle', () => { }); it.each(['/nonexistent', '/index.htm', '/assets/', '/assets/nonexistent.js', '/api', '/ws/x'])('answers %s with 404', async (path) => { - expect((await host.http('GET', path)).status).toBe(404); + expect((await fx.host.http('GET', path)).status).toBe(404); }); it.each(['/../package.json', '/%2e%2e/package.json', '/assets/../index.html', '/%2fetc%2fpasswd', '/..%2fpackage.json'])( 'refuses traversal %s with 404', async (path) => { - expect((await host.http('GET', path)).status).toBe(404); + expect((await fx.host.http('GET', path)).status).toBe(404); }, ); }); @@ -240,37 +243,37 @@ describe('identity and shutdown', () => { let hub: WtdProcess; beforeEach(async () => { - hub = await host.startHub(); + hub = await fx.host.startHub(); }); it('reports the package version and the record’s instance', async () => { - expect(await host.identity()).toEqual({ version: packageVersion(), instance: host.record()?.instance }); + expect(await fx.host.identity()).toEqual({ version: packageVersion(), instance: fx.host.record()?.instance }); }); it('refuses identity without the token with 401 and from another origin with 403', async () => { - expect((await host.api('GET', '/api/identity', { token: null })).status).toBe(401); - expect((await host.api('GET', '/api/identity', { token: '1'.repeat(64) })).status).toBe(401); - expect((await host.api('GET', '/api/identity', { origin: 'http://evil.example' })).status).toBe(403); + expect((await fx.host.api('GET', '/api/identity', { token: null })).status).toBe(401); + expect((await fx.host.api('GET', '/api/identity', { token: '1'.repeat(64) })).status).toBe(401); + expect((await fx.host.api('GET', '/api/identity', { origin: 'http://evil.example' })).status).toBe(403); }); it('refuses shutdown without the token with 401 and keeps running', async () => { - expect((await host.api('POST', '/api/shutdown', { token: null })).status).toBe(401); - expect((await host.api('POST', '/api/shutdown', { token: '1'.repeat(64) })).status).toBe(401); + expect((await fx.host.api('POST', '/api/shutdown', { token: null })).status).toBe(401); + expect((await fx.host.api('POST', '/api/shutdown', { token: '1'.repeat(64) })).status).toBe(401); await sleep(300); - expect(await host.serving()).toBe(true); + expect(await fx.host.serving()).toBe(true); }); it('refuses shutdown from another origin with 403 and keeps running', async () => { - expect((await host.api('POST', '/api/shutdown', { origin: 'http://evil.example' })).status).toBe(403); + expect((await fx.host.api('POST', '/api/shutdown', { origin: 'http://evil.example' })).status).toBe(403); await sleep(300); - expect(await host.serving()).toBe(true); + expect(await fx.host.serving()).toBe(true); }); it('answers an authenticated shutdown and then exits 0, removing its record', async () => { - const response = await host.api('POST', '/api/shutdown'); + const response = await fx.host.api('POST', '/api/shutdown'); expect(response.status).toBeGreaterThanOrEqual(200); expect(response.status).toBeLessThan(300); expect(await waitExit(hub)).toEqual({ code: 0, signal: null }); - expect(existsSync(host.recordPath)).toBe(false); + expect(existsSync(fx.host.recordPath)).toBe(false); }); }); diff --git a/test/process/hub-server-websocket.test.ts b/test/process/hub-server-websocket.test.ts index ab1bb6f..0ebac18 100644 --- a/test/process/hub-server-websocket.test.ts +++ b/test/process/hub-server-websocket.test.ts @@ -3,7 +3,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { encodeWsData, MAX_FRAME, BROWSER_PROTOCOL_VERSION } from '../../src/protocol/index.js'; import { sleep, type WtdProcess } from '../support/daemon-host.js'; import type { HubClient, HubMessageOf } from '../support/hub-client.js'; -import { ANY_TEXT, header, host, outcome, packageVersion, refusal, useHubServerHost, waitExit } from '../support/hub-server.js'; +import { ANY_TEXT, fx, header, outcome, packageVersion, refusal, useHubServerHost, waitExit } from '../support/hub-server.js'; vi.setConfig({ testTimeout: 60_000, hookTimeout: 30_000 }); useHubServerHost(); @@ -17,21 +17,21 @@ describe('WebSocket authentication', () => { let token: string; beforeEach(async () => { - hub = await host.startHub(); - token = host.token(); + hub = await fx.host.startHub(); + token = fx.host.token(); }); it('accepts the token and selects the subprotocol wtd', async () => { - const client = await host.open(['wtd', `wtd.token.${token}`]); + const client = await fx.host.open(['wtd', `wtd.token.${token}`]); expect(client.protocol).toBe('wtd'); client.close(); - const raw = await host.upgrade(['wtd', `wtd.token.${token}`]); + const raw = await fx.host.upgrade(['wtd', `wtd.token.${token}`]); expect(raw.status).toBe(101); expect(header(raw, 'sec-websocket-protocol')).toEqual(['wtd']); }); it('accepts the credential before wtd', async () => { - expect((await host.upgrade([`wtd.token.${token}`, 'wtd'])).status).toBe(101); + expect((await fx.host.upgrade([`wtd.token.${token}`, 'wtd'])).status).toBe(101); }); it.each([ @@ -40,19 +40,19 @@ describe('WebSocket authentication', () => { ['localhost as Origin', 'http://localhost'], ['another port as Origin', 'http://127.0.0.1:1'], ])('refuses an upgrade with %s with 403', async (_name, origin) => { - expect(await refusal(host.open(['wtd', `wtd.token.${token}`], origin))).toBe(403); + expect(await refusal(fx.host.open(['wtd', `wtd.token.${token}`], origin))).toBe(403); }); it('refuses an upgrade with two Origin headers with 403', async () => { - const response = await host.http('GET', '/ws', { + const response = await fx.host.http('GET', '/ws', { headers: [ ['Upgrade', 'websocket'], ['Connection', 'Upgrade'], ['Sec-WebSocket-Version', '13'], ['Sec-WebSocket-Key', 'dGhlIHNhbXBsZSBub25jZQ=='], ['Sec-WebSocket-Protocol', `wtd, wtd.token.${token}`], - ['Origin', host.origin], - ['Origin', host.origin], + ['Origin', fx.host.origin], + ['Origin', fx.host.origin], ], headersOnly: true, }); @@ -72,25 +72,25 @@ describe('WebSocket authentication', () => { ['a credential without wtd', () => [`wtd.token.${token}`]], ])('refuses %s and starts no session', async (_name, protocols) => { // Raw upgrade: the ws client itself refuses to offer a duplicated subprotocol. - expect((await host.upgrade(protocols())).status).toBe(401); + expect((await fx.host.upgrade(protocols())).status).toBe(401); await sleep(500); - expect(host.daemonPids()).toEqual([]); + expect(fx.host.daemonPids()).toEqual([]); }); it('refuses an upgrade without Sec-WebSocket-Protocol with 401', async () => { - expect((await host.upgrade(null)).status).toBe(401); + expect((await fx.host.upgrade(null)).status).toBe(401); }); it('accepts upgrades only at /ws', async () => { for (const path of ['/', '/ws/', '/wsx', '/api/code']) { - const response = await host.http('GET', path, { + const response = await fx.host.http('GET', path, { headers: [ ['Upgrade', 'websocket'], ['Connection', 'Upgrade'], ['Sec-WebSocket-Version', '13'], ['Sec-WebSocket-Key', 'dGhlIHNhbXBsZSBub25jZQ=='], ['Sec-WebSocket-Protocol', `wtd, wtd.token.${token}`], - ['Origin', host.origin], + ['Origin', fx.host.origin], ], headersOnly: true, }); @@ -99,75 +99,78 @@ describe('WebSocket authentication', () => { }); it('accepts a code once', async () => { - const code = await host.code(); - const client = await host.open(['wtd', `wtd.code.${code}`]); + const code = await fx.host.code(); + const client = await fx.host.open(['wtd', `wtd.code.${code}`]); expect(client.protocol).toBe('wtd'); - expect(await refusal(host.open(['wtd', `wtd.code.${code}`]))).toBe(401); + expect(await refusal(fx.host.open(['wtd', `wtd.code.${code}`]))).toBe(401); }); it('keeps a code presented by an upgrade the WebSocket handshake rejects', async () => { - const code = await host.code(); - const response = await host.http('GET', '/ws', { + const code = await fx.host.code(); + const response = await fx.host.http('GET', '/ws', { headers: [ ['Upgrade', 'websocket'], ['Connection', 'Upgrade'], ['Sec-WebSocket-Version', '12'], ['Sec-WebSocket-Key', 'dGhlIHNhbXBsZSBub25jZQ=='], ['Sec-WebSocket-Protocol', `wtd, wtd.code.${code}`], - ['Origin', host.origin], + ['Origin', fx.host.origin], ], headersOnly: true, }); expect(response.status).toBe(400); - const client = await host.open(['wtd', `wtd.code.${code}`]); + const client = await fx.host.open(['wtd', `wtd.code.${code}`]); expect(client.protocol).toBe('wtd'); }); it('accepts exactly one of two concurrent upgrades presenting the same code', async () => { - const code = await host.code(); - const results = await Promise.all([outcome(host.open(['wtd', `wtd.code.${code}`])), outcome(host.open(['wtd', `wtd.code.${code}`]))]); + const code = await fx.host.code(); + const results = await Promise.all([ + outcome(fx.host.open(['wtd', `wtd.code.${code}`])), + outcome(fx.host.open(['wtd', `wtd.code.${code}`])), + ]); expect(results.filter((r) => typeof r !== 'number')).toHaveLength(1); expect(results.filter((r) => typeof r === 'number')).toEqual([401]); }); it('refuses an unknown code with 401', async () => { - expect(await refusal(host.open(['wtd', `wtd.code.${'b'.repeat(64)}`]))).toBe(401); + expect(await refusal(fx.host.open(['wtd', `wtd.code.${'b'.repeat(64)}`]))).toBe(401); }); it('keeps at most 16 codes outstanding, dropping the oldest', async () => { const codes: string[] = []; - for (let i = 0; i < 17; i++) codes.push(await host.code()); - expect(await refusal(host.open(['wtd', `wtd.code.${codes[0] ?? ''}`]))).toBe(401); - const newest = await host.open(['wtd', `wtd.code.${codes[16] ?? ''}`]); + for (let i = 0; i < 17; i++) codes.push(await fx.host.code()); + expect(await refusal(fx.host.open(['wtd', `wtd.code.${codes[0] ?? ''}`]))).toBe(401); + const newest = await fx.host.open(['wtd', `wtd.code.${codes[16] ?? ''}`]); expect(newest.protocol).toBe('wtd'); - const second = await host.open(['wtd', `wtd.code.${codes[1] ?? ''}`]); + const second = await fx.host.open(['wtd', `wtd.code.${codes[1] ?? ''}`]); expect(second.protocol).toBe('wtd'); }); it('never records the token or codes it was offered', async () => { - const used = await host.code(); - const client = await host.open(['wtd', `wtd.code.${used}`]); + const used = await fx.host.code(); + const client = await fx.host.open(['wtd', `wtd.code.${used}`]); await client.handshake(); - const unused = await host.code(); - await outcome(host.open(['wtd', `wtd.token.${'0'.repeat(64)}`])); - await outcome(host.open(['wtd', `wtd.code.${used}`])); - await outcome(host.open(['wtd', `wtd.token.${token}`], 'http://evil.example')); - await outcome(host.open(['wtd', `wtd.token.${token}`, `wtd.code.${unused}`])); + const unused = await fx.host.code(); + await outcome(fx.host.open(['wtd', `wtd.token.${'0'.repeat(64)}`])); + await outcome(fx.host.open(['wtd', `wtd.code.${used}`])); + await outcome(fx.host.open(['wtd', `wtd.token.${token}`], 'http://evil.example')); + await outcome(fx.host.open(['wtd', `wtd.token.${token}`, `wtd.code.${unused}`])); hub.kill('SIGTERM'); await waitExit(hub); - const logs = [hub.stdout, hub.stderr, existsSync(host.hubLogPath) ? readFileSync(host.hubLogPath, 'utf8') : ''].join('\n'); + const logs = [hub.stdout, hub.stderr, existsSync(fx.host.hubLogPath) ? readFileSync(fx.host.hubLogPath, 'utf8') : ''].join('\n'); for (const secret of [token, used, unused]) expect(logs).not.toContain(secret); }); it('closes a session sending a message of MAX_FRAME + 1 bytes', async () => { - const client = await host.session(); + const client = await fx.host.session(); client.sendBinary(new Uint8Array(MAX_FRAME + 1)); await client.waitClosed(10_000); expect(client.closeCode).toBe(1009); }); it('accepts a message of MAX_FRAME bytes, then rejects it as malformed', async () => { - const client = await host.session(); + const client = await fx.host.session(); client.sendBinary(new Uint8Array(MAX_FRAME)); expect(await client.waitFor('error', () => true, { timeout: 10_000 })).toMatchObject({ code: 'bad-message' }); expect(client.closeCode).not.toBe(1009); @@ -178,29 +181,29 @@ describe('session handshake', () => { let token: string; beforeEach(async () => { - await host.startHub(); - token = host.token(); + await fx.host.startHub(); + token = fx.host.token(); }); it('sends hello with the protocol, package version and instance', async () => { - const client = await host.open(['wtd', `wtd.token.${token}`]); + const client = await fx.host.open(['wtd', `wtd.token.${token}`]); expect(await client.waitFor('hello')).toEqual({ t: 'hello', protocol: BROWSER_PROTOCOL_VERSION, version: packageVersion(), - instance: host.record()?.instance, + instance: fx.host.record()?.instance, }); }); it('sends the token right after hello to a code session', async () => { - const client = await host.open(['wtd', `wtd.code.${await host.code()}`]); + const client = await fx.host.open(['wtd', `wtd.code.${await fx.host.code()}`]); await client.waitFor('token'); expect(client.messages.slice(0, 2).map((m) => m.t)).toEqual(['hello', 'token']); expect(client.messages[1]).toEqual({ t: 'token', token }); }); it('sends no token to a token session', async () => { - const client = await host.open(['wtd', `wtd.token.${token}`]); + const client = await fx.host.open(['wtd', `wtd.token.${token}`]); await client.waitFor('hello'); await client.expectNone('token', () => true, 300); const from = client.mark(); @@ -210,7 +213,7 @@ describe('session handshake', () => { }); it('sends hosts after the browser’s hello', async () => { - const client = await host.open(['wtd', `wtd.token.${token}`]); + const client = await fx.host.open(['wtd', `wtd.token.${token}`]); await client.waitFor('hello'); await client.expectNone('hosts', () => true, 300); const { hosts } = await client.handshake(); @@ -218,7 +221,7 @@ describe('session handshake', () => { }); it('sends presets and then hosts after the browser’s hello', async () => { - const client = await host.open(['wtd', `wtd.token.${token}`]); + const client = await fx.host.open(['wtd', `wtd.token.${token}`]); await client.waitFor('hello'); await client.expectNone('presets', () => true, 300); const from = client.mark(); @@ -228,8 +231,8 @@ describe('session handshake', () => { }); it('sends the default presets and no repos without a configuration file', async () => { - rmSync(host.configPath); - const client = await host.session(); + rmSync(fx.host.configPath); + const client = await fx.host.session(); expect(presetsOf(client)).toEqual([ [ { name: 'shell', command: null }, @@ -238,12 +241,12 @@ describe('session handshake', () => { ], ]); expect(client.hosts()[0]?.repos).toEqual([]); - expect(existsSync(host.configPath)).toBe(false); + expect(existsSync(fx.host.configPath)).toBe(false); }); it('sends the shell, claude and codex presets when the configuration has no presets', async () => { - host.writeConfig({ port: host.port }); - const client = await host.session(); + fx.host.writeConfig({ port: fx.host.port }); + const client = await fx.host.session(); expect(presetsOf(client)).toEqual([ [ { name: 'shell', command: null }, @@ -254,14 +257,14 @@ describe('session handshake', () => { }); it('sends exactly the configured presets in their order', async () => { - host.writeConfig({ - port: host.port, + fx.host.writeConfig({ + port: fx.host.port, presets: [ { name: 'claude', command: 'claude' }, { name: 'shell', command: null }, ], }); - const client = await host.session(); + const client = await fx.host.session(); expect(presetsOf(client)).toEqual([ [ { name: 'claude', command: 'claude' }, @@ -271,7 +274,7 @@ describe('session handshake', () => { }); it('answers a message before hello with bad-message and closes the session', async () => { - const client = await host.open(['wtd', `wtd.token.${token}`]); + const client = await fx.host.open(['wtd', `wtd.token.${token}`]); await client.waitFor('hello'); client.sendHost(0, { t: 'setVisible', termIds: [] }); expect(await client.waitFor('error')).toEqual({ t: 'error', req: null, host: null, code: 'bad-message', message: ANY_TEXT }); @@ -279,7 +282,7 @@ describe('session handshake', () => { }); it('answers a hello of another protocol with version-mismatch and closes the session', async () => { - const client = await host.open(['wtd', `wtd.token.${token}`]); + const client = await fx.host.open(['wtd', `wtd.token.${token}`]); await client.waitFor('hello'); client.sendHello(BROWSER_PROTOCOL_VERSION + 1); expect(await client.waitFor('error')).toEqual({ @@ -298,9 +301,9 @@ describe('malformed browser traffic', () => { let b: HubClient; beforeEach(async () => { - await host.startHub(); - a = await host.session(); - b = await host.session(); + await fx.host.startHub(); + a = await fx.host.session(); + b = await fx.host.session(); }); /** Asserts `a` got bad-message and closed while `b` still answers. */ diff --git a/test/support/daemon-server.ts b/test/support/daemon-server.ts index 5638cb4..910fa50 100644 --- a/test/support/daemon-server.ts +++ b/test/support/daemon-server.ts @@ -1,15 +1,20 @@ import { afterEach, beforeEach } from 'vitest'; import { DaemonHost } from './daemon-host.js'; -export let host: DaemonHost; +/** Per-test values, refilled by the `beforeEach` that `useDaemonServerHost` registers. */ +class DaemonServerFixture { + host!: DaemonHost; +} + +export const fx = new DaemonServerFixture(); /** Registers the per-test setup and cleanup. */ export const useDaemonServerHost = (): void => { beforeEach(() => { - host = DaemonHost.create(); + fx.host = DaemonHost.create(); }); afterEach(async () => { - await host.cleanup(); + await fx.host.cleanup(); }); }; diff --git a/test/support/daemon-terminals.ts b/test/support/daemon-terminals.ts index dfc666d..3dc4f1e 100644 --- a/test/support/daemon-terminals.ts +++ b/test/support/daemon-terminals.ts @@ -4,28 +4,33 @@ import type { Terminal } from '../../src/protocol/index.js'; import type { DaemonClient } from './daemon-client.js'; import { addWorktree, DaemonHost, makeRepo, sleep, type WtdProcess } from './daemon-host.js'; -export let host: DaemonHost; -export let daemon: WtdProcess; -export let repo: string; -export let wt: string; -export let a: DaemonClient; -export let b: DaemonClient; +/** Per-test values, refilled by the `beforeEach` that `useDaemonTerminalsHost` registers. */ +class DaemonTerminalsFixture { + host!: DaemonHost; + daemon!: WtdProcess; + repo!: string; + wt!: string; + a!: DaemonClient; + b!: DaemonClient; +} + +export const fx = new DaemonTerminalsFixture(); /** Registers the per-test setup and cleanup. */ export const useDaemonTerminalsHost = (): void => { beforeEach(async () => { - host = DaemonHost.create(); - repo = makeRepo(join(host.dir, 'repo')); - wt = addWorktree(repo, join(host.dir, 'wt'), 'wt'); - daemon = await host.start(); - a = await host.client(); - b = await host.client(); - await a.watch(repo); - await b.watch(repo); + fx.host = DaemonHost.create(); + fx.repo = makeRepo(join(fx.host.dir, 'repo')); + fx.wt = addWorktree(fx.repo, join(fx.host.dir, 'wt'), 'wt'); + fx.daemon = await fx.host.start(); + fx.a = await fx.host.client(); + fx.b = await fx.host.client(); + await fx.a.watch(fx.repo); + await fx.b.watch(fx.repo); }); afterEach(async () => { - await host.cleanup(); + await fx.host.cleanup(); }); }; @@ -40,7 +45,7 @@ export const MiB = 1024 * KiB; export const started = async ( client: DaemonClient, command: string | null, - { worktree = wt, cols = 80, rows = 24 }: { worktree?: string; cols?: number; rows?: number } = {}, + { worktree = fx.wt, cols = 80, rows = 24 }: { worktree?: string; cols?: number; rows?: number } = {}, ): Promise => { const term = await client.create(worktree, { command: command === null ? null : `read -r _; ${command}`, cols, rows }); await client.attach(term.termId); @@ -56,11 +61,11 @@ export const started = async ( /** Restarts the daemon with extra environment, re-watching from fresh clients. */ export const restart = async (env: Record = {}): Promise => { - a.send({ t: 'shutdown' }); - await daemon.exited; - daemon = await host.start(env); - a = await host.client(); - b = await host.client(); - await a.watch(repo); - await b.watch(repo); + fx.a.send({ t: 'shutdown' }); + await fx.daemon.exited; + fx.daemon = await fx.host.start(env); + fx.a = await fx.host.client(); + fx.b = await fx.host.client(); + await fx.a.watch(fx.repo); + await fx.b.watch(fx.repo); }; diff --git a/test/support/hub-router.ts b/test/support/hub-router.ts index 02f2b8a..f8b5b4d 100644 --- a/test/support/hub-router.ts +++ b/test/support/hub-router.ts @@ -8,30 +8,35 @@ import type { HubClient } from './hub-client.js'; import { HubHost } from './hub-host.js'; import { REPO_ROOT } from './exec.js'; -export let host: HubHost; -export let hub: WtdProcess; -export let repo: string; -export let wt: string; -export let ssh: FakeSsh; +/** Per-test values, refilled by the `beforeEach` that `useHubRouterHost` registers. */ +class HubRouterFixture { + host!: HubHost; + hub!: WtdProcess; + repo!: string; + wt!: string; + ssh!: FakeSsh; +} + +export const fx = new HubRouterFixture(); /** Registers the per-test setup and cleanup. */ export const useHubRouterHost = (): void => { beforeEach(async () => { - host = await HubHost.createHub(); - ssh = new FakeSsh(join(host.dir, 'ssh')); - host.sshProgram = ssh.program; - repo = makeRepo(join(host.dir, 'repo')); - wt = addWorktree(repo, join(host.dir, 'wt'), 'wt'); - host.writeRepos([repo]); - hub = await host.startHub(); + fx.host = await HubHost.createHub(); + fx.ssh = new FakeSsh(join(fx.host.dir, 'ssh')); + fx.host.sshProgram = fx.ssh.program; + fx.repo = makeRepo(join(fx.host.dir, 'repo')); + fx.wt = addWorktree(fx.repo, join(fx.host.dir, 'wt'), 'wt'); + fx.host.writeRepos([fx.repo]); + fx.hub = await fx.host.startHub(); }); afterEach(async () => { // Stop the hub first, so it starts no SSH runs while the fake remotes go away. - for (const pid of host.hubPids()) process.kill(pid, 'SIGKILL'); - await waitUntil(() => host.hubPids().length === 0, 'hubs to exit').catch(() => undefined); - await ssh.cleanup(); - await host.cleanup(); + for (const pid of fx.host.hubPids()) process.kill(pid, 'SIGKILL'); + await waitUntil(() => fx.host.hubPids().length === 0, 'hubs to exit').catch(() => undefined); + await fx.ssh.cleanup(); + await fx.host.cleanup(); }); }; @@ -58,7 +63,7 @@ export const instanceOf = async (client: HubClient, from = 0): Promise = }; /** Creates a terminal, attaches, and for a shell waits until it ran a command. */ -export const started = async (client: HubClient, command: string | null, worktree = wt): Promise => { +export const started = async (client: HubClient, command: string | null, worktree = fx.wt): Promise => { const term = await client.create(0, worktree, { command }); await client.attach(0, term.termId); if (command === null) { @@ -71,9 +76,9 @@ export const started = async (client: HubClient, command: string | null, worktre /** A connected session watching the repo. */ export const watching = async (): Promise => { - const client = await host.session(); + const client = await fx.host.session(); await connected(client); - await client.watch(0, repo); + await client.watch(0, fx.repo); return client; }; @@ -89,17 +94,19 @@ export interface RemoteHostConfig { /** Writes `config.json` with the local repo, `hosts` and any other keys. */ export const configure = (hosts: readonly RemoteHostConfig[], extra: Record = {}): void => { - host.writeConfig({ port: host.port, repos: [repo], hosts, ...extra }); + fx.host.writeConfig({ port: fx.host.port, repos: [fx.repo], hosts, ...extra }); }; /** A remote whose PATH offers this runner's Node, so the hub's installer finds one there. */ -export const nodeRemote = (alias: string): FakeRemote => - ssh.addHost(alias, { path: `${currentNodeDir(join(ssh.dir, `node-${alias}`))}:${ssh.pathWithoutNode}` }); +export const nodeRemote = (alias: string): FakeRemote => { + const nodeDir = currentNodeDir(join(fx.ssh.dir, 'node-' + alias)); + return fx.ssh.addHost(alias, { path: `${nodeDir}:${fx.ssh.pathWithoutNode}` }); +}; /** The remote `box`, installed, holding one repo, configured as host 1. */ export const installedBox = async (): Promise<{ remote: FakeRemote; remoteRepo: string }> => { const remote = nodeRemote('box'); - await installRemote(host, ssh, 'box'); + await installRemote(fx.host, fx.ssh, 'box'); const remoteRepo = makeRepo(join(remote.home, 'src', 'app')); configure([{ name: 'box', ssh: 'box', repos: [remoteRepo] }]); return { remote, remoteRepo }; diff --git a/test/support/hub-server.ts b/test/support/hub-server.ts index b76874b..af0e6d8 100644 --- a/test/support/hub-server.ts +++ b/test/support/hub-server.ts @@ -6,16 +6,21 @@ import { UpgradeRefused, type HubClient } from './hub-client.js'; import { HubHost, type RawResponse } from './hub-host.js'; import { REPO_ROOT } from './exec.js'; -export let host: HubHost; +/** Per-test values, refilled by the `beforeEach` that `useHubServerHost` registers. */ +class HubServerFixture { + host!: HubHost; +} + +export const fx = new HubServerFixture(); /** Registers the per-test setup and cleanup. */ export const useHubServerHost = (): void => { beforeEach(async () => { - host = await HubHost.createHub(); + fx.host = await HubHost.createHub(); }); afterEach(async () => { - await host.cleanup(); + await fx.host.cleanup(); }); }; From a1752478ebb991f0eea723dca4048818c32a6f8b Mon Sep 17 00:00:00 2001 From: Egor Kraev Date: Sun, 18 Oct 2026 19:07:31 +0200 Subject: [PATCH 5/6] Tick the DEV-2108 CI and measurement tasks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three green CI runs on e7d9e19 (1m56s–2m11s), 4 e2e workers per shard; timings and shard file lists are in the PR description. --- .../tasks.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/openspec/changes/dev-2108-worktree-term-cut-ci-time-from-65-min-to-3-min-with-parallel/tasks.md b/openspec/changes/dev-2108-worktree-term-cut-ci-time-from-65-min-to-3-min-with-parallel/tasks.md index c698196..5ea783f 100644 --- a/openspec/changes/dev-2108-worktree-term-cut-ci-time-from-65-min-to-3-min-with-parallel/tasks.md +++ b/openspec/changes/dev-2108-worktree-term-cut-ci-time-from-65-min-to-3-min-with-parallel/tasks.md @@ -13,13 +13,13 @@ ## 3. CI -- [ ] 3.1 Add `.github/actions/setup/action.yml` with input `arch-tools` (design D2); verify by the workflow run in 3.3 -- [ ] 3.2 Rewrite `.github/workflows/ci.yml` as jobs `architecture`, `vitest` (shard 1/2, 2/2), `e2e` (shard 1/2, 2/2) per design D1/D3, keeping the hardware-WebGL comment; verify with `actionlint` if available, else by the run in 3.3 -- [ ] 3.3 `playwright.config.ts`: CI workers = `min(8, availableParallelism())` (design D5); verify locally that `CI=1 pnpm exec playwright test --list` and an unset `CI` both load, and on CI that each e2e shard logs 4 workers +- [x] 3.1 Add `.github/actions/setup/action.yml` with input `arch-tools` (design D2); verify by the workflow run in 3.3 +- [x] 3.2 Rewrite `.github/workflows/ci.yml` as jobs `architecture`, `vitest` (shard 1/2, 2/2), `e2e` (shard 1/2, 2/2) per design D1/D3, keeping the hardware-WebGL comment; verify with `actionlint` if available, else by the run in 3.3 +- [x] 3.3 `playwright.config.ts`: CI workers = `min(8, availableParallelism())` (design D5); verify locally that `CI=1 pnpm exec playwright test --list` and an unset `CI` both load, and on CI that each e2e shard logs 4 workers - [x] 3.4 Update README if it describes the single CI job; verify by grep for `pnpm lint` / CI wording ## 4. Measure and final gates - [x] 4.1 Local: unsharded `pnpm test:ci` passes, and `pnpm test` (incl. perf) passes; `openspec validate dev-2108-worktree-term-cut-ci-time-from-65-min-to-3-min-with-parallel --strict` passes -- [ ] 4.2 After the PR is open: 3 consecutive full CI runs (`gh run rerun`) all green; record per-job wall times, each vitest shard's file list, and the total in the PR description -- [ ] 4.3 Check targets: total ≤ ~3 min, each e2e shard ≤ the slower vitest shard, no failures; if vitest shards are imbalanced enough to miss the target, add the duration-balancing sequencer (design D4) and re-measure; if e2e flakes at 4 workers, drop to 3 (design D5) +- [x] 4.2 After the PR is open: 3 consecutive full CI runs (`gh run rerun`) all green; record per-job wall times, each vitest shard's file list, and the total in the PR description +- [x] 4.3 Check targets: total ≤ ~3 min, each e2e shard ≤ the slower vitest shard, no failures; if vitest shards are imbalanced enough to miss the target, add the duration-balancing sequencer (design D4) and re-measure; if e2e flakes at 4 workers, drop to 3 (design D5) From fe40a29b775e8063eb4de4f6497991c0c560ef4f Mon Sep 17 00:00:00 2001 From: Egor Kraev Date: Sun, 18 Oct 2026 19:11:34 +0200 Subject: [PATCH 6/6] Archive the DEV-2108 OpenSpec change --- .../.openspec.yaml | 0 .../design.md | 0 .../proposal.md | 0 .../tasks.md | 0 4 files changed, 0 insertions(+), 0 deletions(-) rename openspec/changes/{dev-2108-worktree-term-cut-ci-time-from-65-min-to-3-min-with-parallel => archive/2026-10-18-dev-2108-worktree-term-cut-ci-time-from-65-min-to-3-min-with-parallel}/.openspec.yaml (100%) rename openspec/changes/{dev-2108-worktree-term-cut-ci-time-from-65-min-to-3-min-with-parallel => archive/2026-10-18-dev-2108-worktree-term-cut-ci-time-from-65-min-to-3-min-with-parallel}/design.md (100%) rename openspec/changes/{dev-2108-worktree-term-cut-ci-time-from-65-min-to-3-min-with-parallel => archive/2026-10-18-dev-2108-worktree-term-cut-ci-time-from-65-min-to-3-min-with-parallel}/proposal.md (100%) rename openspec/changes/{dev-2108-worktree-term-cut-ci-time-from-65-min-to-3-min-with-parallel => archive/2026-10-18-dev-2108-worktree-term-cut-ci-time-from-65-min-to-3-min-with-parallel}/tasks.md (100%) diff --git a/openspec/changes/dev-2108-worktree-term-cut-ci-time-from-65-min-to-3-min-with-parallel/.openspec.yaml b/openspec/changes/archive/2026-10-18-dev-2108-worktree-term-cut-ci-time-from-65-min-to-3-min-with-parallel/.openspec.yaml similarity index 100% rename from openspec/changes/dev-2108-worktree-term-cut-ci-time-from-65-min-to-3-min-with-parallel/.openspec.yaml rename to openspec/changes/archive/2026-10-18-dev-2108-worktree-term-cut-ci-time-from-65-min-to-3-min-with-parallel/.openspec.yaml diff --git a/openspec/changes/dev-2108-worktree-term-cut-ci-time-from-65-min-to-3-min-with-parallel/design.md b/openspec/changes/archive/2026-10-18-dev-2108-worktree-term-cut-ci-time-from-65-min-to-3-min-with-parallel/design.md similarity index 100% rename from openspec/changes/dev-2108-worktree-term-cut-ci-time-from-65-min-to-3-min-with-parallel/design.md rename to openspec/changes/archive/2026-10-18-dev-2108-worktree-term-cut-ci-time-from-65-min-to-3-min-with-parallel/design.md diff --git a/openspec/changes/dev-2108-worktree-term-cut-ci-time-from-65-min-to-3-min-with-parallel/proposal.md b/openspec/changes/archive/2026-10-18-dev-2108-worktree-term-cut-ci-time-from-65-min-to-3-min-with-parallel/proposal.md similarity index 100% rename from openspec/changes/dev-2108-worktree-term-cut-ci-time-from-65-min-to-3-min-with-parallel/proposal.md rename to openspec/changes/archive/2026-10-18-dev-2108-worktree-term-cut-ci-time-from-65-min-to-3-min-with-parallel/proposal.md diff --git a/openspec/changes/dev-2108-worktree-term-cut-ci-time-from-65-min-to-3-min-with-parallel/tasks.md b/openspec/changes/archive/2026-10-18-dev-2108-worktree-term-cut-ci-time-from-65-min-to-3-min-with-parallel/tasks.md similarity index 100% rename from openspec/changes/dev-2108-worktree-term-cut-ci-time-from-65-min-to-3-min-with-parallel/tasks.md rename to openspec/changes/archive/2026-10-18-dev-2108-worktree-term-cut-ci-time-from-65-min-to-3-min-with-parallel/tasks.md