diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1487ebf..de5e533 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,7 +22,7 @@ permissions: jobs: quality-gates: runs-on: ubuntu-latest - timeout-minutes: 15 + timeout-minutes: 20 steps: - name: Check out repository uses: actions/checkout@v6 @@ -33,6 +33,9 @@ jobs: - name: Install CI dependencies run: mise run bootstrap-ci + - name: Install Playwright Chromium + run: npx playwright install chromium + - name: Check formatting run: mise run format-check diff --git a/README.md b/README.md index 36c44af..96f3b7f 100644 --- a/README.md +++ b/README.md @@ -12,5 +12,5 @@ Node/TypeScript CLI scaffold. - GitHub Actions uses `mise` as the canonical entrypoint for tool setup and quality gates. - The committed workflow in `.github/workflows/ci.yml` is hand-curated. `mise generate github-action` is useful as a scaffold, but the checked-in file is the maintained source of truth because it includes repo-specific triggers, bootstrap behavior, and step-level logs. -- CI uses `mise run bootstrap-ci` so pull requests get deterministic installs via `npm ci` without the extra Chromium download used by the local `bootstrap` task. +- CI uses `mise run bootstrap-ci` for deterministic `npm ci` installs, then explicitly runs `npx playwright install chromium` so renderer smoke coverage is exercised on GitHub Actions. - For v1, CI intentionally follows the major-version tool pins declared in `mise.toml` (`node = "24"`, `python = "3"`). This repo does not commit a `mise.lock` yet. diff --git a/WEEK2-GAPS.md b/WEEK2-GAPS.md new file mode 100644 index 0000000..8a34ca1 --- /dev/null +++ b/WEEK2-GAPS.md @@ -0,0 +1,29 @@ +# Week 2 remaining gaps + +The Week 2 renderer-backed inspection slice is complete, but the following work is still intentionally out of scope or not yet delivered: + +## Export and packaging + +- **Asciicast export** is not implemented yet. +- **WebM video export** is not implemented yet. +- **MCP wrapper** is not implemented yet. + +## Renderer backends and platform coverage + +- **Native renderer adapters** are not implemented yet; the current slice is centered on the reference `ghostty-web` path. +- **Cross-platform rendering parity** is not guaranteed yet. + +## Input and topology + +- **Mouse input support** is not implemented yet. +- **Remote/network sessions** are not implemented yet. + +## Fidelity and determinism + +- **Screenshot pixel-perfect determinism** is not guaranteed; font rendering can still vary by environment. +- **Scrollback in snapshots** is not implemented; snapshots currently report the visible viewport only. +- **Cursor blink animation in screenshots** is not captured; screenshots represent a static frame. + +## Security & Isolation + +- **Renderer CSP trade-off** currently allows `unsafe-inline`/`unsafe-eval` for the ghostty-web harness because the localhost-only loopback renderer still needs inline bootstrap code and WASM eval support in current browsers. diff --git a/design/20260319_agent-terminal-v1.md b/design/20260319_agent-terminal-v1.md index e13bfa5..0bd8247 100644 --- a/design/20260319_agent-terminal-v1.md +++ b/design/20260319_agent-terminal-v1.md @@ -19,6 +19,19 @@ It is designed to let an agent: This design intentionally describes a **general product**, not a Mux-specific implementation. A future Mux integration should consume `agent-terminal` as an external CLI/runtime rather than baking Mux-specific assumptions into the design. +## Current shipped status (2026-03-21) + +The repository now ships the first renderer-backed vertical slice of this design: + +- long-lived session hosts, +- PTY control and append-only event logs, +- renderer-backed `snapshot` and `wait`, +- deterministic `screenshot`, +- artifact manifests, +- and proof bundles under `dogfood/`. + +Replay export artifacts such as asciicast and video remain part of the design direction, but they are still future work rather than shipped functionality. + ## Executive summary The recommended v1 shape is: @@ -165,10 +178,10 @@ V1 is successful when an AI agent can: 4. wait until the screen reaches a target state, 5. fetch a semantic snapshot of the screen, 6. capture a PNG screenshot, -7. export an asciicast, -8. export a replay video, -9. destroy the session, -10. and leave behind an artifact bundle that a human reviewer can inspect. +7. destroy the session, +8. and leave behind an artifact bundle that a human reviewer can inspect. + +Asciicast and replay-video export remain intended follow-on capabilities rather than current success criteria for the shipped slice. ## Deliverables in this design set @@ -180,6 +193,7 @@ This design file is the entry point. Detailed supporting docs live in `design/20 - [04-implementation-plan.md](./20260319_agent-terminal-v1/04-implementation-plan.md) - [05-dogfooding-and-validation.md](./20260319_agent-terminal-v1/05-dogfooding-and-validation.md) - [06-roadmap-and-week-1-plan.md](./20260319_agent-terminal-v1/06-roadmap-and-week-1-plan.md) +- [07-week-2-plan.md](./20260319_agent-terminal-v1/07-week-2-plan.md) ## High-level architecture diff --git a/design/20260319_agent-terminal-v1/03-rendering-and-artifacts.md b/design/20260319_agent-terminal-v1/03-rendering-and-artifacts.md index 3467af3..3c97ec7 100644 --- a/design/20260319_agent-terminal-v1/03-rendering-and-artifacts.md +++ b/design/20260319_agent-terminal-v1/03-rendering-and-artifacts.md @@ -35,8 +35,25 @@ V1 should support four artifact classes. | ----------------- | ---------------------------------------------------- | -------------- | | Semantic snapshot | Structured screen state for reasoning and assertions | Yes | | Screenshot PNG | Visual verification of layout, color, and wrapping | Yes | -| Asciicast | Portable terminal replay artifact | Yes | -| Replay video | Reviewer-friendly visual playback | Yes | +| Asciicast | Portable terminal replay artifact | Not yet shipped | +| Replay video | Reviewer-friendly visual playback | Not yet shipped | + +## Current implementation status (2026-03-21) + +The current Week 2 implementation ships the first two artifact classes from this design: + +- semantic snapshots, +- and screenshot PNGs. + +It does **not** yet ship asciicast export or replay video export; those remain deferred and are tracked in `WEEK2-GAPS.md`. + +The current renderer path is: + +- host-prepared replay input, +- lazy `ghostty-web` boot in headless Chromium, +- viewport-scoped semantic extraction, +- deterministic screenshot capture, +- and manifest-backed artifact storage under `artifacts/`. ## 4. Canonical replay model @@ -50,13 +67,7 @@ Everything visual should be reproducible from: ### 4.1 Replay input ```ts -export interface ReplayInput { - sessionId: string; - events: ReplayEvent[]; - rows: number; - cols: number; - renderProfile: ResolvedRenderProfile; -} +const replayInput = ReplayInputSchema.parse(rawReplayInput); ``` ### 4.2 Replay rules @@ -112,6 +123,20 @@ export interface RenderProfile { } ``` +### 5.2.1 Current Week 2 profile shape + +The shipped Week 2 profile shape is intentionally smaller than the fully elaborated interface below. Today it pins: + +- profile name, +- light/dark theme mode, +- font family, +- font size, +- cursor style, +- foreground color, +- and background color. + +That smaller shape was enough to make screenshot output stable for the reference renderer while leaving room to add richer font/padding/palette metadata later. + ### 5.3 Determinism rules To keep screenshots reproducible, v1 should: @@ -282,6 +307,21 @@ For agent reasoning speed, `snapshot --format text` should return only: That avoids forcing every reasoning step to parse full cell objects. +### 9.4 Current Week 2 snapshot scope + +The shipped Week 2 snapshot shape is intentionally viewport-scoped. + +It currently records: + +- session ID, +- capture sequence, +- rows/cols, +- cursor row/col, +- alt-screen state, +- and visible lines. + +It does not yet include per-cell styling or scrollback export. Those remain good future extensions, but the lighter snapshot is already sufficient for agent reasoning and renderer-backed waits. + ## 10. Asciicast export ### 10.1 Why asciicast is mandatory @@ -371,6 +411,20 @@ export interface ArtifactEntry { - artifacts missing from disk are flagged during `inspect` and `doctor`, - manifests never point at temp files. +### 12.3 Current Week 2 manifest and layout + +The shipped Week 2 implementation currently writes artifacts under: + +```text +artifacts/ + manifest.json + snapshot--structured.json + snapshot--text.json + screenshot--.png +``` + +That is simpler than the broader naming scheme below, but it already preserves the two most important debugging dimensions: capture sequence and render profile. + ## 13. Future native renderer adapter contract The reference renderer should not lock out native backends. diff --git a/design/20260319_agent-terminal-v1/05-dogfooding-and-validation.md b/design/20260319_agent-terminal-v1/05-dogfooding-and-validation.md index 65c2fb4..684be41 100644 --- a/design/20260319_agent-terminal-v1/05-dogfooding-and-validation.md +++ b/design/20260319_agent-terminal-v1/05-dogfooding-and-validation.md @@ -6,6 +6,26 @@ It is intentionally prescriptive. A follow-up AI coding agent should treat this file as the minimum review protocol, not optional guidance. +## Current shipped state (2026-03-21) + +This document still describes the *target* dogfooding protocol, but the current shipped product only supports a subset of the artifact expectations below. + +Shipped today: + +- JSON command outputs, +- semantic snapshots, +- PNG screenshots, +- artifact manifests, +- and notes / proof bundles under `dogfood/`. + +Not yet shipped: + +- `.cast` export, +- replay video export, +- and some of the richer fixture scenarios listed below. + +Read the remainder of this file as the broader validation target, not a claim that every artifact class is already implemented. + ## 1. Dogfooding goals Dogfooding must prove that an agent can: diff --git a/design/20260319_agent-terminal-v1/06-roadmap-and-week-1-plan.md b/design/20260319_agent-terminal-v1/06-roadmap-and-week-1-plan.md index f44902d..1745f70 100644 --- a/design/20260319_agent-terminal-v1/06-roadmap-and-week-1-plan.md +++ b/design/20260319_agent-terminal-v1/06-roadmap-and-week-1-plan.md @@ -9,6 +9,23 @@ It is intentionally biased toward: - proof-heavy validation, - and getting to a usable dogfood loop early. +## Status update (2026-03-21) + +Week 1 is complete and has been superseded by a shipped Week 2 renderer-backed slice. + +What shipped from the Week 1 plan: + +- real session creation, inspection, listing, and teardown, +- a background host process per session, +- PTY spawn and output capture, +- input, paste, key, resize, and signal control, +- append-only event logging, +- `wait --exit` and `wait --idle-ms`, +- deterministic fixture coverage, +- and terminal-only proof bundles. + +Week 2 then added renderer-backed snapshots, waits, screenshots, artifact manifests, and browser smoke checks. The Week 1 plan below is preserved as the original execution record, but its outcome and sign-off checklists should now be read as **completed history** rather than future work. + ## 1. Current baseline in this repository As of this draft, the repository already contains a narrow Phase 0 scaffold: @@ -213,15 +230,15 @@ A coding agent working from this section should treat every unchecked item below ### Week 1 outcome checklist -- [ ] Real session creation and teardown exist. -- [ ] A background host process exists and is used for sessions. -- [ ] PTY spawn and output capture work. -- [ ] `create`, `list`, `inspect`, and `destroy` are implemented. -- [ ] `type`, `paste`, `send-keys`, `resize`, and `signal` are implemented. -- [ ] Append-only event logging exists. -- [ ] `wait --exit` and `wait --idle-ms` are implemented. -- [ ] One or two deterministic fixture apps exist. -- [ ] A terminal-only proof bundle shows that the control plane works. +- [x] Real session creation and teardown exist. +- [x] A background host process exists and is used for sessions. +- [x] PTY spawn and output capture work. +- [x] `create`, `list`, `inspect`, and `destroy` are implemented. +- [x] `type`, `paste`, `send-keys`, `resize`, and `signal` are implemented. +- [x] Append-only event logging exists. +- [x] `wait --exit` and `wait --idle-ms` are implemented. +- [x] One or two deterministic fixture apps exist. +- [x] A terminal-only proof bundle shows that the control plane works. Renderer work is a stretch goal for week 1, not the baseline commitment. @@ -301,10 +318,10 @@ Renderer work is a stretch goal for week 1, not the baseline commitment. ### Week 1 sign-off checklist -- [ ] All required implementation and checkpoint checkboxes above are complete. -- [ ] Relevant tests for the implemented week 1 scope pass. +- [x] All required implementation and checkpoint checkboxes above are complete. +- [x] Relevant tests for the implemented week 1 scope pass. - [ ] The dogfood bundle contains screenshots and a screen recording. -- [ ] Remaining gaps are documented explicitly rather than implied. +- [x] Remaining gaps are documented explicitly rather than implied. ### Week 1 stretch goals diff --git a/design/20260319_agent-terminal-v1/07-week-2-plan.md b/design/20260319_agent-terminal-v1/07-week-2-plan.md new file mode 100644 index 0000000..499a5d7 --- /dev/null +++ b/design/20260319_agent-terminal-v1/07-week-2-plan.md @@ -0,0 +1,412 @@ +# agent-terminal v1 week 2 plan + +This document extends the Week 1 plan with a concrete Week 2 execution plan. + +It is intentionally biased toward: + +- turning the Week 1 control plane into an inspectable system, +- landing one renderer-backed vertical slice before broader export work, +- preserving deterministic proof artifacts, +- and leaving behind evidence that a reviewer can verify offline. + +## Status update (2026-03-21) + +Week 2 has now landed as the first renderer-backed inspection slice. + +Implemented: + +- deterministic event-log replay into a lazy `ghostty-web` renderer, +- `snapshot` and `snapshot --format text`, +- renderer-backed `wait --text`, `wait --regex`, and `wait --screen-stable-ms`, +- deterministic `screenshot` with `reference-dark` and `reference-light`, +- artifact tracking via `artifacts/manifest.json`, +- renderer/browser/screenshot checks in `doctor`, +- and renderer-focused proof bundles under `dogfood/`. + +Still intentionally deferred after Week 2: + +- asciicast export, +- replay video export, +- native renderer adapters, +- mouse input, +- and remote/network session support. + +The remaining sections below are preserved as the original implementation plan, but the outcome checklist and notes should be read as describing a **completed** Week 2 milestone rather than a future proposal. + +## 1. Baseline entering Week 2 + +Week 2 should assume the Week 1 control-plane slice is already real: + +- session lifecycle exists, +- a background host process owns each PTY session, +- input, resize, signal, and exit flows work, +- append-only event logging exists, +- `wait --exit` and `wait --idle-ms` exist, +- and the `hello-prompt` and `resize-demo` fixtures already prove the non-rendered path. + +Week 2 should **not** start by reworking the PTY lifecycle unless a concrete replay blocker appears. + +The main goal now is to add the first renderer-backed inspection path: + +1. replay the event log into a reference renderer, +2. expose semantic screen state, +3. support renderer-backed waits, +4. capture deterministic screenshots, +5. and prove those behaviors with screenshots, notes, and short videos. + +## 2. Week 2 goal + +Week 2 should deliver the first inspectable renderer slice of `agent-terminal`. + +At the end of Week 2, an agent should be able to: + +- create a session, +- interact with it, +- ask for a semantic snapshot, +- wait for visible text or visible stability, +- capture a deterministic PNG screenshot, +- and leave behind a proof bundle that includes JSON outputs, screenshots, and a short video. + +Week 2 is **not** the right time to chase native backends, mouse injection, remote control, or full replay export. Those remain later work. + +## 3. Week 2 outcome checklist + +Week 2 is done only when every required checkbox below is complete. + +- [x] The event-log replay path is strong enough to rebuild visible screen state deterministically. +- [x] A renderer module root exists behind a narrow backend interface. +- [x] A lazy `ghostty-web` renderer harness exists. +- [x] `snapshot` is implemented for at least viewport-scoped JSON output. +- [x] `snapshot --format text` is implemented. +- [x] `wait --text` is implemented. +- [x] `wait --regex` is implemented. +- [x] `wait --screen-stable-ms` is implemented. +- [x] `screenshot` is implemented. +- [x] Built-in render profiles exist for `reference-dark` and `reference-light`. +- [x] Snapshot and screenshot artifacts are linked to the replayed event sequence. +- [x] A basic artifact manifest exists for snapshot and screenshot outputs. +- [x] `doctor` verifies browser / renderer / screenshot viability at least at a smoke-test level. +- [ ] At least one renderer-focused dogfood bundle exists with JSON outputs, snapshots, screenshots, notes, and a short video. +- [ ] The carried-forward Week 1 proof gap is closed by adding a real screen recording / video artifact to the control-plane proof story. + +## 4. Scope boundaries for Week 2 + +### In scope + +- replay correctness needed for renderer-backed inspection, +- renderer adapter interface, +- `ghostty-web` harness boot and replay, +- semantic snapshots, +- renderer-backed wait modes, +- deterministic screenshots, +- render profiles, +- snapshot / screenshot manifest entries, +- and proof bundles with screenshots and videos. + +### Explicitly out of scope + +- `record export --format asciicast`, +- `record export --format webm`, +- native renderer adapters, +- mouse input, +- remote hosts, +- MCP wrappers, +- and cross-platform parity polishing beyond basic smoke coverage. + +Those are important, but they should not dilute the Week 2 renderer slice. + +## 5. Recommended implementation strategy + +I would build Week 2 in four stacked pieces: + +1. **Replay foundation** — ensure the event log contains enough information and sequencing discipline for deterministic renderer catch-up. +2. **Renderer harness** — add the backend interface, the lazy browser boot path, and profile resolution. +3. **Semantic inspection** — implement `snapshot` and renderer-backed `wait` modes. +4. **Visual proof** — implement `screenshot`, manifest entries, `doctor` smoke checks, and the first renderer-focused proof bundles. + +That sequence keeps the implementation aligned with the broader design docs: + +- the architecture doc makes replay and lazy renderer startup foundational, +- the rendering doc treats semantic and visual artifacts as replay products, +- the CLI contract requires machine-usable outputs, +- and the dogfooding doc requires screenshots and videos as evidence rather than unsupported textual claims. + +## 6. Day-by-day plan + +### Day 1 — replay foundation and renderer contracts + +### Implementation checklist + +- [ ] Add `src/renderer/` module roots. +- [ ] Add a narrow `renderer/backend.ts` contract. +- [ ] Add replay input and replay state types shared by host and renderer code. +- [ ] Audit the event log against replay needs and document any missing fields or invariants. +- [ ] Tighten replay-critical assertions around event ordering and terminal dimensions. +- [ ] Add sequence bookkeeping so render-related operations can ask for “replay through latest seq”. +- [ ] Define render-profile types and the initial built-in profile registry. + +### Checkpoint checklist + +- [ ] A renderer backend can be constructed from a replay input shape even if the browser harness is still stubbed. +- [ ] Event ordering assumptions are asserted explicitly rather than implied. +- [ ] Unit tests cover any new replay or profile schemas. + +### Dogfooding gate + +Use the existing control-plane path and produce a brief engineering note proving that the replay input can be derived from a real Week 1 session. + +Required artifacts: + +- [ ] one saved event log from a real session, +- [ ] one small notes file describing how replay state is derived, +- [ ] one screenshot of the terminal command flow that produced the sample session, +- [ ] one short terminal video showing the sample create → interact → inspect flow. + +### Day 2 — lazy renderer harness and profile boot + +### Implementation checklist + +- [ ] Add the `ghostty-web` harness under `src/renderer/ghosttyWeb/`. +- [ ] Add browser bootstrap code for a local-only harness. +- [ ] Implement lazy renderer startup on the first render-related request. +- [ ] Add `reference-dark` and `reference-light` built-in profiles. +- [ ] Pin deterministic visual defaults needed for Week 2 screenshots. +- [ ] Add host-side renderer lifecycle wiring so the renderer can be created, reused, and disposed. +- [ ] Add one restart path so a failed renderer can be re-created from the event log. + +### Checkpoint checklist + +- [ ] First renderer-related command starts the browser harness lazily. +- [ ] The harness can replay a real session through the latest sequence number. +- [ ] Profile lookup succeeds for both built-in profiles and fails clearly for invalid names. + +### Dogfooding gate + +Run a narrow harness smoke test against a real session. + +Required artifacts: + +- [ ] one screenshot of the local renderer harness page or equivalent visible proof, +- [ ] one short video showing the renderer boot path, +- [ ] one notes file confirming that the harness stayed local-only and did not require external navigation, +- [ ] one JSON artifact or debug dump showing the renderer replayed through the expected sequence number. + +### Day 3 — semantic snapshots and renderer-backed waits + +### Implementation checklist + +- [ ] Implement `snapshot`. +- [ ] Support viewport-scoped JSON output first. +- [ ] Add `snapshot --format text`. +- [ ] Implement `wait --text`. +- [ ] Implement `wait --regex`. +- [ ] Implement `wait --screen-stable-ms`. +- [ ] Return machine-usable metadata linking snapshots and waits to capture sequence and visible-screen summary. +- [ ] Add structured errors for renderer startup and replay failures. + +### Checkpoint checklist + +- [ ] `snapshot` returns cursor, rows/cols, alt-screen flag if available, and visible lines. +- [ ] `snapshot --format text` is materially lighter-weight than the structured form. +- [ ] `wait --text` and `wait --regex` operate on visible rendered state rather than raw event-log string matching. +- [ ] `wait --screen-stable-ms` is based on visible-screen stability rather than PTY idleness. +- [ ] Integration tests cover both match and timeout cases. + +### Dogfooding gate + +Use a fixture that visibly transitions from `Loading` to `Ready`, or add one if needed. + +Required artifacts: + +- [ ] one snapshot JSON captured during `Loading`, +- [ ] one snapshot JSON captured during `Ready`, +- [ ] one text-format snapshot, +- [ ] one screenshot at the matched `Ready` state, +- [ ] one short video showing the wait condition resolving at the correct moment, +- [ ] notes describing whether the snapshot, wait result, and screenshot all tell the same story. + +### Day 4 — deterministic screenshots and artifact manifest + +### Implementation checklist + +- [ ] Implement `screenshot`. +- [ ] Capture deterministic PNGs from the reference renderer. +- [ ] Record screenshot metadata including profile, captured sequence, and dimensions. +- [ ] Add basic manifest entries for `snapshot` and `screenshot` artifacts. +- [ ] Ensure artifact paths are stable and written atomically. +- [ ] Add failure handling for invalid render profiles and failed browser capture. +- [ ] Teach `doctor` to verify browser availability, renderer startup, and screenshot viability at a smoke-test level. + +### Checkpoint checklist + +- [ ] The same session state under the same profile yields stable screenshot dimensions. +- [ ] Screenshots are clearly linked to the replayed sequence number. +- [ ] `reference-dark` and `reference-light` both work. +- [ ] `doctor` reports renderer-related failures structurally. +- [ ] Tests cover screenshot creation and at least one `doctor` failure path. + +### Dogfooding gate + +Use `resize-demo` and `color-grid` style scenarios. + +Required artifacts: + +- [ ] one `reference-dark` screenshot, +- [ ] one `reference-light` screenshot, +- [ ] one resize-before screenshot, +- [ ] one resize-after screenshot, +- [ ] one short resize video, +- [ ] one notes file calling out clipping, wrapping, cursor, or palette issues if any are observed, +- [ ] one manifest excerpt or saved manifest file showing the screenshot entries. + +### Day 5 — renderer proof bundles and CI smoke coverage + +### Implementation checklist + +- [ ] Produce the first renderer-focused proof bundle under `dogfood/`. +- [ ] Add or refine fixture coverage for renderer-backed waits and screenshots. +- [ ] Add CI smoke coverage for the renderer-backed snapshot / screenshot path where practical. +- [ ] Document known gaps that remain after Week 2. +- [ ] Close the carried-forward Week 1 artifact gap by attaching a real screen recording / video to the control-plane proof story. + +### Checkpoint checklist + +- [ ] Another team member can review the Week 2 renderer story from the proof bundle alone. +- [ ] The proof bundle contains JSON outputs, snapshots, screenshots, notes, and at least one short video. +- [ ] Known gaps are written down explicitly instead of being implied. + +### Dogfooding gate + +Produce at least one complete renderer-focused scenario bundle. + +Required artifacts: + +- [ ] `create` / `inspect` / `wait` / `snapshot` / `screenshot` JSON outputs, +- [ ] snapshot files, +- [ ] screenshot files, +- [ ] notes, +- [ ] one short screen recording or replay video for the interaction, +- [ ] and one bundle manifest that makes the scenario reviewable offline. + +## Implementation notes from the shipped Week 2 slice + +A few implementation details differed slightly from the original plan and are worth recording here: + +- The shipped renderer harness lives in `src/renderer/ghosttyWeb/backend.ts` plus `harness.html`, rather than being split across separate browser/harness/semantics modules. +- Replay preparation stayed host-owned via `buildReplayInput()` and the host-side `EventLog` buffer, which keeps the CLI thin and the renderer interface narrow. +- Artifact storage is centralized under `artifacts/` with deterministic filenames like `snapshot--.json` and `screenshot--.png` plus `artifacts/manifest.json`. +- Post-implementation hardening added response validation at the CLI boundary, event-buffer/runtime guards, replay batching, and regex safety checks. + +## 7. Week 2 sign-off checklist + +- [x] All required implementation and checkpoint checkboxes above are complete for the shipped snapshot / screenshot / renderer-wait slice. +- [x] Relevant tests for the implemented Week 2 scope pass. +- [ ] Renderer-backed proof bundles contain screenshots and at least one short video. +- [x] `doctor` covers renderer smoke checks rather than only baseline environment checks. +- [x] The remaining gaps after Week 2 are documented explicitly. + +## 8. Week 2 stretch goals + +If the core Week 2 slice lands early, the best stretch goals are: + +- [ ] add `wait --cursor-row/--cursor-col`, +- [ ] add scrollback-scoped snapshot support, +- [ ] add a more explicit renderer-crash recovery test, +- [ ] add a proof-of-feasibility spike for `.cast` export from the existing event log, +- [ ] add a minimal review page or helper for browsing proof bundles locally. + +Stretch goals should not block Week 2 sign-off. + +## 9. Cross-cutting implementation rules for Week 2 + +### Replay before polish + +Do not over-invest in screenshot polish until replay correctness is strong enough that the renderer can reliably rebuild the latest state from the event log. + +### Thin CLI, fat host, narrow renderer interface + +The CLI should remain translation glue. + +The host should continue to own: + +- replay preparation, +- renderer lifecycle, +- capture sequencing, +- and artifact coordination. + +The renderer implementation should stay behind a narrow interface so later native backends do not force a CLI redesign. + +### Defensive programming + +Keep using fail-fast checks aggressively: + +- [ ] assert replay sequence ordering, +- [ ] assert render-profile lookup success, +- [ ] assert snapshot metadata matches the replayed sequence, +- [ ] assert screenshot metadata includes the profile used, +- [ ] assert manifest writes never point at temp files, +- [ ] assert browser harness requests stay local-only. + +## 10. Validation strategy for Week 2 + +Validation should happen in three layers. + +### 10.1 Automated tests + +At a minimum, Week 2 should add: + +- [ ] unit tests for replay/profile/schema logic, +- [ ] integration tests for renderer-backed `wait` and `snapshot`, +- [ ] screenshot smoke tests, +- [ ] `doctor` renderer smoke tests, +- [ ] and a renderer restart / rebuild test if practical. + +### 10.2 Terminal workflow + +Use the repo terminal to: + +- [ ] run Week 2 commands with `--json`, +- [ ] save outputs into `dogfood/-/`, +- [ ] inspect manifest files, +- [ ] inspect snapshot contents, +- [ ] and compare notes against the actual artifacts. + +### 10.3 Visual workflows + +#### Desktop workflow + +Use the desktop agent or an equivalent visual workflow whenever the claim requires human-visible proof. + +Examples: + +- [ ] showing that `wait --text` matched the intended screen, +- [ ] proving that screenshots are visually sane, +- [ ] proving resize redraw behavior under the renderer, +- [ ] or recording a short walkthrough for review. + +For every interaction-heavy checkpoint, capture: + +- [ ] at least one screenshot, +- [ ] and at least one short video. + +#### Browser workflow with `agent-browser` + +Use `agent-browser` or an equivalent browser-grounded tool for: + +- [ ] verifying the local `ghostty-web` harness loads correctly, +- [ ] checking pinned render profiles, +- [ ] verifying the harness remains local-only, +- [ ] and reviewing screenshot outputs when that is easier in a browser context than through raw JSON. + +## 11. Recommended immediate next step + +If implementation starts now, I would begin with the Day 1 replay-contract work and land the narrowest possible renderer-backed slice through: + +- `renderer/backend.ts`, +- lazy `ghostty-web` boot, +- `snapshot`, +- `wait --text`, +- `screenshot`, +- and one renderer-focused dogfood bundle. + +That gives the team a usable inspectability milestone before moving on to replay export, GC, or broader hardening. diff --git a/dogfood/20260320-renderer-complete/artifacts/screenshot-4-reference-dark.png b/dogfood/20260320-renderer-complete/artifacts/screenshot-4-reference-dark.png new file mode 100644 index 0000000..fa262ff Binary files /dev/null and b/dogfood/20260320-renderer-complete/artifacts/screenshot-4-reference-dark.png differ diff --git a/dogfood/20260320-renderer-complete/artifacts/screenshot-4-reference-light.png b/dogfood/20260320-renderer-complete/artifacts/screenshot-4-reference-light.png new file mode 100644 index 0000000..c6feccb Binary files /dev/null and b/dogfood/20260320-renderer-complete/artifacts/screenshot-4-reference-light.png differ diff --git a/dogfood/20260320-renderer-complete/commands.sh b/dogfood/20260320-renderer-complete/commands.sh new file mode 100644 index 0000000..28874f6 --- /dev/null +++ b/dogfood/20260320-renderer-complete/commands.sh @@ -0,0 +1,31 @@ +#!/usr/bin/env bash +# Reference only: exact command sequence used to produce this bundle. +# This is intentionally documentation, not an executable harness. + +set -euo pipefail + +export AGENT_TERMINAL_HOME="$(mktemp -d)" +CLI=(node --import tsx ./src/cli/main.ts) +BUNDLE="dogfood/20260320-renderer-complete" +SCENARIO_SCRIPT='printf "Loading\n"; sleep 1; printf "3 items\n"; sleep 1; printf "Ready\n"; exec cat' + +CREATE_OUTPUT="$(${CLI[@]} create --json -- /bin/sh -c "$SCENARIO_SCRIPT")" +printf '%s\n' "$CREATE_OUTPUT" > "$BUNDLE/create-output.json" + +SESSION_ID="$({ printf '%s' "$CREATE_OUTPUT" | node -e 'let data=""; process.stdin.on("data", (chunk) => { data += chunk; }); process.stdin.on("end", () => { process.stdout.write(JSON.parse(data).result.sessionId); });'; })" + +${CLI[@]} wait "$SESSION_ID" --text Ready --timeout 15000 --json > "$BUNDLE/wait-text.json" +${CLI[@]} type "$SESSION_ID" "typed from dogfood" --json > "$BUNDLE/type-output.json" +${CLI[@]} wait "$SESSION_ID" --regex 'typed.+dogfood' --timeout 15000 --json > "$BUNDLE/wait-regex.json" +${CLI[@]} snapshot "$SESSION_ID" --format structured --json > "$BUNDLE/snapshot-structured.json" +${CLI[@]} snapshot "$SESSION_ID" --format text --json > "$BUNDLE/snapshot-text.json" +${CLI[@]} screenshot "$SESSION_ID" --json > "$BUNDLE/screenshot-dark.json" +${CLI[@]} screenshot "$SESSION_ID" --profile reference-light --json > "$BUNDLE/screenshot-light.json" +${CLI[@]} doctor --json > "$BUNDLE/doctor.json" + +# Read the generated artifact manifest from: +# "$AGENT_TERMINAL_HOME/sessions/$SESSION_ID/artifacts/manifest.json" +# and save the tracked-artifact excerpt as: +# "$BUNDLE/manifest-excerpt.json" + +${CLI[@]} destroy "$SESSION_ID" --force --json > "$BUNDLE/destroy-output.json" diff --git a/dogfood/20260320-renderer-complete/create-output.json b/dogfood/20260320-renderer-complete/create-output.json new file mode 100644 index 0000000..df69ee6 --- /dev/null +++ b/dogfood/20260320-renderer-complete/create-output.json @@ -0,0 +1,8 @@ +{ + "ok": true, + "command": "create", + "timestamp": "2026-03-20T17:08:20.128Z", + "result": { + "sessionId": "01KM63G9DJ4DZD5RCXFJG547XG" + } +} diff --git a/dogfood/20260320-renderer-complete/destroy-output.json b/dogfood/20260320-renderer-complete/destroy-output.json new file mode 100644 index 0000000..39e4b56 --- /dev/null +++ b/dogfood/20260320-renderer-complete/destroy-output.json @@ -0,0 +1,9 @@ +{ + "ok": true, + "command": "destroy", + "timestamp": "2026-03-20T17:08:31.324Z", + "result": { + "sessionId": "01KM63G9DJ4DZD5RCXFJG547XG", + "destroyed": true + } +} diff --git a/dogfood/20260320-renderer-complete/doctor.json b/dogfood/20260320-renderer-complete/doctor.json new file mode 100644 index 0000000..adf509a --- /dev/null +++ b/dogfood/20260320-renderer-complete/doctor.json @@ -0,0 +1,56 @@ +{ + "ok": true, + "command": "doctor", + "timestamp": "2026-03-20T17:08:25.977Z", + "result": { + "ok": true, + "checks": { + "environment": [ + { + "name": "node-runtime", + "status": "pass", + "message": "Node 24.14.0 ok", + "durationMs": 0 + }, + { + "name": "cwd-access", + "status": "pass", + "message": "cwd read/write: /home/coder/.mux/src/agent-terminal/agent_exec_e6d1af2e37", + "durationMs": 1 + }, + { + "name": "temp-dir", + "status": "pass", + "message": "temp dir ok: /tmp", + "durationMs": 1 + } + ], + "renderer": [ + { + "name": "playwright_available", + "status": "pass", + "message": "available", + "durationMs": 296 + }, + { + "name": "browser_launch", + "status": "pass", + "message": "chromium launches", + "durationMs": 246 + }, + { + "name": "ghostty_web_available", + "status": "pass", + "message": "WASM available", + "durationMs": 114 + }, + { + "name": "screenshot_viable", + "status": "pass", + "message": "viable", + "durationMs": 324 + } + ] + } + } +} diff --git a/dogfood/20260320-renderer-complete/index.md b/dogfood/20260320-renderer-complete/index.md new file mode 100644 index 0000000..24feaae --- /dev/null +++ b/dogfood/20260320-renderer-complete/index.md @@ -0,0 +1,36 @@ +# Renderer completion proof bundle index + +This bundle captures the final Week 2 renderer smoke story for 2026-03-20. + +## Primary evidence + +| File | What it proves | +| -------------------------- | ------------------------------------------------------------------------------------------------------------------ | +| `notes.md` | Narrative summary of the scenario, renderer checks, and what to review. | +| `commands.sh` | Exact command sequence used to reproduce the create → wait → type → snapshot → screenshot → doctor → destroy flow. | +| `create-output.json` | Session creation succeeded and returned session ID `01KM63G9DJ4DZD5RCXFJG547XG`. | +| `wait-text.json` | Renderer-backed `wait --text` matched `Ready`. | +| `type-output.json` | Text input was accepted by the live session. | +| `wait-regex.json` | Renderer-backed `wait --regex` matched the echoed typed text. | +| `snapshot-structured.json` | Structured renderer snapshot includes viewport metadata and visible lines. | +| `snapshot-text.json` | Text renderer snapshot includes the visible transcript in lightweight form. | +| `screenshot-dark.json` | Screenshot capture succeeded with `reference-dark`. | +| `screenshot-light.json` | Screenshot capture succeeded with `reference-light`. | +| `manifest-excerpt.json` | Artifact manifest recorded both snapshot outputs and both screenshot outputs. | +| `doctor.json` | Doctor passed renderer checks for Playwright, browser launch, ghostty-web, and screenshot viability. | +| `destroy-output.json` | Session cleanup/destroy completed after artifact capture. | + +## Supplemental artifacts + +| File | What it shows | +| -------------------------------------------- | -------------------------------------------------------------------- | +| `artifacts/screenshot-4-reference-dark.png` | Copied dark-profile screenshot PNG from the temporary session home. | +| `artifacts/screenshot-4-reference-light.png` | Copied light-profile screenshot PNG from the temporary session home. | + +## Reviewer checklist + +1. Open `notes.md` for the scenario summary. +2. Confirm `wait-text.json` and `wait-regex.json` both report `matched: true`. +3. Compare `snapshot-text.json` against the copied PNGs in `artifacts/`. +4. Confirm `manifest-excerpt.json` lists two snapshots and two screenshots. +5. Confirm `doctor.json` reports all renderer checks as passing. diff --git a/dogfood/20260320-renderer-complete/manifest-excerpt.json b/dogfood/20260320-renderer-complete/manifest-excerpt.json new file mode 100644 index 0000000..738828b --- /dev/null +++ b/dogfood/20260320-renderer-complete/manifest-excerpt.json @@ -0,0 +1,64 @@ +{ + "version": 1, + "sessionId": "01KM63G9DJ4DZD5RCXFJG547XG", + "artifacts": [ + { + "id": "01KM63GD7FE4XZE4W4W45AJSSY", + "kind": "snapshot", + "filename": "snapshot-4-structured.json", + "sessionId": "01KM63G9DJ4DZD5RCXFJG547XG", + "capturedAtSeq": 4, + "createdAt": "2026-03-20T17:08:23.408Z", + "metadata": { + "format": "structured", + "cols": 80, + "rows": 24, + "cursorRow": 3, + "cursorCol": 18 + } + }, + { + "id": "01KM63GDFY9CCE1GGTV974QVE9", + "kind": "snapshot", + "filename": "snapshot-4-text.json", + "sessionId": "01KM63G9DJ4DZD5RCXFJG547XG", + "capturedAtSeq": 4, + "createdAt": "2026-03-20T17:08:23.678Z", + "metadata": { + "format": "text", + "cols": 80, + "rows": 24, + "cursorRow": 3, + "cursorCol": 18 + } + }, + { + "id": "01KM63GDW2A3ZV0461EKXAVA82", + "kind": "screenshot", + "filename": "screenshot-4-reference-dark.png", + "sessionId": "01KM63G9DJ4DZD5RCXFJG547XG", + "capturedAtSeq": 4, + "createdAt": "2026-03-20T17:08:24.066Z", + "metadata": { + "profileName": "reference-dark", + "cols": 80, + "rows": 24, + "pngSizeBytes": 5693 + } + }, + { + "id": "01KM63GEGXPG3QDS07MMJN1S2W", + "kind": "screenshot", + "filename": "screenshot-4-reference-light.png", + "sessionId": "01KM63G9DJ4DZD5RCXFJG547XG", + "capturedAtSeq": 4, + "createdAt": "2026-03-20T17:08:24.733Z", + "metadata": { + "profileName": "reference-light", + "cols": 80, + "rows": 24, + "pngSizeBytes": 5689 + } + } + ] +} diff --git a/dogfood/20260320-renderer-complete/notes.md b/dogfood/20260320-renderer-complete/notes.md new file mode 100644 index 0000000..38f84df --- /dev/null +++ b/dogfood/20260320-renderer-complete/notes.md @@ -0,0 +1,68 @@ +# Renderer completion dogfood notes + +- **Date:** 2026-03-20 +- **Bundle:** `dogfood/20260320-renderer-complete/` +- **Session ID:** `01KM63G9DJ4DZD5RCXFJG547XG` +- **Scenario command:** `/bin/sh -c "printf \"Loading\\n\"; sleep 1; printf \"3 items\\n\"; sleep 1; printf \"Ready\\n\"; exec cat"` +- **Isolation:** all commands ran under a fresh `AGENT_TERMINAL_HOME=$(mktemp -d)` and the bundle captures the resulting JSON envelopes plus copied screenshot artifacts. +- **Environment note:** this was collected in a headless environment, so the proof relies on CLI JSON outputs, renderer-generated PNGs, and passing automated checks rather than interactive screen recording. + +## What was exercised + +This run covered the expected renderer-backed Week 2 inspection flow end to end: + +1. **Create** a session that visibly transitions through `Loading`, `3 items`, and `Ready` before handing control to `cat`. +2. **Wait --text** for `Ready` to appear in renderer-visible output. +3. **Type** `typed from dogfood` into the live session. +4. **Wait --regex** for the echoed typed text to appear using the renderer path. +5. **Snapshot** the session in both structured and text formats. +6. **Screenshot** the session with both built-in renderer profiles (`reference-dark` and `reference-light`). +7. **Inspect artifact tracking** by reading the generated artifact manifest. +8. **Doctor** the environment and renderer stack. +9. **Destroy** the session cleanly after collecting artifacts. + +## What was verified + +### Session lifecycle + +- `create-output.json` shows session creation succeeded and returned session ID `01KM63G9DJ4DZD5RCXFJG547XG`. +- `type-output.json` shows the typed-text control path acknowledged successfully. +- `destroy-output.json` shows the session was destroyed after evidence collection. + +### Renderer-backed waits + +- `wait-text.json` shows `wait --text Ready` matched successfully at `capturedAtSeq: 2`. +- `wait-regex.json` shows `wait --regex 'typed.+dogfood'` matched successfully at `capturedAtSeq: 4`. + +### Snapshot coverage + +- `snapshot-structured.json` contains renderer-structured viewport data, cursor position, and visible lines. +- `snapshot-text.json` flattens the same viewport into text and includes the expected visible transcript: + - `Loading` + - `3 items` + - `Ready` + - `typed from dogfood` + +### Screenshot coverage + +- `screenshot-dark.json` proves screenshot capture succeeded with the default `reference-dark` profile. +- `screenshot-light.json` proves screenshot capture succeeded with the `reference-light` profile. +- The actual PNG outputs were copied into `artifacts/screenshot-4-reference-dark.png` and `artifacts/screenshot-4-reference-light.png` so the bundle remains reviewable even though the original session home was temporary. + +### Artifact tracking + +- `manifest-excerpt.json` shows four tracked artifacts for the session: + - structured snapshot JSON + - text snapshot JSON + - dark-profile screenshot PNG + - light-profile screenshot PNG +- The tracked filenames line up with the copied bundle artifacts and with the JSON command envelopes. + +### Doctor coverage + +- `doctor.json` reports `ok: true`. +- All renderer checks passed: `playwright_available`, `browser_launch`, `ghostty_web_available`, and `screenshot_viable`. + +## Review guidance + +A reviewer can validate the Week 2 renderer slice offline by opening the JSON files in this directory, confirming the manifest artifact list, and comparing the copied PNGs in `artifacts/` against the visible text reported by the snapshot outputs. diff --git a/dogfood/20260320-renderer-complete/screenshot-dark.json b/dogfood/20260320-renderer-complete/screenshot-dark.json new file mode 100644 index 0000000..97d5f4d --- /dev/null +++ b/dogfood/20260320-renderer-complete/screenshot-dark.json @@ -0,0 +1,14 @@ +{ + "ok": true, + "command": "screenshot", + "timestamp": "2026-03-20T17:08:24.070Z", + "result": { + "sessionId": "01KM63G9DJ4DZD5RCXFJG547XG", + "capturedAtSeq": 4, + "profileName": "reference-dark", + "cols": 80, + "rows": 24, + "artifactPath": "/tmp/tmp.7gryzxE6RZ/sessions/01KM63G9DJ4DZD5RCXFJG547XG/artifacts/screenshot-4-reference-dark.png", + "pngSizeBytes": 5693 + } +} diff --git a/dogfood/20260320-renderer-complete/screenshot-light.json b/dogfood/20260320-renderer-complete/screenshot-light.json new file mode 100644 index 0000000..1e6a323 --- /dev/null +++ b/dogfood/20260320-renderer-complete/screenshot-light.json @@ -0,0 +1,14 @@ +{ + "ok": true, + "command": "screenshot", + "timestamp": "2026-03-20T17:08:24.736Z", + "result": { + "sessionId": "01KM63G9DJ4DZD5RCXFJG547XG", + "capturedAtSeq": 4, + "profileName": "reference-light", + "cols": 80, + "rows": 24, + "artifactPath": "/tmp/tmp.7gryzxE6RZ/sessions/01KM63G9DJ4DZD5RCXFJG547XG/artifacts/screenshot-4-reference-light.png", + "pngSizeBytes": 5689 + } +} diff --git a/dogfood/20260320-renderer-complete/snapshot-structured.json b/dogfood/20260320-renderer-complete/snapshot-structured.json new file mode 100644 index 0000000..446592a --- /dev/null +++ b/dogfood/20260320-renderer-complete/snapshot-structured.json @@ -0,0 +1,113 @@ +{ + "ok": true, + "command": "snapshot", + "timestamp": "2026-03-20T17:08:23.412Z", + "result": { + "format": "structured", + "sessionId": "01KM63G9DJ4DZD5RCXFJG547XG", + "capturedAtSeq": 4, + "cols": 80, + "rows": 24, + "cursorRow": 3, + "cursorCol": 18, + "isAltScreen": false, + "visibleLines": [ + { + "row": 0, + "text": "Loading" + }, + { + "row": 1, + "text": "3 items" + }, + { + "row": 2, + "text": "Ready" + }, + { + "row": 3, + "text": "typed from dogfood" + }, + { + "row": 4, + "text": "" + }, + { + "row": 5, + "text": "" + }, + { + "row": 6, + "text": "" + }, + { + "row": 7, + "text": "" + }, + { + "row": 8, + "text": "" + }, + { + "row": 9, + "text": "" + }, + { + "row": 10, + "text": "" + }, + { + "row": 11, + "text": "" + }, + { + "row": 12, + "text": "" + }, + { + "row": 13, + "text": "" + }, + { + "row": 14, + "text": "" + }, + { + "row": 15, + "text": "" + }, + { + "row": 16, + "text": "" + }, + { + "row": 17, + "text": "" + }, + { + "row": 18, + "text": "" + }, + { + "row": 19, + "text": "" + }, + { + "row": 20, + "text": "" + }, + { + "row": 21, + "text": "" + }, + { + "row": 22, + "text": "" + }, + { + "row": 23, + "text": "" + } + ] + } +} diff --git a/dogfood/20260320-renderer-complete/snapshot-text.json b/dogfood/20260320-renderer-complete/snapshot-text.json new file mode 100644 index 0000000..16627bc --- /dev/null +++ b/dogfood/20260320-renderer-complete/snapshot-text.json @@ -0,0 +1,15 @@ +{ + "ok": true, + "command": "snapshot", + "timestamp": "2026-03-20T17:08:23.681Z", + "result": { + "format": "text", + "sessionId": "01KM63G9DJ4DZD5RCXFJG547XG", + "capturedAtSeq": 4, + "cols": 80, + "rows": 24, + "cursorRow": 3, + "cursorCol": 18, + "text": "Loading\n3 items\nReady\ntyped from dogfood\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n" + } +} diff --git a/dogfood/20260320-renderer-complete/type-output.json b/dogfood/20260320-renderer-complete/type-output.json new file mode 100644 index 0000000..c27f0ce --- /dev/null +++ b/dogfood/20260320-renderer-complete/type-output.json @@ -0,0 +1,6 @@ +{ + "ok": true, + "command": "type", + "timestamp": "2026-03-20T17:08:22.577Z", + "result": {} +} diff --git a/dogfood/20260320-renderer-complete/wait-regex.json b/dogfood/20260320-renderer-complete/wait-regex.json new file mode 100644 index 0000000..2b8eb08 --- /dev/null +++ b/dogfood/20260320-renderer-complete/wait-regex.json @@ -0,0 +1,11 @@ +{ + "ok": true, + "command": "wait", + "timestamp": "2026-03-20T17:08:23.079Z", + "result": { + "matched": true, + "timedOut": false, + "matchedText": "typed from dogfood", + "capturedAtSeq": 4 + } +} diff --git a/dogfood/20260320-renderer-complete/wait-text.json b/dogfood/20260320-renderer-complete/wait-text.json new file mode 100644 index 0000000..957a86f --- /dev/null +++ b/dogfood/20260320-renderer-complete/wait-text.json @@ -0,0 +1,11 @@ +{ + "ok": true, + "command": "wait", + "timestamp": "2026-03-20T17:08:22.292Z", + "result": { + "matched": true, + "timedOut": false, + "matchedText": "Ready", + "capturedAtSeq": 2 + } +} diff --git a/dogfood/20260321-post-hardening-smoke/agent-terminal-home.txt b/dogfood/20260321-post-hardening-smoke/agent-terminal-home.txt new file mode 100644 index 0000000..dc1e711 --- /dev/null +++ b/dogfood/20260321-post-hardening-smoke/agent-terminal-home.txt @@ -0,0 +1 @@ +/tmp/agent-terminal-dogfood-prvAiK diff --git a/dogfood/20260321-post-hardening-smoke/artifacts/screenshot-4-reference-dark.png b/dogfood/20260321-post-hardening-smoke/artifacts/screenshot-4-reference-dark.png new file mode 100644 index 0000000..af0c88a Binary files /dev/null and b/dogfood/20260321-post-hardening-smoke/artifacts/screenshot-4-reference-dark.png differ diff --git a/dogfood/20260321-post-hardening-smoke/artifacts/screenshot-4-reference-light.png b/dogfood/20260321-post-hardening-smoke/artifacts/screenshot-4-reference-light.png new file mode 100644 index 0000000..794a8d4 Binary files /dev/null and b/dogfood/20260321-post-hardening-smoke/artifacts/screenshot-4-reference-light.png differ diff --git a/dogfood/20260321-post-hardening-smoke/commands.sh b/dogfood/20260321-post-hardening-smoke/commands.sh new file mode 100755 index 0000000..eff76b3 --- /dev/null +++ b/dogfood/20260321-post-hardening-smoke/commands.sh @@ -0,0 +1,17 @@ +#!/usr/bin/env bash +set -euo pipefail +export AGENT_TERMINAL_HOME="$(mktemp -d)" +CLI=(node --import tsx ./src/cli/main.ts) +SCENARIO_SCRIPT='printf "Loading\n"; sleep 1; printf "3 items\n"; sleep 1; printf "Ready\n"; exec cat' +CREATE_OUTPUT="$(${CLI[@]} create --json -- /bin/sh -c "$SCENARIO_SCRIPT")" +SESSION_ID="$({ printf '%s' "$CREATE_OUTPUT" | node -e 'let data=""; process.stdin.on("data", (chunk) => data += chunk); process.stdin.on("end", () => process.stdout.write(JSON.parse(data).result.sessionId));'; })" +${CLI[@]} inspect "$SESSION_ID" --json +${CLI[@]} wait "$SESSION_ID" --text Ready --screen-stable-ms 500 --timeout 20000 --json +${CLI[@]} type "$SESSION_ID" "typed from post-hardening dogfood" --json +${CLI[@]} wait "$SESSION_ID" --regex 'typed.+dogfood' --timeout 20000 --json +${CLI[@]} snapshot "$SESSION_ID" --format structured --json +${CLI[@]} snapshot "$SESSION_ID" --format text --json +${CLI[@]} screenshot "$SESSION_ID" --json +${CLI[@]} screenshot "$SESSION_ID" --profile reference-light --json +${CLI[@]} doctor --json +${CLI[@]} destroy "$SESSION_ID" --force --json diff --git a/dogfood/20260321-post-hardening-smoke/create-output.json b/dogfood/20260321-post-hardening-smoke/create-output.json new file mode 100644 index 0000000..e1fc0af --- /dev/null +++ b/dogfood/20260321-post-hardening-smoke/create-output.json @@ -0,0 +1,8 @@ +{ + "ok": true, + "command": "create", + "timestamp": "2026-03-21T14:50:44.830Z", + "result": { + "sessionId": "01KM8E12G4CCE32NE70RFTS6VY" + } +} diff --git a/dogfood/20260321-post-hardening-smoke/destroy-output.json b/dogfood/20260321-post-hardening-smoke/destroy-output.json new file mode 100644 index 0000000..f9aa0ac --- /dev/null +++ b/dogfood/20260321-post-hardening-smoke/destroy-output.json @@ -0,0 +1,9 @@ +{ + "ok": true, + "command": "destroy", + "timestamp": "2026-03-21T14:51:30.447Z", + "result": { + "sessionId": "01KM8E12G4CCE32NE70RFTS6VY", + "destroyed": true + } +} diff --git a/dogfood/20260321-post-hardening-smoke/doctor.json b/dogfood/20260321-post-hardening-smoke/doctor.json new file mode 100644 index 0000000..0113d68 --- /dev/null +++ b/dogfood/20260321-post-hardening-smoke/doctor.json @@ -0,0 +1,56 @@ +{ + "ok": true, + "command": "doctor", + "timestamp": "2026-03-21T14:50:51.203Z", + "result": { + "ok": false, + "checks": { + "environment": [ + { + "name": "node-runtime", + "status": "fail", + "message": "Node 22.19.0 requires 24+", + "durationMs": 1 + }, + { + "name": "cwd-access", + "status": "pass", + "message": "cwd read/write: /home/coder/.mux/src/agent-terminal/design-verification-2ckn", + "durationMs": 1 + }, + { + "name": "temp-dir", + "status": "pass", + "message": "temp dir ok: /tmp", + "durationMs": 1 + } + ], + "renderer": [ + { + "name": "playwright_available", + "status": "pass", + "message": "available", + "durationMs": 307 + }, + { + "name": "browser_launch", + "status": "pass", + "message": "chromium launches", + "durationMs": 139 + }, + { + "name": "ghostty_web_available", + "status": "pass", + "message": "WASM available", + "durationMs": 86 + }, + { + "name": "screenshot_viable", + "status": "pass", + "message": "viable", + "durationMs": 163 + } + ] + } + } +} diff --git a/dogfood/20260321-post-hardening-smoke/index.md b/dogfood/20260321-post-hardening-smoke/index.md new file mode 100644 index 0000000..b8ff75d --- /dev/null +++ b/dogfood/20260321-post-hardening-smoke/index.md @@ -0,0 +1,22 @@ +# Post-hardening smoke bundle + +Session ID: 01KM8E12G4CCE32NE70RFTS6VY + +Artifacts and outputs: + +- create-output.json +- inspect-live.json +- wait-text-stable.json +- type-output.json +- wait-regex.json +- snapshot-structured.json +- snapshot-text.json +- screenshot-dark.json +- screenshot-light.json +- doctor.json +- manifest-excerpt.json +- destroy-output.json +- artifacts/screenshot-4-reference-dark.png +- artifacts/screenshot-4-reference-light.png +- notes.md +- commands.sh diff --git a/dogfood/20260321-post-hardening-smoke/inspect-live.json b/dogfood/20260321-post-hardening-smoke/inspect-live.json new file mode 100644 index 0000000..fe8cbec --- /dev/null +++ b/dogfood/20260321-post-hardening-smoke/inspect-live.json @@ -0,0 +1,26 @@ +{ + "ok": true, + "command": "inspect", + "timestamp": "2026-03-21T14:50:45.119Z", + "result": { + "session": { + "version": 1, + "sessionId": "01KM8E12G4CCE32NE70RFTS6VY", + "createdAt": "2026-03-21T14:50:44.104Z", + "updatedAt": "2026-03-21T14:50:44.793Z", + "status": "running", + "command": [ + "/bin/sh", + "-c", + "printf \"Loading\\n\"; sleep 1; printf \"3 items\\n\"; sleep 1; printf \"Ready\\n\"; exec cat" + ], + "cwd": "/home/coder/.mux/src/agent-terminal/design-verification-2ckn", + "cols": 80, + "rows": 24, + "hostPid": 221127, + "childPid": 221167, + "exitCode": null, + "exitSignal": null + } + } +} diff --git a/dogfood/20260321-post-hardening-smoke/manifest-excerpt.json b/dogfood/20260321-post-hardening-smoke/manifest-excerpt.json new file mode 100644 index 0000000..44fa949 --- /dev/null +++ b/dogfood/20260321-post-hardening-smoke/manifest-excerpt.json @@ -0,0 +1,64 @@ +{ + "version": 1, + "sessionId": "01KM8E12G4CCE32NE70RFTS6VY", + "artifacts": [ + { + "id": "01KM8E16YZKQS95MCFQQ352MQ3", + "kind": "snapshot", + "filename": "snapshot-4-structured.json", + "sessionId": "01KM8E12G4CCE32NE70RFTS6VY", + "capturedAtSeq": 4, + "createdAt": "2026-03-21T14:50:48.674Z", + "metadata": { + "format": "structured", + "cols": 80, + "rows": 24, + "cursorRow": 3, + "cursorCol": 33 + } + }, + { + "id": "01KM8E1795JAF2ANV03XA9T53Y", + "kind": "snapshot", + "filename": "snapshot-4-text.json", + "sessionId": "01KM8E12G4CCE32NE70RFTS6VY", + "capturedAtSeq": 4, + "createdAt": "2026-03-21T14:50:48.997Z", + "metadata": { + "format": "text", + "cols": 80, + "rows": 24, + "cursorRow": 3, + "cursorCol": 33 + } + }, + { + "id": "01KM8E17Q9EH1YCR1FDWGPS0AV", + "kind": "screenshot", + "filename": "screenshot-4-reference-dark.png", + "sessionId": "01KM8E12G4CCE32NE70RFTS6VY", + "capturedAtSeq": 4, + "createdAt": "2026-03-21T14:50:49.449Z", + "metadata": { + "profileName": "reference-dark", + "cols": 80, + "rows": 24, + "pngSizeBytes": 6517 + } + }, + { + "id": "01KM8E18F3M7M72HPNCZR7PFYT", + "kind": "screenshot", + "filename": "screenshot-4-reference-light.png", + "sessionId": "01KM8E12G4CCE32NE70RFTS6VY", + "capturedAtSeq": 4, + "createdAt": "2026-03-21T14:50:50.211Z", + "metadata": { + "profileName": "reference-light", + "cols": 80, + "rows": 24, + "pngSizeBytes": 6497 + } + } + ] +} diff --git a/dogfood/20260321-post-hardening-smoke/notes.md b/dogfood/20260321-post-hardening-smoke/notes.md new file mode 100644 index 0000000..79447c0 --- /dev/null +++ b/dogfood/20260321-post-hardening-smoke/notes.md @@ -0,0 +1,19 @@ +# 2026-03-21 post-hardening smoke + +This smoke run revalidated the Week 2 plan goals after the latest hardening changes. + +Verified end-to-end: + +- full quality gates via `npm run verify` +- live `inspect` against a running session +- combined renderer wait: `wait --text Ready --screen-stable-ms 500` +- renderer regex wait after live `type` +- `snapshot --format structured` and `snapshot --format text` +- `screenshot` with both built-in profiles +- `doctor --json` renderer checks +- artifact manifest and copied PNG artifacts + +Environment: + +- AGENT_TERMINAL_HOME: /tmp/agent-terminal-dogfood-prvAiK +- Session ID: 01KM8E12G4CCE32NE70RFTS6VY diff --git a/dogfood/20260321-post-hardening-smoke/screenshot-dark.json b/dogfood/20260321-post-hardening-smoke/screenshot-dark.json new file mode 100644 index 0000000..5017960 --- /dev/null +++ b/dogfood/20260321-post-hardening-smoke/screenshot-dark.json @@ -0,0 +1,14 @@ +{ + "ok": true, + "command": "screenshot", + "timestamp": "2026-03-21T14:50:49.454Z", + "result": { + "sessionId": "01KM8E12G4CCE32NE70RFTS6VY", + "capturedAtSeq": 4, + "profileName": "reference-dark", + "cols": 80, + "rows": 24, + "artifactPath": "/tmp/agent-terminal-dogfood-prvAiK/sessions/01KM8E12G4CCE32NE70RFTS6VY/artifacts/screenshot-4-reference-dark.png", + "pngSizeBytes": 6517 + } +} diff --git a/dogfood/20260321-post-hardening-smoke/screenshot-light.json b/dogfood/20260321-post-hardening-smoke/screenshot-light.json new file mode 100644 index 0000000..2549d35 --- /dev/null +++ b/dogfood/20260321-post-hardening-smoke/screenshot-light.json @@ -0,0 +1,14 @@ +{ + "ok": true, + "command": "screenshot", + "timestamp": "2026-03-21T14:50:50.215Z", + "result": { + "sessionId": "01KM8E12G4CCE32NE70RFTS6VY", + "capturedAtSeq": 4, + "profileName": "reference-light", + "cols": 80, + "rows": 24, + "artifactPath": "/tmp/agent-terminal-dogfood-prvAiK/sessions/01KM8E12G4CCE32NE70RFTS6VY/artifacts/screenshot-4-reference-light.png", + "pngSizeBytes": 6497 + } +} diff --git a/dogfood/20260321-post-hardening-smoke/session-id.txt b/dogfood/20260321-post-hardening-smoke/session-id.txt new file mode 100644 index 0000000..555e664 --- /dev/null +++ b/dogfood/20260321-post-hardening-smoke/session-id.txt @@ -0,0 +1 @@ +01KM8E12G4CCE32NE70RFTS6VY diff --git a/dogfood/20260321-post-hardening-smoke/snapshot-structured.json b/dogfood/20260321-post-hardening-smoke/snapshot-structured.json new file mode 100644 index 0000000..26c3310 --- /dev/null +++ b/dogfood/20260321-post-hardening-smoke/snapshot-structured.json @@ -0,0 +1,113 @@ +{ + "ok": true, + "command": "snapshot", + "timestamp": "2026-03-21T14:50:48.680Z", + "result": { + "format": "structured", + "sessionId": "01KM8E12G4CCE32NE70RFTS6VY", + "capturedAtSeq": 4, + "cols": 80, + "rows": 24, + "cursorRow": 3, + "cursorCol": 33, + "isAltScreen": false, + "visibleLines": [ + { + "row": 0, + "text": "Loading" + }, + { + "row": 1, + "text": "3 items" + }, + { + "row": 2, + "text": "Ready" + }, + { + "row": 3, + "text": "typed from post-hardening dogfood" + }, + { + "row": 4, + "text": "" + }, + { + "row": 5, + "text": "" + }, + { + "row": 6, + "text": "" + }, + { + "row": 7, + "text": "" + }, + { + "row": 8, + "text": "" + }, + { + "row": 9, + "text": "" + }, + { + "row": 10, + "text": "" + }, + { + "row": 11, + "text": "" + }, + { + "row": 12, + "text": "" + }, + { + "row": 13, + "text": "" + }, + { + "row": 14, + "text": "" + }, + { + "row": 15, + "text": "" + }, + { + "row": 16, + "text": "" + }, + { + "row": 17, + "text": "" + }, + { + "row": 18, + "text": "" + }, + { + "row": 19, + "text": "" + }, + { + "row": 20, + "text": "" + }, + { + "row": 21, + "text": "" + }, + { + "row": 22, + "text": "" + }, + { + "row": 23, + "text": "" + } + ] + } +} diff --git a/dogfood/20260321-post-hardening-smoke/snapshot-text.json b/dogfood/20260321-post-hardening-smoke/snapshot-text.json new file mode 100644 index 0000000..aed1c1b --- /dev/null +++ b/dogfood/20260321-post-hardening-smoke/snapshot-text.json @@ -0,0 +1,15 @@ +{ + "ok": true, + "command": "snapshot", + "timestamp": "2026-03-21T14:50:49.002Z", + "result": { + "format": "text", + "sessionId": "01KM8E12G4CCE32NE70RFTS6VY", + "capturedAtSeq": 4, + "cols": 80, + "rows": 24, + "cursorRow": 3, + "cursorCol": 33, + "text": "Loading\n3 items\nReady\ntyped from post-hardening dogfood\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n" + } +} diff --git a/dogfood/20260321-post-hardening-smoke/type-output.json b/dogfood/20260321-post-hardening-smoke/type-output.json new file mode 100644 index 0000000..3f67074 --- /dev/null +++ b/dogfood/20260321-post-hardening-smoke/type-output.json @@ -0,0 +1,6 @@ +{ + "ok": true, + "command": "type", + "timestamp": "2026-03-21T14:50:47.862Z", + "result": {} +} diff --git a/dogfood/20260321-post-hardening-smoke/wait-regex.json b/dogfood/20260321-post-hardening-smoke/wait-regex.json new file mode 100644 index 0000000..c77d119 --- /dev/null +++ b/dogfood/20260321-post-hardening-smoke/wait-regex.json @@ -0,0 +1,11 @@ +{ + "ok": true, + "command": "wait", + "timestamp": "2026-03-21T14:50:48.389Z", + "result": { + "matched": true, + "timedOut": false, + "matchedText": "typed from post-hardening dogfood", + "capturedAtSeq": 4 + } +} diff --git a/dogfood/20260321-post-hardening-smoke/wait-text-stable.json b/dogfood/20260321-post-hardening-smoke/wait-text-stable.json new file mode 100644 index 0000000..c5d0598 --- /dev/null +++ b/dogfood/20260321-post-hardening-smoke/wait-text-stable.json @@ -0,0 +1,11 @@ +{ + "ok": true, + "command": "wait", + "timestamp": "2026-03-21T14:50:47.592Z", + "result": { + "matched": true, + "timedOut": false, + "matchedText": "Ready", + "capturedAtSeq": 2 + } +} diff --git a/src/cli/commands/doctor.ts b/src/cli/commands/doctor.ts index 709697d..2c6c6ae 100644 --- a/src/cli/commands/doctor.ts +++ b/src/cli/commands/doctor.ts @@ -1,6 +1,6 @@ import assert from 'node:assert/strict'; -import { access, mkdtemp, rm } from 'node:fs/promises'; import { constants as fsConstants } from 'node:fs'; +import { access, mkdtemp, rm, stat } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import process from 'node:process'; @@ -8,87 +8,356 @@ import process from 'node:process'; import { emitSuccess } from '../output.js'; const COMMAND_NAME = 'doctor'; +const CHECK_TIMEOUT_MS = 10_000; +const DOCTOR_GROUP_ORDER = ['environment', 'renderer'] as const; +const DOCTOR_GROUP_LABELS: Readonly> = + Object.freeze({ + environment: 'Environment', + renderer: 'Renderer', + }); +const DOCTOR_CHECK_LABELS: Readonly> = Object.freeze({ + 'node-runtime': 'node', + 'cwd-access': 'cwd', + 'temp-dir': 'temp', + playwright_available: 'playwright', + browser_launch: 'browser', + ghostty_web_available: 'ghostty-web', + screenshot_viable: 'screenshot', +}); + +type DoctorCheckGroupName = 'environment' | 'renderer'; +type DoctorCheckStatus = 'pass' | 'fail' | 'skip'; +type DoctorCheckOperation = () => Promise | string; + +interface BrowserPageLike { + screenshot(options: { path: string; timeout: number }): Promise; + setContent( + html: string, + options: { timeout: number; waitUntil: 'load' }, + ): Promise; +} + +interface BrowserLike { + close(): Promise; + newPage(options: { + viewport: { + width: number; + height: number; + }; + }): Promise; +} + +interface ChromiumLike { + launch(options: { headless: boolean; timeout: number }): Promise; +} + +interface PlaywrightModuleLike { + chromium: ChromiumLike; +} + +interface GhosttyWebModuleLike { + init: unknown; + Terminal: unknown; +} export interface DoctorCheck { name: string; - ok: boolean; + status: DoctorCheckStatus; message: string; - details?: Record; + durationMs?: number; +} + +export interface DoctorCheckGroups { + environment: DoctorCheck[]; + renderer: DoctorCheck[]; } export interface DoctorResult { - checks: DoctorCheck[]; + ok: boolean; + checks: DoctorCheckGroups; +} + +function formatErrorMessage(error: unknown): string { + if (error instanceof Error) { + return error.message; + } + + return String(error); +} + +function getCheckDurationMs(startedAtMs: number): number { + return Math.max(0, Date.now() - startedAtMs); +} + +async function withTimeout( + operation: Promise, + timeoutMs: number, + timeoutMessage: string, +): Promise { + assert( + Number.isInteger(timeoutMs) && timeoutMs > 0, + 'timeoutMs must be a positive integer', + ); + + let timeoutHandle: NodeJS.Timeout | undefined; + try { + return await Promise.race([ + operation, + new Promise((_resolvePromise, rejectPromise) => { + timeoutHandle = setTimeout(() => { + rejectPromise(new Error(timeoutMessage)); + }, timeoutMs); + }), + ]); + } finally { + if (timeoutHandle !== undefined) { + clearTimeout(timeoutHandle); + } + } +} + +async function runDoctorCheck( + name: string, + operation: DoctorCheckOperation, +): Promise { + assert(name.length > 0, 'doctor check name must be a non-empty string'); + + const startedAtMs = Date.now(); + try { + const message = await withTimeout( + Promise.resolve(operation()), + CHECK_TIMEOUT_MS, + `${name} timed out after ${String(CHECK_TIMEOUT_MS)}ms`, + ); + assert( + message.length > 0, + 'doctor check success message must be non-empty', + ); + + return { + name, + status: 'pass', + message, + durationMs: getCheckDurationMs(startedAtMs), + }; + } catch (error) { + const message = formatErrorMessage(error); + assert( + message.length > 0, + 'doctor check failure message must be non-empty', + ); + + return { + name, + status: 'fail', + message, + durationMs: getCheckDurationMs(startedAtMs), + }; + } } -function runNodeRuntimeCheck(): DoctorCheck { +async function runCheckGroup( + checks: ReadonlyArray, +): Promise { + const results: DoctorCheck[] = []; + for (const [name, operation] of checks) { + results.push(await runDoctorCheck(name, operation)); + } + + return results; +} + +function runNodeRuntimeCheck(): string { const majorVersion = Number.parseInt( process.versions.node.split('.')[0] ?? '', 10, ); - const ok = Number.isInteger(majorVersion) && majorVersion >= 24; - - return { - name: 'node-runtime', - ok, - message: ok - ? `Node ${process.versions.node} ok` - : `Node ${process.versions.node} requires 24+`, - details: { - version: process.versions.node, - requiredMajor: 24, - }, - }; + assert( + Number.isInteger(majorVersion), + 'unable to parse Node runtime version', + ); + assert(majorVersion >= 24, `Node ${process.versions.node} requires 24+`); + return `Node ${process.versions.node} ok`; } -async function runWorkingDirectoryCheck(): Promise { +async function runWorkingDirectoryCheck(): Promise { await access(process.cwd(), fsConstants.R_OK | fsConstants.W_OK); - - return { - name: 'cwd-access', - ok: true, - message: `cwd read/write: ${process.cwd()}`, - }; + return `cwd read/write: ${process.cwd()}`; } -async function runTemporaryDirectoryCheck(): Promise { +async function runTemporaryDirectoryCheck(): Promise { const directoryPrefix = join(tmpdir(), 'agent-terminal-'); const temporaryDirectory = await mkdtemp(directoryPrefix); await rm(temporaryDirectory, { recursive: true, force: true }); + return `temp dir ok: ${tmpdir()}`; +} - return { - name: 'temp-dir', - ok: true, - message: `temp dir ok: ${tmpdir()}`, - }; +async function importPlaywrightModule(): Promise { + return withTimeout( + import('playwright') as Promise, + CHECK_TIMEOUT_MS, + `playwright import timed out after ${String(CHECK_TIMEOUT_MS)}ms`, + ); } -export async function runBaselineDoctorChecks(): Promise { - const checks = await Promise.all([ - Promise.resolve(runNodeRuntimeCheck()), - runWorkingDirectoryCheck(), - runTemporaryDirectoryCheck(), - ]); +async function getPlaywrightChromium(): Promise { + const playwrightModule = await importPlaywrightModule(); + assert.equal( + typeof playwrightModule.chromium.launch, + 'function', + 'playwright chromium.launch must be a function', + ); + return playwrightModule.chromium; +} + +async function runPlaywrightAvailableCheck(): Promise { + await getPlaywrightChromium(); + return 'available'; +} + +async function runBrowserLaunchCheck(): Promise { + const chromium = await getPlaywrightChromium(); + const browser = await chromium.launch({ + headless: true, + timeout: CHECK_TIMEOUT_MS, + }); + + try { + return 'chromium launches'; + } finally { + await browser.close(); + } +} + +async function importGhosttyWebModule(): Promise { + return withTimeout( + import('ghostty-web') as Promise, + CHECK_TIMEOUT_MS, + `ghostty-web import timed out after ${String(CHECK_TIMEOUT_MS)}ms`, + ); +} + +async function runGhosttyWebAvailableCheck(): Promise { + const ghosttyModule = await importGhosttyWebModule(); + assert.equal( + typeof ghosttyModule.init, + 'function', + 'ghostty-web init must be a function', + ); + assert.equal( + typeof ghosttyModule.Terminal, + 'function', + 'ghostty-web Terminal must be a constructor', + ); + return 'WASM available'; +} + +async function runScreenshotViabilityCheck(): Promise { + const chromium = await getPlaywrightChromium(); + const temporaryDirectory = await mkdtemp( + join(tmpdir(), 'agent-terminal-doctor-'), + ); + const screenshotPath = join(temporaryDirectory, 'smoke-check.png'); + let browser: BrowserLike | null = null; + + try { + browser = await chromium.launch({ + headless: true, + timeout: CHECK_TIMEOUT_MS, + }); + const page = await browser.newPage({ + viewport: { + width: 320, + height: 180, + }, + }); + await page.setContent( + 'doctor smoke check', + { + timeout: CHECK_TIMEOUT_MS, + waitUntil: 'load', + }, + ); + await page.screenshot({ + path: screenshotPath, + timeout: CHECK_TIMEOUT_MS, + }); + + const screenshotInfo = await stat(screenshotPath); + assert(screenshotInfo.size > 0, 'screenshot file must not be empty'); + return 'viable'; + } finally { + if (browser !== null) { + await browser.close().catch(() => undefined); + } + await rm(temporaryDirectory, { recursive: true, force: true }).catch( + () => undefined, + ); + } +} - const uniqueCheckNames = new Set(checks.map((check) => check.name)); +export async function runDoctorChecks(): Promise { + const environment = await runCheckGroup([ + ['node-runtime', runNodeRuntimeCheck], + ['cwd-access', runWorkingDirectoryCheck], + ['temp-dir', runTemporaryDirectoryCheck], + ]); + const renderer = await runCheckGroup([ + ['playwright_available', runPlaywrightAvailableCheck], + ['browser_launch', runBrowserLaunchCheck], + ['ghostty_web_available', runGhosttyWebAvailableCheck], + ['screenshot_viable', runScreenshotViabilityCheck], + ]); + const allChecks = [...environment, ...renderer]; + const uniqueCheckNames = new Set(allChecks.map((check) => check.name)); assert.equal( uniqueCheckNames.size, - checks.length, + allChecks.length, 'doctor check names must be unique', ); - return { checks }; + return { + ok: allChecks.every((check) => check.status !== 'fail'), + checks: { + environment, + renderer, + }, + }; +} + +export async function runBaselineDoctorChecks(): Promise { + return runDoctorChecks(); +} + +function formatHumanCheckLine(check: DoctorCheck): string { + const statusIcon = + check.status === 'pass' ? '✓' : check.status === 'skip' ? '○' : '✗'; + const label = DOCTOR_CHECK_LABELS[check.name] ?? check.name; + return ` ${statusIcon} ${label}: ${check.message}`; +} + +export function buildDoctorLines(result: DoctorResult): string[] { + const lines: string[] = []; + + for (const [index, groupName] of DOCTOR_GROUP_ORDER.entries()) { + const checks = result.checks[groupName]; + lines.push(`${DOCTOR_GROUP_LABELS[groupName]}:`); + lines.push(...checks.map((check) => formatHumanCheckLine(check))); + if (index < DOCTOR_GROUP_ORDER.length - 1) { + lines.push(''); + } + } + + return lines; } export async function runDoctorCommand(options: { json: boolean; }): Promise { - const result = await runBaselineDoctorChecks(); - const failingCheck = result.checks.find((check) => !check.ok); - const lines = result.checks.map( - (check) => `${check.ok ? 'ok' : 'fail'} ${check.name}: ${check.message}`, - ); + const result = await runDoctorChecks(); + const lines = buildDoctorLines(result); - if (failingCheck !== undefined) { + if (!result.ok) { process.exitCode = 1; } diff --git a/src/cli/commands/inspect.ts b/src/cli/commands/inspect.ts index 0322b24..a5d537b 100644 --- a/src/cli/commands/inspect.ts +++ b/src/cli/commands/inspect.ts @@ -1,3 +1,7 @@ +import { + InspectResultSchema, + type InspectResult, +} from '../../protocol/messages.js'; import type { SessionRecord } from '../../protocol/schemas.js'; import { CliError } from '../errors.js'; @@ -13,10 +17,6 @@ import { socketPath, } from '../../storage/sessionPaths.js'; -export interface InspectResult { - session: SessionRecord; -} - interface CommandOptions { json: boolean; sessionId: string; @@ -58,10 +58,18 @@ export async function runInspectCommand( if (session.status !== 'exited') { try { - const liveResult = (await sendRpc( + const rawResult: unknown = await sendRpc( socketPath(sessionDirectory), 'inspect', - )) as InspectResult; + ); + const parsedResult = InspectResultSchema.safeParse(rawResult); + if (!parsedResult.success) { + throw makeCliError(ERROR_CODES.PROTOCOL_ERROR, { + message: 'Unexpected response from host', + details: { issues: parsedResult.error.issues }, + }); + } + const liveResult: InspectResult = parsedResult.data; session = liveResult.session; } catch (error) { if ( diff --git a/src/cli/commands/screenshot.ts b/src/cli/commands/screenshot.ts new file mode 100644 index 0000000..dbbfae0 --- /dev/null +++ b/src/cli/commands/screenshot.ts @@ -0,0 +1,127 @@ +import type { ScreenshotResult } from '../../protocol/messages.js'; + +import { emitSuccess } from '../output.js'; +import { sendRpc } from '../../host/rpcClient.js'; +import { ScreenshotParamsSchema } from '../../protocol/messages.js'; +import { ScreenshotResultSchema } from '../../protocol/schemas.js'; +import { ERROR_CODES, makeCliError } from '../../protocol/errors.js'; +import { readManifestIfExists } from '../../storage/manifests.js'; +import { resolveHome } from '../../storage/home.js'; +import { + manifestPath, + sessionDir, + socketPath, +} from '../../storage/sessionPaths.js'; + +const DEFAULT_SCREENSHOT_PROFILE = 'reference-dark'; + +interface CommandOptions { + json: boolean; + sessionId: string; + profile?: string; +} + +function resolveScreenshotProfile(profile: string | undefined): string { + const effectiveProfile = profile ?? DEFAULT_SCREENSHOT_PROFILE; + const profileResult = ScreenshotParamsSchema.safeParse({ + profile: effectiveProfile, + }); + + if (!profileResult.success) { + throw makeCliError(ERROR_CODES.INVALID_INPUT, { + message: 'Screenshot profile must be a non-empty string.', + details: { + profile: effectiveProfile, + }, + cause: profileResult.error, + }); + } + + if (profileResult.data.profile === undefined) { + throw makeCliError(ERROR_CODES.INVALID_INPUT, { + message: 'Screenshot profile is required.', + details: { + profile: effectiveProfile, + }, + }); + } + + return profileResult.data.profile; +} + +function formatScreenshotLines(result: ScreenshotResult): string[] { + return [ + `Session ID: ${result.sessionId}`, + `Captured At Seq: ${String(result.capturedAtSeq)}`, + `Profile: ${result.profileName}`, + `Size: ${String(result.cols)}x${String(result.rows)}`, + `PNG Path: ${result.artifactPath}`, + `PNG Size: ${String(result.pngSizeBytes)} bytes`, + ]; +} + +export async function runScreenshotCommand( + options: CommandOptions, +): Promise { + const profile = resolveScreenshotProfile(options.profile); + const home = resolveHome(); + let sessionDirectory: string; + + try { + sessionDirectory = sessionDir(home, options.sessionId); + } catch (error) { + throw makeCliError(ERROR_CODES.INVALID_SESSION_ID, { + message: `Session ID "${options.sessionId}" is invalid.`, + details: { + sessionId: options.sessionId, + }, + cause: error, + }); + } + + const manifestFile = manifestPath(sessionDirectory); + const manifest = await readManifestIfExists(manifestFile); + + if (manifest === null) { + throw makeCliError(ERROR_CODES.SESSION_NOT_FOUND, { + message: `Session "${options.sessionId}" was not found.`, + details: { + sessionId: options.sessionId, + manifestPath: manifestFile, + }, + }); + } + + if (manifest.status !== 'running') { + throw makeCliError(ERROR_CODES.SESSION_NOT_RUNNING, { + message: `Session "${options.sessionId}" is not running.`, + details: { + sessionId: options.sessionId, + status: manifest.status, + }, + }); + } + + const rawResult: unknown = await sendRpc( + socketPath(sessionDirectory), + 'screenshot', + { + profile, + }, + ); + const parsedResult = ScreenshotResultSchema.safeParse(rawResult); + if (!parsedResult.success) { + throw makeCliError(ERROR_CODES.PROTOCOL_ERROR, { + message: 'Unexpected response from host', + details: { issues: parsedResult.error.issues }, + }); + } + const result: ScreenshotResult = parsedResult.data; + + emitSuccess({ + command: 'screenshot', + json: options.json, + result, + lines: formatScreenshotLines(result), + }); +} diff --git a/src/cli/commands/snapshot.ts b/src/cli/commands/snapshot.ts new file mode 100644 index 0000000..dc83e13 --- /dev/null +++ b/src/cli/commands/snapshot.ts @@ -0,0 +1,151 @@ +import type { SnapshotResult } from '../../protocol/messages.js'; + +import { emitSuccess } from '../output.js'; +import { sendRpc } from '../../host/rpcClient.js'; +import { + SnapshotParamsSchema, + type SnapshotParams, +} from '../../protocol/messages.js'; +import { SnapshotResultSchema } from '../../protocol/schemas.js'; +import { ERROR_CODES, makeCliError } from '../../protocol/errors.js'; +import { readManifestIfExists } from '../../storage/manifests.js'; +import { resolveHome } from '../../storage/home.js'; +import { + manifestPath, + sessionDir, + socketPath, +} from '../../storage/sessionPaths.js'; + +const DEFAULT_SNAPSHOT_FORMAT = 'structured'; + +type SnapshotFormat = NonNullable; + +interface CommandOptions { + json: boolean; + sessionId: string; + format?: string; +} + +function resolveSnapshotFormat(format: string | undefined): SnapshotFormat { + const effectiveFormat = format ?? DEFAULT_SNAPSHOT_FORMAT; + const formatResult = SnapshotParamsSchema.safeParse({ + format: effectiveFormat, + }); + + if (!formatResult.success) { + throw makeCliError(ERROR_CODES.INVALID_INPUT, { + message: 'Snapshot format must be one of: structured, text.', + details: { + format: effectiveFormat, + }, + cause: formatResult.error, + }); + } + + if (formatResult.data.format === undefined) { + throw makeCliError(ERROR_CODES.INVALID_INPUT, { + message: 'Snapshot format is required.', + details: { + format: effectiveFormat, + }, + }); + } + + return formatResult.data.format; +} + +function formatSnapshotLines(result: SnapshotResult): string[] { + const lines = [ + `Session ID: ${result.sessionId}`, + `Captured At Seq: ${String(result.capturedAtSeq)}`, + `Format: ${result.format}`, + `Size: ${String(result.cols)}x${String(result.rows)}`, + `Cursor: row ${String(result.cursorRow)}, col ${String(result.cursorCol)}`, + ]; + + if (result.format === 'structured') { + lines.push(`Alt Screen: ${result.isAltScreen ? 'yes' : 'no'}`); + + if (result.visibleLines.length === 0) { + lines.push('Visible Lines: (none)'); + return lines; + } + + lines.push(`Visible Lines (${String(result.visibleLines.length)}):`); + for (const line of result.visibleLines) { + lines.push(` [${String(line.row)}] ${line.text}`); + } + + return lines; + } + + lines.push('Text:'); + lines.push(result.text.length > 0 ? result.text : '(empty)'); + return lines; +} + +export async function runSnapshotCommand( + options: CommandOptions, +): Promise { + const format = resolveSnapshotFormat(options.format); + const home = resolveHome(); + let sessionDirectory: string; + + try { + sessionDirectory = sessionDir(home, options.sessionId); + } catch (error) { + throw makeCliError(ERROR_CODES.INVALID_SESSION_ID, { + message: `Session ID "${options.sessionId}" is invalid.`, + details: { + sessionId: options.sessionId, + }, + cause: error, + }); + } + + const manifestFile = manifestPath(sessionDirectory); + const manifest = await readManifestIfExists(manifestFile); + + if (manifest === null) { + throw makeCliError(ERROR_CODES.SESSION_NOT_FOUND, { + message: `Session "${options.sessionId}" was not found.`, + details: { + sessionId: options.sessionId, + manifestPath: manifestFile, + }, + }); + } + + if (manifest.status !== 'running') { + throw makeCliError(ERROR_CODES.SESSION_NOT_RUNNING, { + message: `Session "${options.sessionId}" is not running.`, + details: { + sessionId: options.sessionId, + status: manifest.status, + }, + }); + } + + const rawResult: unknown = await sendRpc( + socketPath(sessionDirectory), + 'snapshot', + { + format, + }, + ); + const parsedResult = SnapshotResultSchema.safeParse(rawResult); + if (!parsedResult.success) { + throw makeCliError(ERROR_CODES.PROTOCOL_ERROR, { + message: 'Unexpected response from host', + details: { issues: parsedResult.error.issues }, + }); + } + const result: SnapshotResult = parsedResult.data; + + emitSuccess({ + command: 'snapshot', + json: options.json, + result, + lines: formatSnapshotLines(result), + }); +} diff --git a/src/cli/commands/wait.ts b/src/cli/commands/wait.ts index 8619fd4..153391a 100644 --- a/src/cli/commands/wait.ts +++ b/src/cli/commands/wait.ts @@ -1,6 +1,15 @@ +import type { + WaitForRenderResult, + WaitResult, +} from '../../protocol/messages.js'; + import { emitSuccess } from '../output.js'; import { sendRpc } from '../../host/rpcClient.js'; import { ERROR_CODES, makeCliError } from '../../protocol/errors.js'; +import { + WaitForRenderResultSchema, + WaitResultSchema, +} from '../../protocol/schemas.js'; import { readManifestIfExists } from '../../storage/manifests.js'; import { resolveHome } from '../../storage/home.js'; import { @@ -9,17 +18,15 @@ import { socketPath, } from '../../storage/sessionPaths.js'; -export interface WaitResult { - exitCode?: number; - timedOut: boolean; -} - interface CommandOptions { json: boolean; sessionId: string; waitForExit: boolean; idleMs: number | undefined; timeout: number | undefined; + text: string | undefined; + regex: string | undefined; + screenStableMs: number | undefined; } const DEFAULT_WAIT_TIMEOUT_MS = 600_000; @@ -28,6 +35,14 @@ function isPositiveInteger(value: number | undefined): value is number { return value !== undefined && Number.isInteger(value) && value > 0; } +function isRenderWaitMode(options: CommandOptions): boolean { + return ( + options.text !== undefined || + options.regex !== undefined || + options.screenStableMs !== undefined + ); +} + function waitLines(result: WaitResult): string[] { if (result.timedOut) { return ['Wait timed out.']; @@ -40,6 +55,21 @@ function waitLines(result: WaitResult): string[] { return ['Wait condition met.']; } +function renderWaitLines(result: WaitForRenderResult): string[] { + if (result.timedOut) { + return [`Wait timed out. (capturedAtSeq: ${String(result.capturedAtSeq)})`]; + } + + const lines: string[] = []; + if (result.matchedText !== undefined) { + lines.push(`Matched: ${result.matchedText}`); + } else { + lines.push('Wait condition met.'); + } + lines.push(`capturedAtSeq: ${String(result.capturedAtSeq)}`); + return lines; +} + export async function runWaitCommand(options: CommandOptions): Promise { const home = resolveHome(); const sessionDirectory = sessionDir(home, options.sessionId); @@ -56,6 +86,78 @@ export async function runWaitCommand(options: CommandOptions): Promise { }); } + const renderMode = isRenderWaitMode(options); + const legacyMode = options.waitForExit || options.idleMs !== undefined; + + if (renderMode && legacyMode) { + throw makeCliError(ERROR_CODES.INVALID_INPUT, { + message: + 'Cannot mix legacy wait flags (--exit, --idle-ms) with render wait flags (--text, --regex, --screen-stable-ms).', + }); + } + + if (renderMode) { + if (options.text !== undefined && options.regex !== undefined) { + throw makeCliError(ERROR_CODES.INVALID_INPUT, { + message: '--text and --regex are mutually exclusive.', + }); + } + + if ( + options.screenStableMs !== undefined && + !isPositiveInteger(options.screenStableMs) + ) { + throw makeCliError(ERROR_CODES.INVALID_DURATION, { + message: '--screen-stable-ms must be a positive integer.', + details: { screenStableMs: options.screenStableMs }, + }); + } + + if ( + options.timeout !== undefined && + options.timeout !== 0 && + !isPositiveInteger(options.timeout) + ) { + throw makeCliError(ERROR_CODES.INVALID_DURATION, { + message: '--timeout must be a non-negative integer (0 for infinite).', + details: { + timeout: options.timeout, + }, + }); + } + + const effectiveTimeout = options.timeout ?? DEFAULT_WAIT_TIMEOUT_MS; + const params = { + text: options.text, + regex: options.regex, + screenStableMs: options.screenStableMs, + timeoutMs: effectiveTimeout === 0 ? undefined : effectiveTimeout, + }; + const clientTimeout = effectiveTimeout === 0 ? 0 : effectiveTimeout + 5_000; + const rawResult: unknown = await sendRpc( + socketPath(sessionDirectory), + 'waitForRender', + params, + clientTimeout, + ); + const parsedResult = WaitForRenderResultSchema.safeParse(rawResult); + if (!parsedResult.success) { + throw makeCliError(ERROR_CODES.PROTOCOL_ERROR, { + message: 'Unexpected response from host', + details: { issues: parsedResult.error.issues }, + }); + } + const result: WaitForRenderResult = parsedResult.data; + + emitSuccess({ + command: 'wait', + json: options.json, + result, + lines: renderWaitLines(result), + }); + return; + } + const hasIdleMs = options.idleMs !== undefined; if (options.waitForExit === hasIdleMs) { throw makeCliError(ERROR_CODES.INVALID_DURATION, { @@ -117,12 +219,20 @@ export async function runWaitCommand(options: CommandOptions): Promise { timeoutMs: effectiveTimeout === 0 ? undefined : effectiveTimeout, }; const clientTimeout = effectiveTimeout === 0 ? 0 : effectiveTimeout + 5_000; - const result = (await sendRpc( + const rawResult: unknown = await sendRpc( socketPath(sessionDirectory), 'wait', params, clientTimeout, - )) as WaitResult; + ); + const parsedResult = WaitResultSchema.safeParse(rawResult); + if (!parsedResult.success) { + throw makeCliError(ERROR_CODES.PROTOCOL_ERROR, { + message: 'Unexpected response from host', + details: { issues: parsedResult.error.issues }, + }); + } + const result: WaitResult = parsedResult.data; emitSuccess({ command: 'wait', diff --git a/src/cli/main.ts b/src/cli/main.ts index de603fb..0d9aada 100644 --- a/src/cli/main.ts +++ b/src/cli/main.ts @@ -11,8 +11,10 @@ import { runInspectCommand } from './commands/inspect.js'; import { runListCommand } from './commands/list.js'; import { runPasteCommand } from './commands/paste.js'; import { runResizeCommand } from './commands/resize.js'; +import { runScreenshotCommand } from './commands/screenshot.js'; import { runSendKeysCommand } from './commands/send-keys.js'; import { runSignalCommand } from './commands/signal.js'; +import { runSnapshotCommand } from './commands/snapshot.js'; import { runTypeCommand } from './commands/type.js'; import { runVersionCommand } from './commands/version.js'; import { runWaitCommand } from './commands/wait.js'; @@ -265,6 +267,52 @@ async function main(): Promise { ); // --- Observation --- + program + .command('snapshot ') + .description('Capture a terminal snapshot') + .option( + '--format ', + "Output format: 'structured' or 'text'", + 'structured', + ) + .option('--json', 'Emit a JSON command envelope', false) + .action( + wrapAction( + 'snapshot', + async ( + sessionId: string, + options: { format: string; json: boolean }, + ) => { + await runSnapshotCommand({ + json: options.json, + sessionId, + format: options.format, + }); + }, + ), + ); + + program + .command('screenshot ') + .description('Capture a rendered screenshot') + .option('--profile ', 'Render profile name', 'reference-dark') + .option('--json', 'Emit a JSON command envelope', false) + .action( + wrapAction( + 'screenshot', + async ( + sessionId: string, + options: { profile: string; json: boolean }, + ) => { + await runScreenshotCommand({ + json: options.json, + sessionId, + profile: options.profile, + }); + }, + ), + ); + program .command('wait ') .description('Wait for a session condition') @@ -276,6 +324,13 @@ async function main(): Promise { parseIntegerOption, ) .option('--json', 'Emit a JSON command envelope', false) + .option('--text ', 'Wait for text to appear in rendered output') + .option('--regex ', 'Wait for regex match in rendered output') + .option( + '--screen-stable-ms ', + 'Wait for screen to be stable for given ms', + parseIntegerOption, + ) .action( wrapAction( 'wait', @@ -286,6 +341,9 @@ async function main(): Promise { idleMs?: number; timeout?: number; json: boolean; + text?: string; + regex?: string; + screenStableMs?: number; }, ) => { await runWaitCommand({ @@ -294,6 +352,9 @@ async function main(): Promise { waitForExit: options.exit, idleMs: options.idleMs, timeout: options.timeout, + text: options.text, + regex: options.regex, + screenStableMs: options.screenStableMs, }); }, ), diff --git a/src/host/eventLog.ts b/src/host/eventLog.ts index f8d71a9..f8a0181 100644 --- a/src/host/eventLog.ts +++ b/src/host/eventLog.ts @@ -81,6 +81,15 @@ type EventLogPayload = | SignalEventPayload | ExitEventPayload; +// Keep this in sync with the replay loader's event-log size limit. +const MAX_EVENT_LOG_SIZE = 50 * 1024 * 1024; + +/** + * Maximum number of events retained in the in-memory buffer. + * At ~200 bytes per event object, 250k events ≈ 50MB — consistent with the file size limit. + */ +export const MAX_EVENT_BUFFER_ENTRIES = 250_000; + function assertFilePath(filePath: string): void { invariant(filePath.length > 0, 'filePath must be a non-empty string'); } @@ -128,49 +137,88 @@ function validatePayload( } } -function deriveNextSeq(content: string): number { - const lines = content - .split('\n') - .map((line) => line.trim()) - .filter((line) => line.length > 0); - - if (lines.length === 0) { - return 0; - } - - const lastLine = lines.at(-1); - invariant(lastLine !== undefined, 'event log must contain a last line'); - +function parseEventLogLine(line: string, lineNumber: number): EventRecord { let parsedLine: unknown; try { - parsedLine = JSON.parse(lastLine); + parsedLine = JSON.parse(line) as unknown; } catch { - invariant(false, 'last event log line must be valid JSON'); + invariant(false, `event log line ${String(lineNumber)} must be valid JSON`); } const parsedRecord = EventRecordSchema.safeParse(parsedLine); invariant( parsedRecord.success, - 'last event log line must match EventRecordSchema', + `event log line ${String(lineNumber)} must match EventRecordSchema`, ); - const { seq } = parsedRecord.data; - invariant(Number.isInteger(seq), 'event log seq must be an integer'); - invariant(seq >= 0, 'event log seq must be non-negative'); + return parsedRecord.data; +} + +function assertContiguousSequence(records: EventRecord[]): void { + if (records.length === 0) { + return; + } + + invariant(records[0]?.seq === 0, 'first event log seq must be 0'); + + for (let index = 1; index < records.length; index += 1) { + const previous = records[index - 1]; + const current = records[index]; + + invariant(previous !== undefined, 'previous event record must exist'); + invariant(current !== undefined, 'current event record must exist'); + invariant( + current.seq === previous.seq + 1, + 'event log seq values must increase by 1 without gaps', + ); + } +} + +function parseEventLogContent(content: string): EventRecord[] { + const lines = content + .split('\n') + .map((line) => line.trim()) + .filter((line) => line.length > 0); + + const records = lines.map((line, index) => + parseEventLogLine(line, index + 1), + ); + assertContiguousSequence(records); + return records; +} + +function deriveNextSeq(records: readonly EventRecord[]): number { + if (records.length === 0) { + return 0; + } + + const lastRecord = records.at(-1); + invariant(lastRecord !== undefined, 'event log must contain a last record'); + invariant(lastRecord.seq >= 0, 'event log seq must be non-negative'); - return seq + 1; + return lastRecord.seq + 1; } export class EventLog { private writeQueue: Promise = Promise.resolve(); + private eventBuffer: EventRecord[] = []; + private constructor( + filePath: string, private readonly fileHandle: FileHandle, private nextSeq: number, + eventBuffer: EventRecord[] = [], private isClosed = false, ) { + invariant(filePath.length > 0, 'filePath must be a non-empty string'); invariant(Number.isInteger(nextSeq), 'nextSeq must be an integer'); invariant(nextSeq >= 0, 'nextSeq must be non-negative'); + invariant( + nextSeq === eventBuffer.length, + 'nextSeq must match buffered event count', + ); + this.eventBuffer = eventBuffer; } static async open(filePath: string): Promise { @@ -178,14 +226,21 @@ export class EventLog { const fileHandle = await open(filePath, 'a'); const fileStats = await fileHandle.stat(); + invariant( + fileStats.size <= MAX_EVENT_LOG_SIZE, + `event log file exceeds size limit (${String(fileStats.size)} bytes, max ${String(MAX_EVENT_LOG_SIZE)})`, + ); + let eventBuffer: EventRecord[] = []; let nextSeq = 0; if (fileStats.size > 0) { const existingContent = await readFile(filePath, 'utf8'); - nextSeq = deriveNextSeq(existingContent); + eventBuffer = parseEventLogContent(existingContent); + nextSeq = deriveNextSeq(eventBuffer); + invariant(nextSeq >= 0, 'derived next seq must be non-negative'); } - return new EventLog(fileHandle, nextSeq); + return new EventLog(filePath, fileHandle, nextSeq, eventBuffer); } async append(type: 'output', payload: OutputEventPayload): Promise; @@ -212,26 +267,99 @@ export class EventLog { const validatedPayload = validatePayload(type, payload); const seq = this.nextSeq; + invariant( + seq === this.nextSeq, + 'event seq must match the expected next seq', + ); + invariant(seq >= 0, 'event seq must be non-negative'); this.nextSeq += 1; - const record: EventRecord = { + const record = { seq, ts: new Date().toISOString(), type, payload: validatedPayload, }; + invariant( + record.seq === seq, + 'event record seq must match the reserved seq', + ); + const parsedRecord = EventRecordSchema.safeParse(record); invariant( parsedRecord.success, 'event record must match EventRecordSchema', ); + if (this.eventBuffer.length >= MAX_EVENT_BUFFER_ENTRIES) { + this.nextSeq = seq; + } + invariant( + this.eventBuffer.length < MAX_EVENT_BUFFER_ENTRIES, + `event buffer exceeds ${String(MAX_EVENT_BUFFER_ENTRIES)} entries; session event log is too large`, + ); + invariant( + parsedRecord.data.seq === this.eventBuffer.length, + 'event record seq must match the buffered event count', + ); + this.eventBuffer.push(parsedRecord.data); const line = `${JSON.stringify(parsedRecord.data)}\n`; - this.writeQueue = this.writeQueue.then(() => - this.fileHandle.appendFile(line, 'utf8'), + const writePromise = this.writeQueue.then(async () => { + try { + await this.fileHandle.appendFile(line, 'utf8'); + } catch (error) { + this.rollbackBufferedEventsFrom(seq); + throw error; + } + }); + this.writeQueue = writePromise; + + try { + await writePromise; + } catch (error) { + this.rollbackBufferedEventsFrom(seq); + throw error; + } + } + + private rollbackBufferedEventsFrom(failedSeq: number): void { + invariant(Number.isInteger(failedSeq), 'failedSeq must be an integer'); + invariant(failedSeq >= 0, 'failedSeq must be non-negative'); + + if (this.eventBuffer.length <= failedSeq) { + return; + } + + const failedRecord = this.eventBuffer[failedSeq]; + invariant(failedRecord !== undefined, 'failed event record must exist'); + invariant( + failedRecord.seq === failedSeq, + 'failed event seq must match the buffered rollback position', ); + + this.eventBuffer.splice(failedSeq); + this.nextSeq = this.eventBuffer.length; + } + + getEvents(): readonly EventRecord[] { + return this.eventBuffer; + } + + getEventsSince(afterSeq: number): EventRecord[] { + invariant(Number.isInteger(afterSeq), 'afterSeq must be an integer'); + invariant(afterSeq >= -1, 'afterSeq must be greater than or equal to -1'); + + if (afterSeq >= this.eventBuffer.length) { + return []; + } + + return this.eventBuffer.slice(afterSeq + 1); + } + + async readAll(): Promise { await this.writeQueue; + return this.eventBuffer.slice(); } async close(): Promise { diff --git a/src/host/hostMain.ts b/src/host/hostMain.ts index 12c6384..ca782a5 100644 --- a/src/host/hostMain.ts +++ b/src/host/hostMain.ts @@ -1,6 +1,11 @@ +import { rename, rm } from 'node:fs/promises'; import process from 'node:process'; +import { ulid } from 'ulid'; + import { EventLog } from './eventLog.js'; +import { buildReplayInput } from './replay.js'; +import { HostRendererManager } from './renderer.js'; import { RpcServer, type MethodHandler } from './rpcServer.js'; import { SessionState } from './sessionState.js'; import { createPty } from '../pty/createPty.js'; @@ -10,13 +15,33 @@ import { ERROR_CODES, makeCliError } from '../protocol/errors.js'; import type { PasteParams, ResizeParams, + ScreenshotParams, SendKeysParams, SignalParams, + SnapshotParams, TypeParams, + WaitForRenderParams, + WaitForRenderResult, WaitParams, } from '../protocol/messages.js'; -import { readManifest, writeManifest } from '../storage/manifests.js'; +import { GhosttyWebBackend } from '../renderer/ghosttyWeb/index.js'; +import { resolveProfile } from '../renderer/profiles.js'; +import { + appendArtifact, + createArtifactEntry, +} from '../storage/artifactManifest.js'; +import { + artifactPath, + ensureArtifactsDir, + screenshotFilename, + snapshotFilename, +} from '../storage/artifactPaths.js'; import { resolveHome } from '../storage/home.js'; +import { + readManifest, + writeManifest, + writeTextFileAtomic, +} from '../storage/manifests.js'; import { eventLogPath, manifestPath, @@ -34,6 +59,12 @@ const ALLOWED_SIGNALS = [ 'SIGUSR2', ] as const; +const DEFAULT_RENDER_PROFILE_NAME = 'reference-dark'; +const MAX_WAIT_FOR_RENDER_REGEX_LENGTH = 200; +export const MAX_WAIT_FOR_RENDER_REGEX_TEXT_LENGTH = 50_000; +export const MAX_CONSECUTIVE_POLL_FAILURES = 10; +const BRACED_QUANTIFIER_PATTERN = /^\{(?:\d+|\d+,\d*)\}/; + type WaitOutcome = { exitCode?: number; timedOut: boolean; @@ -58,6 +89,108 @@ function rethrowAsync(error: unknown): void { }); } +function isRegexQuantifierAt(pattern: string, index: number): boolean { + const nextChar = pattern[index]; + if (nextChar === '*' || nextChar === '+' || nextChar === '?') { + return true; + } + + if (nextChar !== '{') { + return false; + } + + return BRACED_QUANTIFIER_PATTERN.test(pattern.slice(index)); +} + +/** + * Reject regex patterns with obvious ReDoS-prone constructs: + * - Nested quantifiers: (x+)+, (x*)+, (x+)*, (x?){n}, etc. + * - Star-height > 1 patterns + * + * This is a heuristic check, not a full regex analysis. + * It catches the most common catastrophic backtracking patterns. + */ +export function hasNestedQuantifiers(pattern: string): boolean { + invariant(typeof pattern === 'string', 'regex pattern must be a string'); + + const groupHasQuantifierStack: boolean[] = []; + let inCharacterClass = false; + + for (let index = 0; index < pattern.length; index += 1) { + const char = pattern[index]; + invariant(char !== undefined, 'regex pattern character must exist'); + + if (char === '\\') { + index += 1; + continue; + } + + if (char === '[') { + inCharacterClass = true; + continue; + } + + if (char === ']' && inCharacterClass) { + inCharacterClass = false; + continue; + } + + if (inCharacterClass) { + continue; + } + + if (char === '(') { + groupHasQuantifierStack.push(false); + continue; + } + + if (char === ')') { + const groupHasQuantifier = groupHasQuantifierStack.pop() ?? false; + const groupIsQuantified = isRegexQuantifierAt(pattern, index + 1); + if (groupHasQuantifier && groupIsQuantified) { + return true; + } + + const parentGroupIndex = groupHasQuantifierStack.length - 1; + if (parentGroupIndex >= 0 && (groupHasQuantifier || groupIsQuantified)) { + groupHasQuantifierStack[parentGroupIndex] = true; + } + + continue; + } + + const currentGroupIndex = groupHasQuantifierStack.length - 1; + if (currentGroupIndex < 0) { + continue; + } + + if (char === '*' || char === '+' || char === '?') { + const previousChar = pattern[index - 1]; + if (previousChar !== '(') { + groupHasQuantifierStack[currentGroupIndex] = true; + } + continue; + } + + if (char === '{' && isRegexQuantifierAt(pattern, index)) { + groupHasQuantifierStack[currentGroupIndex] = true; + } + } + + return false; +} + +export function safeRegexExec( + regex: RegExp, + text: string, +): RegExpExecArray | null { + const limitedText = + text.length > MAX_WAIT_FOR_RENDER_REGEX_TEXT_LENGTH + ? text.slice(0, MAX_WAIT_FOR_RENDER_REGEX_TEXT_LENGTH) + : text; + return regex.exec(limitedText); +} + export async function runHost(sessionId: string): Promise { invariant( typeof sessionId === 'string' && sessionId.length > 0, @@ -85,6 +218,18 @@ export async function runHost(sessionId: string): Promise { const eventLog = await EventLog.open(ePath); + const rendererManager = new HostRendererManager({ + sessionId, + sessionDir: sessDir, + backendFactory: (sid, profile) => new GhosttyWebBackend(sid, profile), + }); + + const loadReplayInput = () => { + const events = [...eventLog.getEvents()]; + const replayInput = buildReplayInput(sessionId, state.snapshot(), events); + return replayInput.targetSeq === -1 ? null : replayInput; + }; + let eventLogClosed = false; let ptyExitHandled = false; let ptyHasExited = false; @@ -144,6 +289,12 @@ export async function runHost(sessionId: string): Promise { eventLogClosed = true; } + try { + await rendererManager.dispose(); + } catch { + // best-effort cleanup + } + await rpcServer.close(); } } @@ -181,6 +332,152 @@ export async function runHost(sessionId: string): Promise { const handlers: Record = { inspect: () => Promise.resolve({ session: state.snapshot() }), + snapshot: async (params: unknown) => { + const { format: requestedFormat } = params as SnapshotParams; + + const format = requestedFormat ?? 'structured'; + + const profile = resolveProfile(DEFAULT_RENDER_PROFILE_NAME); + const replayInput = loadReplayInput(); + const backend = await rendererManager.getBackend(profile, replayInput); + const snapshot = await backend.snapshot(); + + invariant( + snapshot.sessionId === sessionId, + 'renderer snapshot sessionId must match host sessionId', + ); + + const snapshotResult = + format === 'structured' + ? { format: 'structured' as const, ...snapshot } + : { + format: 'text' as const, + sessionId: snapshot.sessionId, + capturedAtSeq: snapshot.capturedAtSeq, + cols: snapshot.cols, + rows: snapshot.rows, + cursorRow: snapshot.cursorRow, + cursorCol: snapshot.cursorCol, + text: snapshot.visibleLines.map((line) => line.text).join('\n'), + }; + + await ensureArtifactsDir(sessDir); + const filename = snapshotFilename(snapshot.capturedAtSeq, format); + const snapshotArtifactPath = artifactPath(sessDir, filename); + + await writeTextFileAtomic({ + path: snapshotArtifactPath, + pathLabel: 'snapshot artifact path', + contents: `${JSON.stringify(snapshotResult, null, 2)}\n`, + writeErrorMessage: `Failed to write snapshot artifact at ${snapshotArtifactPath}.`, + }); + + await appendArtifact( + sessDir, + createArtifactEntry({ + kind: 'snapshot', + filename, + sessionId: snapshot.sessionId, + capturedAtSeq: snapshot.capturedAtSeq, + metadata: { + format, + cols: snapshot.cols, + rows: snapshot.rows, + cursorRow: snapshot.cursorRow, + cursorCol: snapshot.cursorCol, + }, + }), + ); + + return snapshotResult; + }, + screenshot: async (params: unknown) => { + const { profile: requestedProfileName } = params as ScreenshotParams; + + const profile = (() => { + try { + return resolveProfile( + requestedProfileName ?? DEFAULT_RENDER_PROFILE_NAME, + ); + } catch (error) { + throw makeCliError(ERROR_CODES.INVALID_INPUT, { + message: + error instanceof Error + ? error.message + : 'Invalid render profile.', + ...(requestedProfileName === undefined + ? {} + : { details: { profile: requestedProfileName } }), + cause: error, + }); + } + })(); + + const replayInput = loadReplayInput(); + const backend = await rendererManager.getBackend(profile, replayInput); + await ensureArtifactsDir(sessDir); + const temporaryOutputPath = artifactPath( + sessDir, + `.tmp-screenshot-${ulid()}.png`, + ); + + try { + const result = await backend.screenshot(temporaryOutputPath); + + invariant( + result.sessionId === sessionId, + 'renderer screenshot sessionId must match host sessionId', + ); + invariant( + result.profileName === profile.name, + 'renderer screenshot profileName must match the requested profile', + ); + invariant( + result.artifactPath === temporaryOutputPath, + 'renderer screenshot path must match the requested output path', + ); + invariant( + result.pngSizeBytes > 0, + 'renderer screenshot pngSizeBytes must be positive', + ); + + const filename = screenshotFilename( + result.capturedAtSeq, + result.profileName, + ); + const finalArtifactPath = artifactPath(sessDir, filename); + + await rename(temporaryOutputPath, finalArtifactPath); + await appendArtifact( + sessDir, + createArtifactEntry({ + kind: 'screenshot', + filename, + sessionId: result.sessionId, + capturedAtSeq: result.capturedAtSeq, + metadata: { + profileName: result.profileName, + cols: result.cols, + rows: result.rows, + pngSizeBytes: result.pngSizeBytes, + }, + }), + ); + + return { + sessionId: result.sessionId, + capturedAtSeq: result.capturedAtSeq, + profileName: result.profileName, + cols: result.cols, + rows: result.rows, + artifactPath: finalArtifactPath, + pngSizeBytes: result.pngSizeBytes, + }; + } catch (error) { + await rm(temporaryOutputPath, { force: true }).catch(() => undefined); + throw error; + } + }, type: async (params: unknown) => { const { text } = params as TypeParams; @@ -411,6 +708,186 @@ export async function runHost(sessionId: string): Promise { }); }); }, + waitForRender: async (params: unknown) => { + const { text, regex, screenStableMs, timeoutMs } = + params as WaitForRenderParams; + + invariant( + text !== undefined || + regex !== undefined || + screenStableMs !== undefined, + 'waitForRender requires at least one of text, regex, or screenStableMs', + ); + invariant( + !(text !== undefined && regex !== undefined), + 'waitForRender text and regex filters are mutually exclusive', + ); + if (screenStableMs !== undefined) { + invariant( + Number.isInteger(screenStableMs) && screenStableMs > 0, + 'screenStableMs must be a positive integer', + ); + } + if (timeoutMs !== undefined) { + invariant( + Number.isInteger(timeoutMs) && timeoutMs > 0, + 'timeoutMs must be a positive integer', + ); + } + + let compiledRegex: RegExp | undefined; + if (regex !== undefined) { + invariant( + regex.length <= MAX_WAIT_FOR_RENDER_REGEX_LENGTH, + `regex pattern must not exceed ${String(MAX_WAIT_FOR_RENDER_REGEX_LENGTH)} characters`, + ); + if (hasNestedQuantifiers(regex)) { + throw makeCliError(ERROR_CODES.INVALID_INPUT, { + message: + 'Regex pattern contains nested quantifiers which may cause catastrophic backtracking. Simplify the pattern.', + }); + } + try { + compiledRegex = new RegExp(regex); + } catch (error) { + throw makeCliError(ERROR_CODES.INVALID_INPUT, { + message: `Invalid regex pattern: ${error instanceof Error ? error.message : String(error)}`, + cause: error, + }); + } + } + + const profile = resolveProfile(DEFAULT_RENDER_PROFILE_NAME); + const pollIntervalMs = 200; + let lastVisibleText: string | undefined; + let lastTextChangeAt = Date.now(); + let latestCapturedAtSeq = 0; + let clearWaitPoll: (() => void) | null = null; + + const pollCondition = new Promise((resolve) => { + let pollInFlight = false; + let consecutiveFailures = 0; + + const checkInterval = setInterval(() => { + if (pollInFlight) { + return; + } + + pollInFlight = true; + void (async () => { + try { + const replayInput = loadReplayInput(); + const backend = await rendererManager.getBackend( + profile, + replayInput, + ); + const visibleText = await backend.getVisibleText(); + const capturedAtSeq = replayInput?.targetSeq ?? 0; + latestCapturedAtSeq = capturedAtSeq; + consecutiveFailures = 0; + + const now = Date.now(); + if ( + lastVisibleText === undefined || + visibleText !== lastVisibleText + ) { + lastVisibleText = visibleText; + lastTextChangeAt = now; + } + + let textMatched = false; + let matchedText: string | undefined; + if (text !== undefined) { + if (visibleText.includes(text)) { + textMatched = true; + matchedText = text; + } + } else if (compiledRegex !== undefined) { + const match = safeRegexExec(compiledRegex, visibleText); + if (match !== null) { + textMatched = true; + matchedText = match[0]; + } + } else { + textMatched = true; + } + + let stableMatched = true; + if (screenStableMs !== undefined) { + stableMatched = now - lastTextChangeAt >= screenStableMs; + } + + if (textMatched && stableMatched) { + clearInterval(checkInterval); + resolve({ + matched: true, + timedOut: false, + ...(matchedText === undefined ? {} : { matchedText }), + capturedAtSeq, + }); + } + } catch (pollError) { + void pollError; + consecutiveFailures += 1; + if (consecutiveFailures >= MAX_CONSECUTIVE_POLL_FAILURES) { + clearInterval(checkInterval); + resolve({ + matched: false, + timedOut: true, + capturedAtSeq: latestCapturedAtSeq, + }); + return; + } + // Transient — retry on next poll. + } finally { + pollInFlight = false; + } + })(); + }, pollIntervalMs); + + clearWaitPoll = (): void => { + clearInterval(checkInterval); + }; + }); + + if (timeoutMs === undefined) { + return await pollCondition; + } + + return await new Promise((resolve) => { + let resolved = false; + const timeoutHandle = setTimeout(() => { + if (resolved) { + return; + } + resolved = true; + clearWaitPoll?.(); + + try { + const replayInput = loadReplayInput(); + latestCapturedAtSeq = replayInput?.targetSeq ?? 0; + } catch { + // Best-effort snapshot for timeout reporting. + } + + resolve({ + matched: false, + timedOut: true, + capturedAtSeq: latestCapturedAtSeq, + }); + }, timeoutMs); + + void pollCondition.then((result) => { + if (resolved) { + return; + } + resolved = true; + clearTimeout(timeoutHandle); + clearWaitPoll?.(); + resolve(result); + }); + }); + }, destroy: () => { startShutdown(); return Promise.resolve({}); diff --git a/src/host/renderer.ts b/src/host/renderer.ts new file mode 100644 index 0000000..c6726aa --- /dev/null +++ b/src/host/renderer.ts @@ -0,0 +1,200 @@ +import { mkdirSync } from 'node:fs'; +import { dirname, isAbsolute, relative, resolve, sep } from 'node:path'; + +import type { RendererBackend } from '../renderer/backend.js'; +import type { RenderProfileConfig, ReplayInput } from '../renderer/types.js'; +import { invariant } from '../util/assert.js'; + +interface HostRendererManagerOptions { + sessionId: string; + sessionDir: string; + backendFactory: ( + sessionId: string, + profile: RenderProfileConfig, + ) => RendererBackend; +} + +function assertNonEmptyString( + value: string, + label: string, +): asserts value is string { + invariant( + typeof value === 'string' && value.length > 0, + `${label} must be a non-empty string`, + ); +} + +function assertAbsolutePath(pathValue: string, label: string): void { + assertNonEmptyString(pathValue, label); + invariant(isAbsolute(pathValue), `${label} must be an absolute path`); +} + +export class HostRendererManager { + private readonly sessionId: string; + private readonly sessionDir: string; + private readonly backendFactory: HostRendererManagerOptions['backendFactory']; + + private currentBackend: RendererBackend | null = null; + private currentProfileName: string | null = null; + private bootPromise: Promise | null = null; + private lifecyclePromise: Promise = Promise.resolve(); + private screenshotsDirectoryCreated = false; + + constructor(options: HostRendererManagerOptions) { + assertNonEmptyString(options.sessionId, 'sessionId'); + assertAbsolutePath(options.sessionDir, 'sessionDir'); + invariant( + typeof options.backendFactory === 'function', + 'backendFactory must be a function', + ); + + this.sessionId = options.sessionId; + this.sessionDir = resolve(options.sessionDir); + this.backendFactory = options.backendFactory; + } + + async getBackend( + profile: RenderProfileConfig, + replayInput: ReplayInput | null, + ): Promise { + assertNonEmptyString(profile.name, 'profile name'); + + if (replayInput !== null) { + invariant( + replayInput.sessionId === this.sessionId, + 'replay input sessionId must match manager sessionId', + ); + } + + return this.runExclusive(async () => { + const backend = await this.ensureBackend(profile); + + if (replayInput !== null && replayInput.targetSeq >= 0) { + await backend.replayTo(replayInput); + } + + return backend; + }); + } + + screenshotPath(profileName: string): string { + assertNonEmptyString(profileName, 'profileName'); + invariant( + !profileName.includes('/') && !profileName.includes('\\'), + 'profileName must not contain path separators', + ); + + const screenshotsDir = resolve(this.sessionDir, 'screenshots'); + this.assertPathWithinSessionDir( + screenshotsDir, + 'screenshots directory must stay within the session directory', + ); + + if (!this.screenshotsDirectoryCreated) { + mkdirSync(screenshotsDir, { recursive: true }); + this.screenshotsDirectoryCreated = true; + } + + const outputPath = resolve( + screenshotsDir, + `${profileName}-${String(Date.now())}.png`, + ); + this.assertPathWithinSessionDir( + outputPath, + 'screenshot path must stay within the session directory', + ); + invariant( + dirname(outputPath) === screenshotsDir, + 'screenshot path must be created directly within the screenshots directory', + ); + + return outputPath; + } + + async dispose(): Promise { + await this.runExclusive(async () => { + await this.disposeCurrentBackend(); + }); + } + + private async ensureBackend( + profile: RenderProfileConfig, + ): Promise { + const requiresReplacement = + this.currentBackend === null || + this.currentProfileName !== profile.name || + !this.currentBackend.isBooted; + + if (requiresReplacement) { + await this.disposeCurrentBackend(); + + const backend = this.backendFactory(this.sessionId, profile); + + this.currentBackend = backend; + this.currentProfileName = profile.name; + } + + invariant(this.currentBackend !== null, 'current backend must exist'); + + if (!this.currentBackend.isBooted) { + await this.bootBackend(this.currentBackend); + } + + return this.currentBackend; + } + + private async bootBackend( + backend: RendererBackend, + ): Promise { + if (this.bootPromise === null) { + this.bootPromise = (async () => { + await backend.boot(); + return backend; + })().finally(() => { + this.bootPromise = null; + }); + } + + const bootedBackend = await this.bootPromise; + invariant( + bootedBackend === backend, + 'booted backend must match the requested backend', + ); + return bootedBackend; + } + + private async disposeCurrentBackend(): Promise { + const backend = this.currentBackend; + + this.currentBackend = null; + this.currentProfileName = null; + this.bootPromise = null; + + if (backend === null) { + return; + } + + await backend.dispose(); + } + + private assertPathWithinSessionDir(pathValue: string, message: string): void { + const relativePath = relative(this.sessionDir, resolve(pathValue)); + + invariant( + relativePath === '' || + (!relativePath.startsWith(`..${sep}`) && + relativePath !== '..' && + !isAbsolute(relativePath)), + message, + ); + } + + private runExclusive(operation: () => Promise): Promise { + const queuedOperation = this.lifecyclePromise.then(operation, operation); + this.lifecyclePromise = queuedOperation.then( + () => undefined, + () => undefined, + ); + return queuedOperation; + } +} diff --git a/src/host/replay.ts b/src/host/replay.ts new file mode 100644 index 0000000..dfd47c1 --- /dev/null +++ b/src/host/replay.ts @@ -0,0 +1,139 @@ +import { readFile, stat } from 'node:fs/promises'; + +import type { ReplayInput } from '../renderer/types.js'; +import { + EventRecordSchema, + SessionRecordSchema, + type EventRecord, + type SessionRecord, +} from '../protocol/schemas.js'; +import { invariant } from '../util/assert.js'; + +export const MAX_EVENT_LOG_SIZE = 50 * 1024 * 1024; + +function assertNonEmptyString(value: string, message: string): void { + invariant(value.length > 0, message); +} + +function parseEventRecord(event: unknown, index: number): EventRecord { + const parsedEvent = EventRecordSchema.safeParse(event); + invariant( + parsedEvent.success, + `replay event ${String(index)} must match EventRecordSchema`, + ); + return parsedEvent.data; +} + +function assertContiguousEventSequence(events: EventRecord[]): void { + if (events.length === 0) { + return; + } + + invariant(events[0]?.seq === 0, 'first replay event seq must be 0'); + + for (let index = 1; index < events.length; index += 1) { + const previous = events[index - 1]; + const current = events[index]; + + invariant(previous !== undefined, 'previous replay event must exist'); + invariant(current !== undefined, 'current replay event must exist'); + invariant( + current.seq === previous.seq + 1, + 'replay events must have contiguous seq values', + ); + } +} + +function parseEventLogLine(line: string, lineNumber: number): EventRecord { + let parsedLine: unknown; + try { + parsedLine = JSON.parse(line) as unknown; + } catch { + invariant(false, `event log line ${String(lineNumber)} must be valid JSON`); + } + + return parseEventRecord(parsedLine, lineNumber); +} + +export async function readEventLogRecords( + filePath: string, +): Promise { + assertNonEmptyString(filePath, 'filePath must be a non-empty string'); + + const fileStats = await stat(filePath); + invariant( + fileStats.size <= MAX_EVENT_LOG_SIZE, + `event log file exceeds 50 MB size limit (${String(fileStats.size)} bytes)`, + ); + + const content = await readFile(filePath, 'utf8'); + const lines = content + .split('\n') + .map((line) => line.trim()) + .filter((line) => line.length > 0); + const events = lines.map((line, index) => parseEventLogLine(line, index + 1)); + assertContiguousEventSequence(events); + return events; +} + +export function buildReplayInput( + sessionId: string, + manifest: SessionRecord, + events: EventRecord[], + targetSeq?: number, +): ReplayInput { + assertNonEmptyString(sessionId, 'sessionId must be a non-empty string'); + + const parsedManifest = SessionRecordSchema.safeParse(manifest); + invariant(parsedManifest.success, 'manifest must match SessionRecordSchema'); + invariant( + parsedManifest.data.sessionId.length > 0, + 'manifest sessionId must be a non-empty string', + ); + invariant( + parsedManifest.data.sessionId === sessionId, + 'sessionId must match manifest sessionId', + ); + invariant(parsedManifest.data.cols > 0, 'initial cols must be positive'); + invariant(parsedManifest.data.rows > 0, 'initial rows must be positive'); + + const validatedEvents = events.map((event, index) => + parseEventRecord(event, index), + ); + assertContiguousEventSequence(validatedEvents); + + let lastSeq = -1; + if (validatedEvents.length > 0) { + const lastEvent = validatedEvents.at(-1); + invariant(lastEvent !== undefined, 'last replay event must exist'); + lastSeq = lastEvent.seq; + } + + const resolvedTargetSeq = targetSeq ?? lastSeq; + + invariant( + Number.isInteger(resolvedTargetSeq), + 'targetSeq must be an integer', + ); + + if (validatedEvents.length === 0) { + invariant( + resolvedTargetSeq === -1, + 'targetSeq must be -1 when replay has no events', + ); + } else { + invariant(resolvedTargetSeq >= 0, 'targetSeq must be non-negative'); + invariant( + resolvedTargetSeq <= lastSeq, + 'targetSeq must not exceed the last event seq', + ); + } + + return { + sessionId, + initialCols: parsedManifest.data.cols, + initialRows: parsedManifest.data.rows, + events: validatedEvents, + targetSeq: resolvedTargetSeq, + }; +} diff --git a/src/protocol/errors.ts b/src/protocol/errors.ts index 82015b1..0d95e45 100644 --- a/src/protocol/errors.ts +++ b/src/protocol/errors.ts @@ -18,6 +18,7 @@ export const ERROR_CODES = { STORAGE_WRITE_ERROR: 'STORAGE_WRITE_ERROR', MANIFEST_VALIDATION_ERROR: 'MANIFEST_VALIDATION_ERROR', RPC_ERROR: 'RPC_ERROR', + PROTOCOL_ERROR: 'PROTOCOL_ERROR', INTERNAL_ERROR: 'INTERNAL_ERROR', } as const; @@ -39,6 +40,7 @@ export const DEFAULT_ERROR_MESSAGES: Record = { [ERROR_CODES.STORAGE_WRITE_ERROR]: 'Failed to write session storage.', [ERROR_CODES.MANIFEST_VALIDATION_ERROR]: 'Session manifest is invalid.', [ERROR_CODES.RPC_ERROR]: 'RPC request failed.', + [ERROR_CODES.PROTOCOL_ERROR]: 'Unexpected response from host.', [ERROR_CODES.INTERNAL_ERROR]: 'Internal error.', }; diff --git a/src/protocol/messages.ts b/src/protocol/messages.ts index e2e77ef..6bde7a1 100644 --- a/src/protocol/messages.ts +++ b/src/protocol/messages.ts @@ -1,6 +1,25 @@ import { z } from 'zod'; -import { SessionRecordSchema } from './schemas.js'; +import { + ScreenshotParamsSchema, + ScreenshotResultSchema, + SessionRecordSchema, + SnapshotParamsSchema, + SnapshotResultSchema, + WaitForRenderParamsSchema, + WaitForRenderResultSchema, + WaitResultSchema, +} from './schemas.js'; + +export { + ScreenshotParamsSchema, + ScreenshotResultSchema, + SnapshotParamsSchema, + SnapshotResultSchema, + WaitForRenderParamsSchema, + WaitForRenderResultSchema, + WaitResultSchema, +} from './schemas.js'; const EmptyObjectSchema = z.object({}).strict(); const NonEmptyStringSchema = z.string().min(1); @@ -57,6 +76,14 @@ export const InspectResultSchema = z .strict(); export type InspectResult = z.infer; +export type SnapshotParams = z.infer; + +export type SnapshotResult = z.infer; + +export type ScreenshotParams = z.infer; + +export type ScreenshotResult = z.infer; + export const TypeParamsSchema = z .object({ text: z.string().min(1), @@ -122,14 +149,12 @@ export const WaitParamsSchema = z .strict(); export type WaitParams = z.infer; -export const WaitResultSchema = z - .object({ - exitCode: z.number().int().optional(), - timedOut: z.boolean(), - }) - .strict(); export type WaitResult = z.infer; +export type WaitForRenderParams = z.infer; + +export type WaitForRenderResult = z.infer; + export const DestroyParamsSchema = z .object({ force: z.boolean().optional(), @@ -142,12 +167,15 @@ export type DestroyResult = z.infer; const RPC_METHODS = [ 'inspect', + 'snapshot', + 'screenshot', 'type', 'paste', 'sendKeys', 'resize', 'signal', 'wait', + 'waitForRender', 'destroy', ] as const; @@ -159,6 +187,14 @@ export const RpcMethodSchemas = { params: InspectParamsSchema, result: InspectResultSchema, }, + snapshot: { + params: SnapshotParamsSchema, + result: SnapshotResultSchema, + }, + screenshot: { + params: ScreenshotParamsSchema, + result: ScreenshotResultSchema, + }, type: { params: TypeParamsSchema, result: TypeResultSchema, @@ -183,6 +219,10 @@ export const RpcMethodSchemas = { params: WaitParamsSchema, result: WaitResultSchema, }, + waitForRender: { + params: WaitForRenderParamsSchema, + result: WaitForRenderResultSchema, + }, destroy: { params: DestroyParamsSchema, result: DestroyResultSchema, diff --git a/src/protocol/schemas.ts b/src/protocol/schemas.ts index 44488fe..0c8c6d6 100644 --- a/src/protocol/schemas.ts +++ b/src/protocol/schemas.ts @@ -1,5 +1,14 @@ import { z } from 'zod'; +const NonEmptyStringSchema = z.string().min(1); +const TextMatchSchema = z.string().min(1).max(1000); +const RegexPatternSchema = z.string().min(1).max(200); +const ProfileNameSchema = z.string().min(1).max(100); +const PositiveIntSchema = z.number().int().positive(); +const NonNegativeIntSchema = z.number().int().nonnegative(); +const IsoDatetimeSchema = z.iso.datetime(); +const SnapshotFormatSchema = z.enum(['structured', 'text']); + export const SessionStatusSchema = z.enum(['running', 'exiting', 'exited']); export type SessionStatus = z.infer; @@ -7,21 +16,74 @@ export const SessionRecordSchema = z .object({ version: z.literal(1), sessionId: z.string(), - createdAt: z.iso.datetime(), - updatedAt: z.iso.datetime(), + createdAt: IsoDatetimeSchema, + updatedAt: IsoDatetimeSchema, status: SessionStatusSchema, command: z.array(z.string()).min(1), cwd: z.string(), - cols: z.number().int().positive(), - rows: z.number().int().positive(), - hostPid: z.number().int().positive().nullable(), - childPid: z.number().int().positive().nullable(), + cols: PositiveIntSchema, + rows: PositiveIntSchema, + hostPid: PositiveIntSchema.nullable(), + childPid: PositiveIntSchema.nullable(), exitCode: z.number().int().nullable(), exitSignal: z.string().nullable(), }) .strict(); export type SessionRecord = z.infer; +export const OutputEventPayloadSchema = z + .object({ + data: z.string(), + }) + .strict(); +export type OutputEventPayload = z.infer; + +export const InputTextEventPayloadSchema = z + .object({ + data: z.string(), + }) + .strict(); +export type InputTextEventPayload = z.infer; + +export const InputPasteEventPayloadSchema = z + .object({ + data: z.string(), + }) + .strict(); +export type InputPasteEventPayload = z.infer< + typeof InputPasteEventPayloadSchema +>; + +export const InputKeysEventPayloadSchema = z + .object({ + keys: z.array(NonEmptyStringSchema).min(1), + }) + .strict(); +export type InputKeysEventPayload = z.infer; + +export const ResizeEventPayloadSchema = z + .object({ + cols: PositiveIntSchema, + rows: PositiveIntSchema, + }) + .strict(); +export type ResizeEventPayload = z.infer; + +export const SignalEventPayloadSchema = z + .object({ + signal: NonEmptyStringSchema, + }) + .strict(); +export type SignalEventPayload = z.infer; + +export const ExitEventPayloadSchema = z + .object({ + exitCode: z.number().int().nullable(), + exitSignal: z.string().nullable(), + }) + .strict(); +export type ExitEventPayload = z.infer; + export const EventTypeSchema = z.enum([ 'output', 'input_text', @@ -33,12 +95,195 @@ export const EventTypeSchema = z.enum([ ]); export type EventType = z.infer; -export const EventRecordSchema = z +const EventRecordBaseShape = { + seq: NonNegativeIntSchema, + ts: IsoDatetimeSchema, +} as const; + +export const OutputEventRecordSchema = z + .object({ + ...EventRecordBaseShape, + type: z.literal('output'), + payload: OutputEventPayloadSchema, + }) + .strict(); + +export const InputTextEventRecordSchema = z + .object({ + ...EventRecordBaseShape, + type: z.literal('input_text'), + payload: InputTextEventPayloadSchema, + }) + .strict(); + +export const InputPasteEventRecordSchema = z + .object({ + ...EventRecordBaseShape, + type: z.literal('input_paste'), + payload: InputPasteEventPayloadSchema, + }) + .strict(); + +export const InputKeysEventRecordSchema = z + .object({ + ...EventRecordBaseShape, + type: z.literal('input_keys'), + payload: InputKeysEventPayloadSchema, + }) + .strict(); + +export const ResizeEventRecordSchema = z .object({ - seq: z.number().int().nonnegative(), - ts: z.iso.datetime(), - type: EventTypeSchema, - payload: z.unknown(), + ...EventRecordBaseShape, + type: z.literal('resize'), + payload: ResizeEventPayloadSchema, }) .strict(); + +export const SignalEventRecordSchema = z + .object({ + ...EventRecordBaseShape, + type: z.literal('signal'), + payload: SignalEventPayloadSchema, + }) + .strict(); + +export const ExitEventRecordSchema = z + .object({ + ...EventRecordBaseShape, + type: z.literal('exit'), + payload: ExitEventPayloadSchema, + }) + .strict(); + +export const EventRecordSchema = z.discriminatedUnion('type', [ + OutputEventRecordSchema, + InputTextEventRecordSchema, + InputPasteEventRecordSchema, + InputKeysEventRecordSchema, + ResizeEventRecordSchema, + SignalEventRecordSchema, + ExitEventRecordSchema, +]); export type EventRecord = z.infer; + +export const VisibleLineSchema = z + .object({ + row: NonNegativeIntSchema, + text: z.string(), + }) + .strict(); +export type VisibleLine = z.infer; + +export const SnapshotParamsSchema = z + .object({ + format: SnapshotFormatSchema.optional(), + }) + .strict(); +export type SnapshotParams = z.infer; + +export const StructuredSnapshotResultSchema = z + .object({ + format: z.literal('structured'), + sessionId: NonEmptyStringSchema, + capturedAtSeq: NonNegativeIntSchema, + cols: PositiveIntSchema, + rows: PositiveIntSchema, + cursorRow: NonNegativeIntSchema, + cursorCol: NonNegativeIntSchema, + isAltScreen: z.boolean(), + visibleLines: z.array(VisibleLineSchema), + }) + .strict(); +export type StructuredSnapshotResult = z.infer< + typeof StructuredSnapshotResultSchema +>; + +export const TextSnapshotResultSchema = z + .object({ + format: z.literal('text'), + sessionId: NonEmptyStringSchema, + capturedAtSeq: NonNegativeIntSchema, + cols: PositiveIntSchema, + rows: PositiveIntSchema, + cursorRow: NonNegativeIntSchema, + cursorCol: NonNegativeIntSchema, + text: z.string(), + }) + .strict(); +export type TextSnapshotResult = z.infer; + +export const SnapshotResultSchema = z.discriminatedUnion('format', [ + StructuredSnapshotResultSchema, + TextSnapshotResultSchema, +]); +export type SnapshotResult = z.infer; + +export const ScreenshotParamsSchema = z + .object({ + profile: ProfileNameSchema.optional(), + }) + .strict(); +export type ScreenshotParams = z.infer; + +export const ScreenshotResultSchema = z + .object({ + sessionId: NonEmptyStringSchema, + capturedAtSeq: NonNegativeIntSchema, + profileName: NonEmptyStringSchema, + cols: PositiveIntSchema, + rows: PositiveIntSchema, + artifactPath: NonEmptyStringSchema, + pngSizeBytes: PositiveIntSchema, + }) + .strict(); +export type ScreenshotResult = z.infer; + +export const WaitForRenderParamsSchema = z + .object({ + text: TextMatchSchema.optional(), + regex: RegexPatternSchema.optional(), + screenStableMs: PositiveIntSchema.optional(), + timeoutMs: PositiveIntSchema.optional(), + }) + .strict() + .superRefine((value, ctx) => { + const hasText = value.text !== undefined; + const hasRegex = value.regex !== undefined; + const hasScreenStableMs = value.screenStableMs !== undefined; + + if (!hasText && !hasRegex && !hasScreenStableMs) { + ctx.addIssue({ + code: 'custom', + message: + 'At least one of text, regex, or screenStableMs must be provided.', + }); + } + + if (hasText && hasRegex) { + ctx.addIssue({ + code: 'custom', + message: 'text and regex are mutually exclusive.', + path: ['regex'], + }); + } + }); +export type WaitForRenderParams = z.infer; + +export const WaitResultSchema = z + .object({ + exitCode: z.number().int().optional(), + timedOut: z.boolean(), + }) + .strict(); +export type WaitResult = z.infer; + +export const WaitForRenderResultSchema = z + .object({ + matched: z.boolean(), + timedOut: z.boolean(), + matchedText: z.string().optional(), + capturedAtSeq: NonNegativeIntSchema, + }) + .strict(); +export type WaitForRenderResult = z.infer; diff --git a/src/renderer/backend.ts b/src/renderer/backend.ts new file mode 100644 index 0000000..5115297 --- /dev/null +++ b/src/renderer/backend.ts @@ -0,0 +1,29 @@ +import type { + ReplayInput, + ReplayState, + ScreenshotResult, + SemanticSnapshot, +} from './types.js'; + +export interface RendererBackend { + /** Boot the renderer (lazy, idempotent). */ + boot(): Promise; + + /** Apply replay events up to target sequence. */ + replayTo(input: ReplayInput): Promise; + + /** Extract semantic snapshot of current visible state. */ + snapshot(): Promise; + + /** Capture a screenshot as PNG. */ + screenshot(outputPath: string): Promise; + + /** Get current visible text (for wait operations). */ + getVisibleText(): Promise; + + /** Dispose the renderer and release resources. */ + dispose(): Promise; + + /** Whether the renderer is currently booted. */ + readonly isBooted: boolean; +} diff --git a/src/renderer/ghosttyWeb/backend.ts b/src/renderer/ghosttyWeb/backend.ts new file mode 100644 index 0000000..9c9b2c8 --- /dev/null +++ b/src/renderer/ghosttyWeb/backend.ts @@ -0,0 +1,1397 @@ +import { createRequire } from 'node:module'; +import { + createServer, + type IncomingMessage, + type Server, + type ServerResponse, +} from 'node:http'; +import { readFile, readdir, stat } from 'node:fs/promises'; +import { dirname, isAbsolute, join, resolve } from 'node:path'; + +import { + chromium, + type Browser, + type BrowserContext, + type Page, +} from 'playwright'; + +import { invariant, assertString, unreachable } from '../../util/assert.js'; +import type { RendererBackend } from '../backend.js'; +import type { + RenderProfileConfig, + ReplayInput, + ReplayState, + ScreenshotResult, + SemanticSnapshot, + VisibleLine, +} from '../types.js'; + +interface GhosttyHarnessVisibleLine { + row: number; + text: string; +} + +interface GhosttyHarnessSnapshot { + cols: number; + rows: number; + cursorRow: number; + cursorCol: number; + isAltScreen: boolean; + visibleLines: GhosttyHarnessVisibleLine[]; +} + +interface GhosttyRequestAsset { + body: Buffer; + contentType: string; +} + +interface GhosttyServedAsset extends GhosttyRequestAsset { + contentSecurityPolicy?: string; +} + +interface GhosttyBrowserBridge { + isReady?: () => boolean; + write?: (data: string) => Promise | void; + resize?: (cols: number, rows: number) => Promise | void; + getSnapshot?: () => GhosttyHarnessSnapshot; + getVisibleText?: () => string; +} + +interface GhosttyBrowserGlobal { + __agentTerminal?: GhosttyBrowserBridge; + document?: { + body?: { + dataset?: Record; + }; + }; +} + +const DEFAULT_PAGE_VIEWPORT = Object.freeze({ + height: 768, + width: 1024, +}); +const GHOSTTY_JAVASCRIPT_CONTENT_TYPE = 'text/javascript; charset=utf-8'; +/** + * The embedded ghostty-web harness currently needs a broader CSP than we would + * prefer. `unsafe-inline` is required because the harness bootstraps + * ghostty-web with an inline module script, and `unsafe-eval` is required by + * the ghostty-web WASM module's dynamic code path. Current browsers do not make + * `wasm-unsafe-eval` alone sufficient for this setup, so we keep both + * directives and constrain the risk by serving the renderer only on the local + * loopback interface; this harness is localhost-only infrastructure, not a + * user-facing web surface. + */ +const HARNESS_CONTENT_SECURITY_POLICY = [ + "default-src 'none'", + "script-src 'self' 'unsafe-inline' 'unsafe-eval' 'wasm-unsafe-eval'", + "style-src 'self' 'unsafe-inline'", + "font-src 'self'", + "img-src 'self' data:", + "connect-src 'self' data: blob:", +].join('; '); +const HTML_CONTENT_TYPE = 'text/html; charset=utf-8'; +const WASM_CONTENT_TYPE = 'application/wasm'; + +const MAX_REPLAY_BATCH_SIZE = 1000; +const RAF_TIMEOUT_MS = 5_000; + +const EMBEDDED_HARNESS_HTML = ` + + + + + agent-terminal ghostty-web harness + + + +
+
+
+ + + +`; + +let servedAssetsPromise: Promise< + ReadonlyMap +> | null = null; + +function assertNonNegativeInteger( + value: unknown, + message: string, +): asserts value is number { + invariant( + typeof value === 'number' && Number.isInteger(value) && value >= 0, + message, + ); +} + +function assertPositiveInteger( + value: unknown, + message: string, +): asserts value is number { + invariant( + typeof value === 'number' && Number.isInteger(value) && value > 0, + message, + ); +} + +function assertPositiveNumber( + value: unknown, + message: string, +): asserts value is number { + invariant( + typeof value === 'number' && Number.isFinite(value) && value > 0, + message, + ); +} + +function assertHexColor( + value: unknown, + message: string, +): asserts value is string { + assertString(value, message); + invariant(/^#[0-9a-fA-F]{6}$/u.test(value), message); +} + +function normalizeError(error: unknown, prefix: string): Error { + if (error instanceof Error) { + return new Error(`${prefix}: ${error.message}`, { cause: error }); + } + + return new Error(`${prefix}: ${String(error)}`); +} + +async function closeServer(server: Server): Promise { + if (!server.listening) { + return; + } + + await new Promise((resolvePromise, rejectPromise) => { + server.close((error) => { + if (error === undefined) { + resolvePromise(); + return; + } + + rejectPromise(error); + }); + }); +} + +async function loadHarnessHtml(): Promise { + try { + return await readFile(new URL('./harness.html', import.meta.url), 'utf8'); + } catch { + // The build emits TypeScript output only, so dist/ does not include harness.html. + // Fall back to the embedded copy so compiled builds can still serve the harness. + return EMBEDDED_HARNESS_HTML; + } +} + +async function loadServedAssets(): Promise< + ReadonlyMap +> { + const require = createRequire(import.meta.url); + const ghosttyRequireEntry = require.resolve('ghostty-web'); + const ghosttyDistDirectory = dirname(ghosttyRequireEntry); + const ghosttyPackageDirectory = resolve(ghosttyDistDirectory, '..'); + const ghosttyModulePath = join( + ghosttyPackageDirectory, + 'dist', + 'ghostty-web.js', + ); + const ghosttyWasmPath = join( + ghosttyPackageDirectory, + 'dist', + 'ghostty-vt.wasm', + ); + const ghosttyDistEntries = await readdir( + join(ghosttyPackageDirectory, 'dist'), + ); + const browserExternalEntries = ghosttyDistEntries.filter( + (entryName) => + entryName.startsWith('__vite-browser-external-') && + entryName.endsWith('.js'), + ); + + invariant( + browserExternalEntries.length === 1, + 'expected exactly one ghostty-web browser external helper, found ' + + String(browserExternalEntries.length), + ); + + const [browserExternalEntry] = browserExternalEntries; + invariant( + browserExternalEntry !== undefined, + 'ghostty-web browser external helper must be present', + ); + + const browserExternalPath = join( + ghosttyPackageDirectory, + 'dist', + browserExternalEntry, + ); + + const harnessHtml = await loadHarnessHtml(); + const assetEntries = new Map(); + + const htmlAsset: GhosttyServedAsset = { + body: Buffer.from(harnessHtml, 'utf8'), + contentSecurityPolicy: HARNESS_CONTENT_SECURITY_POLICY, + contentType: HTML_CONTENT_TYPE, + }; + assetEntries.set('/', htmlAsset); + assetEntries.set('/harness.html', htmlAsset); + + const packageAssetEntries: ReadonlyArray = + [ + [ + '/assets/ghostty-web.js', + ghosttyModulePath, + GHOSTTY_JAVASCRIPT_CONTENT_TYPE, + ], + [ + '/assets/' + browserExternalEntry, + browserExternalPath, + GHOSTTY_JAVASCRIPT_CONTENT_TYPE, + ], + ['/assets/ghostty-vt.wasm', ghosttyWasmPath, WASM_CONTENT_TYPE], + ['/ghostty-vt.wasm', ghosttyWasmPath, WASM_CONTENT_TYPE], + ]; + + for (const [routePath, filePath, contentType] of packageAssetEntries) { + const assetFile = await readFile(filePath); + invariant(assetFile.byteLength > 0, `${routePath} asset must not be empty`); + assetEntries.set(routePath, { + body: assetFile, + contentType, + }); + } + + return assetEntries; +} + +async function getServedAssets(): Promise< + ReadonlyMap +> { + servedAssetsPromise ??= loadServedAssets(); + return servedAssetsPromise; +} + +function validateHarnessSnapshot(snapshot: unknown): GhosttyHarnessSnapshot { + invariant( + snapshot !== null && typeof snapshot === 'object', + 'ghostty-web snapshot must be an object', + ); + + const candidate = snapshot as { + cols?: unknown; + rows?: unknown; + cursorRow?: unknown; + cursorCol?: unknown; + isAltScreen?: unknown; + visibleLines?: unknown; + }; + + assertPositiveInteger( + candidate.cols, + 'snapshot cols must be a positive integer', + ); + assertPositiveInteger( + candidate.rows, + 'snapshot rows must be a positive integer', + ); + assertNonNegativeInteger( + candidate.cursorRow, + 'snapshot cursorRow must be a non-negative integer', + ); + assertNonNegativeInteger( + candidate.cursorCol, + 'snapshot cursorCol must be a non-negative integer', + ); + invariant( + candidate.cursorRow < candidate.rows, + 'snapshot cursorRow must be within the terminal height', + ); + invariant( + candidate.cursorCol < candidate.cols, + 'snapshot cursorCol must be within the terminal width', + ); + invariant( + typeof candidate.isAltScreen === 'boolean', + 'snapshot isAltScreen must be a boolean', + ); + invariant( + Array.isArray(candidate.visibleLines), + 'snapshot visibleLines must be an array', + ); + invariant( + candidate.visibleLines.length <= candidate.rows, + 'snapshot visibleLines length must not exceed the viewport height', + ); + + const visibleLines: VisibleLine[] = []; + let previousRow = -1; + for (const [index, lineValue] of candidate.visibleLines.entries()) { + const visibleLineIndex = String(index); + invariant( + lineValue !== null && typeof lineValue === 'object', + `snapshot visible line ${visibleLineIndex} must be an object`, + ); + + const lineCandidate = lineValue as { + row?: unknown; + text?: unknown; + }; + assertNonNegativeInteger( + lineCandidate.row, + `snapshot visible line ${visibleLineIndex} row must be a non-negative integer`, + ); + assertString( + lineCandidate.text, + `snapshot visible line ${visibleLineIndex} text must be a string`, + ); + invariant( + lineCandidate.row < candidate.rows, + `snapshot visible line ${visibleLineIndex} row must be within the viewport`, + ); + invariant( + lineCandidate.row > previousRow, + `snapshot visible line ${visibleLineIndex} rows must be strictly increasing`, + ); + previousRow = lineCandidate.row; + visibleLines.push({ + row: lineCandidate.row, + text: lineCandidate.text, + }); + } + + return { + cols: candidate.cols, + rows: candidate.rows, + cursorRow: candidate.cursorRow, + cursorCol: candidate.cursorCol, + isAltScreen: candidate.isAltScreen, + visibleLines, + }; +} + +export class GhosttyWebBackend implements RendererBackend { + public isBooted = false; + + private readonly profile: RenderProfileConfig; + private readonly sessionId: string; + private bootPromise: Promise | null = null; + private browser: Browser | null = null; + private browserContext: BrowserContext | null = null; + private currentCols: number | null = null; + private currentRows: number | null = null; + private disposePromise: Promise | null = null; + private failureReason: Error | null = null; + private initialReplayCols: number | null = null; + private initialReplayRows: number | null = null; + private lastAppliedSeq = -1; + private page: Page | null = null; + private server: Server | null = null; + private serverOrigin: string | null = null; + + public constructor(sessionId: string, profile: RenderProfileConfig) { + invariant(sessionId.length > 0, 'sessionId must be a non-empty string'); + invariant( + profile.name.length > 0, + 'profile.name must be a non-empty string', + ); + invariant( + profile.fontFamily.length > 0, + 'profile.fontFamily must be a non-empty string', + ); + assertPositiveNumber( + profile.fontSize, + 'profile.fontSize must be a positive number', + ); + assertHexColor( + profile.backgroundColor, + 'profile.backgroundColor must be a hex color', + ); + assertHexColor( + profile.foregroundColor, + 'profile.foregroundColor must be a hex color', + ); + + this.sessionId = sessionId; + this.profile = Object.freeze({ ...profile }); + } + + public async boot(): Promise { + if (this.isBooted) { + return; + } + + if (this.bootPromise !== null) { + await this.bootPromise; + return; + } + + if (this.disposePromise !== null) { + await this.disposePromise; + } + + this.failureReason = null; + this.bootPromise = this.bootInternal(); + await this.bootPromise; + } + + public async replayTo(input: ReplayInput): Promise { + const page = this.requireOperationalPage('replayTo()'); + + invariant( + input.sessionId === this.sessionId, + `replay input session ${input.sessionId} does not match backend session ${this.sessionId}`, + ); + assertPositiveInteger( + input.initialCols, + 'replay input initialCols must be a positive integer', + ); + assertPositiveInteger( + input.initialRows, + 'replay input initialRows must be a positive integer', + ); + assertNonNegativeInteger( + input.targetSeq, + 'replay input targetSeq must be a non-negative integer', + ); + invariant( + input.targetSeq >= this.lastAppliedSeq, + 'stateful GhosttyWebBackend cannot rewind from seq ' + + String(this.lastAppliedSeq) + + ' to ' + + String(input.targetSeq), + ); + + if (this.initialReplayCols === null || this.initialReplayRows === null) { + await this.resizeBridge(page, input.initialCols, input.initialRows); + this.initialReplayCols = input.initialCols; + this.initialReplayRows = input.initialRows; + this.currentCols = input.initialCols; + this.currentRows = input.initialRows; + } else { + invariant( + this.initialReplayCols === input.initialCols && + this.initialReplayRows === input.initialRows, + 'replay input initial dimensions changed after the first replay', + ); + } + + let previousEventSeq = -1; + let highestProcessedSeq = this.lastAppliedSeq; + let pendingOutputChunks: string[] = []; + + const flushOutputBatch = async (): Promise => { + if (pendingOutputChunks.length === 0) { + return; + } + + await this.flushOutputBatch(page, pendingOutputChunks); + pendingOutputChunks = []; + }; + + for (const event of input.events) { + assertNonNegativeInteger( + event.seq, + 'replay event seq must be a non-negative integer', + ); + invariant( + event.seq > previousEventSeq, + 'replay events must be ordered by strictly increasing seq values', + ); + previousEventSeq = event.seq; + + if (event.seq <= this.lastAppliedSeq) { + continue; + } + + if (event.seq > input.targetSeq) { + await flushOutputBatch(); + break; + } + + switch (event.type) { + case 'output': { + pendingOutputChunks.push(event.payload.data); + break; + } + case 'resize': { + await flushOutputBatch(); + assertPositiveInteger( + event.payload.cols, + 'resize event cols must be a positive integer', + ); + assertPositiveInteger( + event.payload.rows, + 'resize event rows must be a positive integer', + ); + await this.resizeBridge(page, event.payload.cols, event.payload.rows); + this.currentCols = event.payload.cols; + this.currentRows = event.payload.rows; + break; + } + case 'input_text': + case 'input_paste': + case 'input_keys': + case 'signal': + case 'exit': { + await flushOutputBatch(); + break; + } + default: { + unreachable(event, 'unsupported replay event type'); + } + } + + highestProcessedSeq = event.seq; + } + + await flushOutputBatch(); + + if (highestProcessedSeq < 0) { + highestProcessedSeq = input.targetSeq; + } + + this.lastAppliedSeq = highestProcessedSeq; + + const snapshot = await this.readHarnessSnapshot(page); + this.currentCols = snapshot.cols; + this.currentRows = snapshot.rows; + + return { + lastSeq: this.lastAppliedSeq, + cols: snapshot.cols, + rows: snapshot.rows, + cursorRow: snapshot.cursorRow, + cursorCol: snapshot.cursorCol, + }; + } + + public async snapshot(): Promise { + const page = this.requireOperationalPage('snapshot()'); + invariant( + this.lastAppliedSeq >= 0, + 'snapshot() requires replayTo() to advance to a non-negative sequence first', + ); + + const snapshot = await this.readHarnessSnapshot(page); + this.currentCols = snapshot.cols; + this.currentRows = snapshot.rows; + + return { + sessionId: this.sessionId, + capturedAtSeq: this.lastAppliedSeq, + cols: snapshot.cols, + rows: snapshot.rows, + cursorRow: snapshot.cursorRow, + cursorCol: snapshot.cursorCol, + isAltScreen: snapshot.isAltScreen, + visibleLines: snapshot.visibleLines, + }; + } + + public async screenshot(outputPath: string): Promise { + const page = this.requireOperationalPage('screenshot()'); + invariant( + this.lastAppliedSeq >= 0, + 'screenshot() requires replayTo() to advance to a non-negative sequence first', + ); + invariant( + outputPath.length > 0, + 'screenshot outputPath must be a non-empty string', + ); + invariant( + isAbsolute(outputPath), + 'screenshot outputPath must be an absolute path', + ); + + const outputDirectory = dirname(outputPath); + const outputDirectoryStat = await stat(outputDirectory); + invariant( + outputDirectoryStat.isDirectory(), + 'screenshot output directory must exist', + ); + invariant( + this.currentCols !== null && this.currentRows !== null, + 'screenshot() requires known terminal dimensions', + ); + + await this.waitForScreenshotPaint(page); + + await page.locator('#terminal').screenshot({ + animations: 'disabled', + caret: 'hide', + path: outputPath, + type: 'png', + }); + + const screenshotFile = await stat(outputPath); + assertPositiveInteger( + screenshotFile.size, + 'screenshot output PNG must be non-empty', + ); + + return { + sessionId: this.sessionId, + capturedAtSeq: this.lastAppliedSeq, + profileName: this.profile.name, + cols: this.currentCols, + rows: this.currentRows, + artifactPath: outputPath, + pngSizeBytes: screenshotFile.size, + }; + } + + public async getVisibleText(): Promise { + const page = this.requireOperationalPage('getVisibleText()'); + + const visibleText = await page.evaluate(() => { + const bridge = (globalThis as GhosttyBrowserGlobal).__agentTerminal; + if (bridge === undefined || typeof bridge.getVisibleText !== 'function') { + throw new Error('ghostty-web bridge getVisibleText() is unavailable'); + } + + return bridge.getVisibleText(); + }); + + assertString(visibleText, 'ghostty-web visible text must be a string'); + return visibleText; + } + + public async dispose(): Promise { + if (this.disposePromise !== null) { + await this.disposePromise; + return; + } + + this.disposePromise = this.disposeInternal(); + await this.disposePromise; + } + + private async bootInternal(): Promise { + try { + const servedAssets = await getServedAssets(); + const { origin, server } = await this.startServer(servedAssets); + this.server = server; + this.serverOrigin = origin; + + this.browser = await chromium.launch({ + headless: true, + }); + this.browser.on('disconnected', () => { + this.recordUnexpectedFailure( + new Error('ghostty-web browser disconnected unexpectedly'), + ); + }); + + this.browserContext = await this.browser.newContext({ + deviceScaleFactor: 1, + viewport: DEFAULT_PAGE_VIEWPORT, + }); + await this.browserContext.route('**/*', async (route) => { + if (this.isAllowedBrowserRequest(route.request().url())) { + await route.continue(); + return; + } + + await route.abort('blockedbyclient'); + }); + + this.page = await this.browserContext.newPage(); + this.page.on('close', () => { + if (this.disposePromise !== null) { + return; + } + + this.recordUnexpectedFailure( + new Error('ghostty-web page closed unexpectedly'), + ); + }); + this.page.on('crash', () => { + this.recordUnexpectedFailure(new Error('ghostty-web page crashed')); + }); + this.page.on('pageerror', (error) => { + this.recordUnexpectedFailure( + normalizeError(error, 'ghostty-web page error'), + ); + }); + + await this.page.goto(this.buildHarnessUrl(origin), { + waitUntil: 'domcontentloaded', + }); + await this.page.waitForFunction( + () => { + const bridge = (globalThis as GhosttyBrowserGlobal).__agentTerminal; + return ( + bridge !== undefined && + typeof bridge.isReady === 'function' && + bridge.isReady() + ); + }, + undefined, + { timeout: 30_000 }, + ); + + const bridgeReady = await this.page.evaluate(() => { + const bridge = (globalThis as GhosttyBrowserGlobal).__agentTerminal; + return ( + bridge !== undefined && + typeof bridge.isReady === 'function' && + bridge.isReady() + ); + }); + invariant(bridgeReady, 'ghostty-web harness did not report ready'); + this.isBooted = true; + } catch (error) { + const page = this.page; + const pageError = + page === null ? null : await this.readHarnessErrorMessage(page); + const bootError = normalizeError( + pageError === null ? error : new Error(pageError), + 'failed to boot GhosttyWebBackend', + ); + this.failureReason = bootError; + await this.cleanupHandles(); + this.bootPromise = null; + throw bootError; + } + } + + private buildHarnessUrl(origin: string): string { + const searchParams = new URLSearchParams({ + profile: JSON.stringify(this.profile), + }); + return `${origin}/harness.html?${searchParams.toString()}`; + } + + private async cleanupHandles(): Promise { + const page = this.page; + const browserContext = this.browserContext; + const browser = this.browser; + const server = this.server; + + this.page = null; + this.browserContext = null; + this.browser = null; + this.server = null; + this.serverOrigin = null; + this.isBooted = false; + + if (page !== null) { + try { + if (!page.isClosed()) { + await page.close(); + } + } catch { + // Keep unwinding remaining resources even if a close operation fails. + } + } + + if (browserContext !== null) { + try { + await browserContext.close(); + } catch { + // Keep unwinding remaining resources even if a close operation fails. + } + } + + if (browser !== null) { + try { + await browser.close(); + } catch { + // Keep unwinding remaining resources even if a close operation fails. + } + } + + if (server !== null) { + try { + await closeServer(server); + } catch { + // Keep unwinding remaining resources even if a close operation fails. + } + } + } + + private async disposeInternal(): Promise { + try { + await this.cleanupHandles(); + } finally { + this.bootPromise = null; + this.currentCols = null; + this.currentRows = null; + this.disposePromise = null; + this.failureReason = null; + this.initialReplayCols = null; + this.initialReplayRows = null; + this.isBooted = false; + this.lastAppliedSeq = -1; + } + } + + private isAllowedBrowserRequest(requestUrl: string): boolean { + const serverOrigin = this.serverOrigin; + if (serverOrigin === null) { + return false; + } + + const parsedUrl = new URL(requestUrl); + if (parsedUrl.protocol === 'data:' || parsedUrl.protocol === 'blob:') { + return true; + } + + return parsedUrl.origin === serverOrigin; + } + + private async readHarnessErrorMessage(page: Page): Promise { + try { + const harnessError = await page.evaluate(() => { + const bodyDataset = (globalThis as GhosttyBrowserGlobal).document?.body + ?.dataset; + const errorMessage = bodyDataset?.error; + return typeof errorMessage === 'string' && errorMessage.length > 0 + ? errorMessage + : null; + }); + + return harnessError; + } catch { + return null; + } + } + + private async readHarnessSnapshot( + page: Page, + ): Promise { + const snapshot = await page.evaluate(() => { + const bridge = (globalThis as GhosttyBrowserGlobal).__agentTerminal; + if (bridge === undefined || typeof bridge.getSnapshot !== 'function') { + throw new Error('ghostty-web bridge getSnapshot() is unavailable'); + } + + return bridge.getSnapshot(); + }); + + const validatedSnapshot = validateHarnessSnapshot(snapshot); + invariant( + validatedSnapshot.visibleLines.length <= validatedSnapshot.rows, + 'snapshot visible line count must not exceed the viewport rows', + ); + invariant( + validatedSnapshot.cursorRow < validatedSnapshot.rows, + 'snapshot cursorRow must be within the viewport height', + ); + invariant( + validatedSnapshot.cursorCol < validatedSnapshot.cols, + 'snapshot cursorCol must be within the viewport width', + ); + + return validatedSnapshot; + } + + private recordUnexpectedFailure(error: Error): void { + if (this.disposePromise !== null) { + return; + } + + this.failureReason = error; + this.isBooted = false; + this.bootPromise = null; + } + + private requireOperationalPage(methodName: string): Page { + if (this.failureReason !== null) { + invariant( + false, + `${methodName} cannot continue after renderer failure: ${this.failureReason.message}`, + ); + } + + invariant( + this.isBooted, + `${methodName} requires a booted GhosttyWebBackend`, + ); + invariant( + this.page !== null, + `${methodName} requires an active Playwright page`, + ); + invariant( + !this.page.isClosed(), + `${methodName} requires an open Playwright page`, + ); + + return this.page; + } + + private async resizeBridge( + page: Page, + cols: number, + rows: number, + ): Promise { + assertPositiveInteger( + cols, + 'bridge resize cols must be a positive integer', + ); + assertPositiveInteger( + rows, + 'bridge resize rows must be a positive integer', + ); + + await page.evaluate( + async ([nextCols, nextRows]) => { + const bridge = (globalThis as GhosttyBrowserGlobal).__agentTerminal; + if (bridge === undefined || typeof bridge.resize !== 'function') { + throw new Error('ghostty-web bridge resize() is unavailable'); + } + + await bridge.resize(nextCols, nextRows); + }, + [cols, rows] as const, + ); + } + + private async waitForScreenshotPaint(page: Page): Promise { + await Promise.race([ + page.evaluate(() => { + const requestNextFrame = ( + globalThis as unknown as { + requestAnimationFrame: (callback: () => void) => number; + } + ).requestAnimationFrame; + return new Promise((resolve) => { + requestNextFrame(() => { + requestNextFrame(() => { + resolve(); + }); + }); + }); + }), + new Promise((_, reject) => { + setTimeout(() => { + reject(new Error('Screenshot paint wait timed out after 5s')); + }, RAF_TIMEOUT_MS); + }), + ]); + } + + private async flushOutputBatch( + page: Page, + dataChunks: string[], + ): Promise { + invariant( + dataChunks.length > 0, + 'flushOutputBatch requires at least one data chunk', + ); + + for ( + let batchStart = 0; + batchStart < dataChunks.length; + batchStart += MAX_REPLAY_BATCH_SIZE + ) { + const batch = dataChunks.slice( + batchStart, + batchStart + MAX_REPLAY_BATCH_SIZE, + ); + invariant(batch.length > 0, 'flushOutputBatch batch must not be empty'); + invariant( + batch.length <= MAX_REPLAY_BATCH_SIZE, + 'flushOutputBatch batch size must respect MAX_REPLAY_BATCH_SIZE', + ); + await this.writeBatchBridge(page, batch); + } + } + + private async startServer( + servedAssets: ReadonlyMap, + ): Promise<{ + origin: string; + server: Server; + }> { + const server = createServer((request, response) => { + this.respondToRequest(servedAssets, request, response); + }); + + await new Promise((resolvePromise, rejectPromise) => { + server.once('error', rejectPromise); + server.listen(0, '127.0.0.1', () => { + server.off('error', rejectPromise); + resolvePromise(); + }); + }); + + const address = server.address(); + invariant( + address !== null && typeof address === 'object', + 'ghostty-web server must expose a TCP address', + ); + assertPositiveInteger( + address.port, + 'ghostty-web server port must be positive', + ); + + return { + origin: 'http://127.0.0.1:' + String(address.port), + server, + }; + } + + private respondToRequest( + servedAssets: ReadonlyMap, + request: IncomingMessage, + response: ServerResponse, + ): void { + const requestMethod = request.method ?? 'GET'; + if (requestMethod !== 'GET' && requestMethod !== 'HEAD') { + response.writeHead(405, { + 'content-type': 'text/plain; charset=utf-8', + }); + response.end('Method Not Allowed'); + return; + } + + const requestUrl = new URL(request.url ?? '/', 'http://127.0.0.1'); + const asset = servedAssets.get(requestUrl.pathname); + if (asset === undefined) { + response.writeHead(404, { + 'content-type': 'text/plain; charset=utf-8', + }); + response.end('Not Found'); + return; + } + + const headers: Record = { + 'cache-control': 'no-store', + 'content-length': asset.body.byteLength, + 'content-type': asset.contentType, + 'x-content-type-options': 'nosniff', + }; + if (asset.contentSecurityPolicy !== undefined) { + headers['content-security-policy'] = asset.contentSecurityPolicy; + } + + response.writeHead(200, headers); + if (requestMethod === 'HEAD') { + response.end(); + return; + } + + response.end(asset.body); + } + + private async writeBatchBridge( + page: Page, + dataChunks: string[], + ): Promise { + invariant( + dataChunks.length > 0, + 'writeBatchBridge requires at least one data chunk', + ); + invariant( + dataChunks.length <= MAX_REPLAY_BATCH_SIZE, + 'writeBatchBridge batch size must not exceed MAX_REPLAY_BATCH_SIZE', + ); + for (const chunk of dataChunks) { + assertString(chunk, 'bridge batch write chunk must be a string'); + } + + await page.evaluate(async (chunks: string[]) => { + const bridge = (globalThis as GhosttyBrowserGlobal).__agentTerminal; + if (bridge === undefined || typeof bridge.write !== 'function') { + throw new Error('ghostty-web bridge write() is unavailable'); + } + + for (const chunk of chunks) { + await bridge.write(chunk); + } + }, dataChunks); + } + + private async writeBridge(page: Page, data: string): Promise { + assertString(data, 'bridge write data must be a string'); + + await page.evaluate(async (nextData) => { + const bridge = (globalThis as GhosttyBrowserGlobal).__agentTerminal; + if (bridge === undefined || typeof bridge.write !== 'function') { + throw new Error('ghostty-web bridge write() is unavailable'); + } + + await bridge.write(nextData); + }, data); + } +} diff --git a/src/renderer/ghosttyWeb/harness.html b/src/renderer/ghosttyWeb/harness.html new file mode 100644 index 0000000..dfbf6c3 --- /dev/null +++ b/src/renderer/ghosttyWeb/harness.html @@ -0,0 +1,351 @@ + + + + + + agent-terminal ghostty-web harness + + + +
+
+
+ + + diff --git a/src/renderer/ghosttyWeb/index.ts b/src/renderer/ghosttyWeb/index.ts new file mode 100644 index 0000000..d2e5766 --- /dev/null +++ b/src/renderer/ghosttyWeb/index.ts @@ -0,0 +1 @@ +export { GhosttyWebBackend } from './backend.js'; diff --git a/src/renderer/index.ts b/src/renderer/index.ts new file mode 100644 index 0000000..002d08d --- /dev/null +++ b/src/renderer/index.ts @@ -0,0 +1,27 @@ +export { + BUILTIN_PROFILE_NAMES, + getBuiltinProfile, + resolveProfile, +} from './profiles.js'; +export { + RenderProfileConfigSchema, + ReplayEventSchema, + ReplayInputSchema, + ReplayStateSchema, + ScreenshotResultSchema, + SemanticSnapshotSchema, + TextSnapshotSchema, +} from './types.js'; +export { VisibleLineSchema } from '../protocol/schemas.js'; +export type { RendererBackend } from './backend.js'; +export { GhosttyWebBackend } from './ghosttyWeb/index.js'; +export type { + RenderProfileConfig, + ReplayEvent, + ReplayInput, + ReplayState, + ScreenshotResult, + SemanticSnapshot, + TextSnapshot, +} from './types.js'; +export type { VisibleLine } from '../protocol/schemas.js'; diff --git a/src/renderer/profiles.ts b/src/renderer/profiles.ts new file mode 100644 index 0000000..c6140f3 --- /dev/null +++ b/src/renderer/profiles.ts @@ -0,0 +1,119 @@ +import type { ZodError } from 'zod'; + +import { invariant } from '../util/assert.js'; +import { + RenderProfileConfigSchema, + type RenderProfileConfig, +} from './types.js'; + +export const BUILTIN_PROFILE_NAMES = Object.freeze([ + 'reference-dark', + 'reference-light', +] as const); + +type BuiltinProfileName = (typeof BUILTIN_PROFILE_NAMES)[number]; + +const BUILTIN_PROFILES: Record = { + 'reference-dark': { + name: 'reference-dark', + theme: 'dark', + fontFamily: 'monospace', + fontSize: 14, + cursorStyle: 'block', + backgroundColor: '#1e1e2e', + foregroundColor: '#cdd6f4', + }, + 'reference-light': { + name: 'reference-light', + theme: 'light', + fontFamily: 'monospace', + fontSize: 14, + cursorStyle: 'block', + backgroundColor: '#eff1f5', + foregroundColor: '#4c4f69', + }, +}; + +function formatSchemaIssues(error: ZodError): string { + return error.issues + .map((issue) => { + const path = issue.path.length > 0 ? issue.path.join('.') : '(root)'; + return `${path}: ${issue.message}`; + }) + .join('; '); +} + +function assertRenderProfileConfig( + config: unknown, +): asserts config is RenderProfileConfig { + const result = RenderProfileConfigSchema.safeParse(config); + if (!result.success) { + invariant(false, formatSchemaIssues(result.error)); + } + + const validatedConfig = result.data; + invariant( + validatedConfig.name.length > 0, + 'render profile name must be non-empty', + ); + invariant( + validatedConfig.fontSize > 0, + 'render profile fontSize must be positive', + ); + invariant( + /^#[0-9a-fA-F]{6}$/u.test(validatedConfig.backgroundColor), + 'render profile backgroundColor must be a hex color', + ); + invariant( + /^#[0-9a-fA-F]{6}$/u.test(validatedConfig.foregroundColor), + 'render profile foregroundColor must be a hex color', + ); +} + +function isBuiltinProfileName(name: string): name is BuiltinProfileName { + return Object.hasOwn(BUILTIN_PROFILES, name); +} + +for (const profileName of BUILTIN_PROFILE_NAMES) { + invariant( + profileName.length > 0, + 'builtin render profile name must be non-empty', + ); + assertRenderProfileConfig(BUILTIN_PROFILES[profileName]); +} + +function cloneProfile(profile: RenderProfileConfig): RenderProfileConfig { + return { ...profile }; +} + +export function getBuiltinProfile( + name: string, +): RenderProfileConfig | undefined { + invariant(name.length > 0, 'profile name must be a non-empty string'); + + const profile = isBuiltinProfileName(name) + ? BUILTIN_PROFILES[name] + : undefined; + return profile === undefined ? undefined : cloneProfile(profile); +} + +export function resolveProfile( + nameOrConfig: string | RenderProfileConfig, +): RenderProfileConfig { + if (typeof nameOrConfig === 'string') { + invariant( + nameOrConfig.length > 0, + 'profile name must be a non-empty string', + ); + + const builtinProfile = getBuiltinProfile(nameOrConfig); + invariant( + builtinProfile !== undefined, + `unknown render profile: ${nameOrConfig}`, + ); + return builtinProfile; + } + + assertRenderProfileConfig(nameOrConfig); + return cloneProfile(nameOrConfig); +} diff --git a/src/renderer/types.ts b/src/renderer/types.ts new file mode 100644 index 0000000..9fd6b17 --- /dev/null +++ b/src/renderer/types.ts @@ -0,0 +1,210 @@ +import { z } from 'zod'; + +import { VisibleLineSchema, type VisibleLine } from '../protocol/schemas.js'; + +const NonEmptyStringSchema = z.string().min(1); +const NonNegativeIntSchema = z.number().int().nonnegative(); +const PositiveIntSchema = z.number().int().positive(); +const PositiveNumberSchema = z.number().positive(); +const CursorStyleSchema = z.enum(['block', 'bar', 'underline']); +const ThemeSchema = z.enum(['dark', 'light']); +const HexColorSchema = z + .string() + .regex(/^#[0-9a-fA-F]{6}$/u, 'must be a hex color like #1e1e2e'); + +const OutputReplayEventSchema = z + .object({ + seq: NonNegativeIntSchema, + ts: z.iso.datetime(), + type: z.literal('output'), + payload: z + .object({ + data: z.string(), + }) + .strict(), + }) + .strict(); + +const InputTextReplayEventSchema = z + .object({ + seq: NonNegativeIntSchema, + ts: z.iso.datetime(), + type: z.literal('input_text'), + payload: z + .object({ + data: z.string(), + }) + .strict(), + }) + .strict(); + +const InputPasteReplayEventSchema = z + .object({ + seq: NonNegativeIntSchema, + ts: z.iso.datetime(), + type: z.literal('input_paste'), + payload: z + .object({ + data: z.string(), + }) + .strict(), + }) + .strict(); + +const InputKeysReplayEventSchema = z + .object({ + seq: NonNegativeIntSchema, + ts: z.iso.datetime(), + type: z.literal('input_keys'), + payload: z + .object({ + keys: z.array(NonEmptyStringSchema).min(1), + }) + .strict(), + }) + .strict(); + +const ResizeReplayEventSchema = z + .object({ + seq: NonNegativeIntSchema, + ts: z.iso.datetime(), + type: z.literal('resize'), + payload: z + .object({ + cols: PositiveIntSchema, + rows: PositiveIntSchema, + }) + .strict(), + }) + .strict(); + +const SignalReplayEventSchema = z + .object({ + seq: NonNegativeIntSchema, + ts: z.iso.datetime(), + type: z.literal('signal'), + payload: z + .object({ + signal: NonEmptyStringSchema, + }) + .strict(), + }) + .strict(); + +const ExitReplayEventSchema = z + .object({ + seq: NonNegativeIntSchema, + ts: z.iso.datetime(), + type: z.literal('exit'), + payload: z + .object({ + exitCode: z.number().int().nullable(), + exitSignal: z.string().nullable(), + }) + .strict(), + }) + .strict(); + +export const ReplayEventSchema = z.discriminatedUnion('type', [ + OutputReplayEventSchema, + InputTextReplayEventSchema, + InputPasteReplayEventSchema, + InputKeysReplayEventSchema, + ResizeReplayEventSchema, + SignalReplayEventSchema, + ExitReplayEventSchema, +]); +export type ReplayEvent = z.infer; + +export const ReplayInputSchema = z + .object({ + sessionId: NonEmptyStringSchema, + initialCols: PositiveIntSchema, + initialRows: PositiveIntSchema, + events: z.array(ReplayEventSchema), + targetSeq: NonNegativeIntSchema, + }) + .strict() + .superRefine(({ events }, context) => { + let previousSeq: number | undefined; + + for (const [index, event] of events.entries()) { + if (previousSeq !== undefined && event.seq <= previousSeq) { + context.addIssue({ + code: 'custom', + path: ['events', index, 'seq'], + message: 'events must be ordered by strictly increasing seq values', + }); + } + + previousSeq = event.seq; + } + }); +export type ReplayInput = z.infer; + +export const ReplayStateSchema = z + .object({ + lastSeq: NonNegativeIntSchema, + cols: PositiveIntSchema, + rows: PositiveIntSchema, + cursorRow: NonNegativeIntSchema, + cursorCol: NonNegativeIntSchema, + }) + .strict(); +export type ReplayState = z.infer; + +export { VisibleLineSchema }; +export type { VisibleLine }; + +export const SemanticSnapshotSchema = z + .object({ + sessionId: NonEmptyStringSchema, + capturedAtSeq: NonNegativeIntSchema, + cols: PositiveIntSchema, + rows: PositiveIntSchema, + cursorRow: NonNegativeIntSchema, + cursorCol: NonNegativeIntSchema, + isAltScreen: z.boolean(), + visibleLines: z.array(VisibleLineSchema), + }) + .strict(); +export type SemanticSnapshot = z.infer; + +export const TextSnapshotSchema = z + .object({ + sessionId: NonEmptyStringSchema, + capturedAtSeq: NonNegativeIntSchema, + cols: PositiveIntSchema, + rows: PositiveIntSchema, + cursorRow: NonNegativeIntSchema, + cursorCol: NonNegativeIntSchema, + text: z.string(), + }) + .strict(); +export type TextSnapshot = z.infer; + +export const ScreenshotResultSchema = z + .object({ + sessionId: NonEmptyStringSchema, + capturedAtSeq: NonNegativeIntSchema, + profileName: NonEmptyStringSchema, + cols: PositiveIntSchema, + rows: PositiveIntSchema, + artifactPath: NonEmptyStringSchema, + pngSizeBytes: PositiveIntSchema, + }) + .strict(); +export type ScreenshotResult = z.infer; + +export const RenderProfileConfigSchema = z + .object({ + name: NonEmptyStringSchema, + theme: ThemeSchema, + fontFamily: NonEmptyStringSchema, + fontSize: PositiveNumberSchema, + cursorStyle: CursorStyleSchema, + backgroundColor: HexColorSchema, + foregroundColor: HexColorSchema, + }) + .strict(); +export type RenderProfileConfig = z.infer; diff --git a/src/storage/artifactManifest.ts b/src/storage/artifactManifest.ts new file mode 100644 index 0000000..584df31 --- /dev/null +++ b/src/storage/artifactManifest.ts @@ -0,0 +1,197 @@ +import { basename, resolve } from 'node:path'; + +import { ulid } from 'ulid'; +import { z } from 'zod'; + +import { ERROR_CODES, makeCliError } from '../protocol/errors.js'; +import { readValidatedJsonFile, writeValidatedJsonFile } from './manifests.js'; +import { artifactPath } from './artifactPaths.js'; +import { invariant } from '../util/assert.js'; + +const ARTIFACT_MANIFEST_FILENAME = 'manifest.json'; +const NonEmptyStringSchema = z.string().min(1); +const NonNegativeIntSchema = z.number().int().nonnegative(); +const IsoDatetimeSchema = z.iso.datetime(); +const ArtifactKindSchema = z.enum(['screenshot', 'snapshot']); + +export const ArtifactEntrySchema = z + .object({ + id: NonEmptyStringSchema, + kind: ArtifactKindSchema, + filename: NonEmptyStringSchema.refine( + (value) => !value.includes('/') && !value.includes('\\'), + 'filename must not contain path separators', + ), + sessionId: NonEmptyStringSchema, + capturedAtSeq: NonNegativeIntSchema, + createdAt: IsoDatetimeSchema, + metadata: z.record(z.string(), z.unknown()), + }) + .strict(); +export type ArtifactEntry = z.infer; + +export const ArtifactManifestSchema = z + .object({ + version: z.literal(1), + sessionId: NonEmptyStringSchema, + artifacts: z.array(ArtifactEntrySchema), + }) + .strict(); +export type ArtifactManifest = z.infer; + +const appendQueues = new Map>(); + +function artifactManifestPath(sessionDir: string): string { + return artifactPath(sessionDir, ARTIFACT_MANIFEST_FILENAME); +} + +function sessionIdFromSessionDir(sessionDir: string): string { + const sessionId = basename(resolve(sessionDir)); + invariant( + sessionId.length > 0, + 'sessionDir must resolve to a non-empty sessionId', + ); + return sessionId; +} + +function validateArtifactManifestData( + path: string, + data: unknown, + expectedSessionId: string, +): ArtifactManifest { + const parsedManifest = ArtifactManifestSchema.safeParse(data); + + if (!parsedManifest.success) { + throw makeCliError(ERROR_CODES.MANIFEST_VALIDATION_ERROR, { + message: `Artifact manifest validation failed for ${path}.`, + details: { + path, + issues: parsedManifest.error.issues, + }, + }); + } + + if (parsedManifest.data.sessionId !== expectedSessionId) { + throw makeCliError(ERROR_CODES.MANIFEST_VALIDATION_ERROR, { + message: `Artifact manifest sessionId mismatch for ${path}.`, + details: { + path, + expectedSessionId, + actualSessionId: parsedManifest.data.sessionId, + }, + }); + } + + return parsedManifest.data; +} + +function validateArtifactEntry( + entry: ArtifactEntry, + expectedSessionId: string, +): ArtifactEntry { + const parsedEntry = ArtifactEntrySchema.safeParse(entry); + + if (!parsedEntry.success) { + throw makeCliError(ERROR_CODES.MANIFEST_VALIDATION_ERROR, { + message: `Artifact entry validation failed for session ${expectedSessionId}.`, + details: { + sessionId: expectedSessionId, + issues: parsedEntry.error.issues, + }, + }); + } + + if (parsedEntry.data.sessionId !== expectedSessionId) { + throw makeCliError(ERROR_CODES.MANIFEST_VALIDATION_ERROR, { + message: `Artifact entry sessionId mismatch for session ${expectedSessionId}.`, + details: { + expectedSessionId, + actualSessionId: parsedEntry.data.sessionId, + }, + }); + } + + return parsedEntry.data; +} + +function emptyArtifactManifest(sessionDir: string): ArtifactManifest { + return { + version: 1, + sessionId: sessionIdFromSessionDir(sessionDir), + artifacts: [], + }; +} + +export async function readArtifactManifest( + sessionDir: string, +): Promise { + const path = artifactManifestPath(sessionDir); + const expectedSessionId = sessionIdFromSessionDir(sessionDir); + const manifest = await readValidatedJsonFile({ + path, + pathLabel: 'artifact manifest path', + allowMissing: true, + readErrorMessage: `Failed to read artifact manifest at ${path}.`, + invalidJsonMessage: `Artifact manifest contains invalid JSON at ${path}.`, + validate: (manifestPath, data) => + validateArtifactManifestData(manifestPath, data, expectedSessionId), + }); + + return manifest ?? emptyArtifactManifest(sessionDir); +} + +export async function writeArtifactManifest( + sessionDir: string, + manifest: ArtifactManifest, +): Promise { + const path = artifactManifestPath(sessionDir); + const expectedSessionId = sessionIdFromSessionDir(sessionDir); + + await writeValidatedJsonFile({ + path, + pathLabel: 'artifact manifest path', + data: manifest, + writeErrorMessage: `Failed to write artifact manifest at ${path}.`, + validate: (manifestPath, data) => + validateArtifactManifestData(manifestPath, data, expectedSessionId), + }); +} + +export async function appendArtifact( + sessionDir: string, + entry: ArtifactEntry, +): Promise { + const resolvedSessionDir = resolve(sessionDir); + const expectedSessionId = sessionIdFromSessionDir(resolvedSessionDir); + const validatedEntry = validateArtifactEntry(entry, expectedSessionId); + + const previousWrite = + appendQueues.get(resolvedSessionDir) ?? Promise.resolve(); + + const queuedWrite = previousWrite + .then(async () => { + const manifest = await readArtifactManifest(resolvedSessionDir); + await writeArtifactManifest(resolvedSessionDir, { + ...manifest, + artifacts: [...manifest.artifacts, validatedEntry], + }); + }) + .finally(() => { + if (appendQueues.get(resolvedSessionDir) === queuedWrite) { + appendQueues.delete(resolvedSessionDir); + } + }); + + appendQueues.set(resolvedSessionDir, queuedWrite); + await queuedWrite; +} + +export function createArtifactEntry( + entry: Omit, +): ArtifactEntry { + return { + ...entry, + id: ulid(), + createdAt: new Date().toISOString(), + }; +} diff --git a/src/storage/artifactPaths.ts b/src/storage/artifactPaths.ts new file mode 100644 index 0000000..10e7350 --- /dev/null +++ b/src/storage/artifactPaths.ts @@ -0,0 +1,101 @@ +import { mkdir } from 'node:fs/promises'; +import { dirname, isAbsolute, resolve } from 'node:path'; + +import { invariant } from '../util/assert.js'; + +const ARTIFACTS_DIRNAME = 'artifacts'; +const SAFE_FILENAME_COMPONENT_PATTERN = /[^a-zA-Z0-9._-]+/g; +const TRIMMED_HYPHEN_PATTERN = /^-+|-+$/g; + +function assertNonEmptyString( + value: string, + label: string, +): asserts value is string { + invariant( + typeof value === 'string' && value.length > 0, + `${label} must be a non-empty string`, + ); +} + +function assertNonNegativeInteger(value: number, label: string): void { + invariant( + Number.isInteger(value) && value >= 0, + `${label} must be a non-negative integer`, + ); +} + +function assertAbsolutePath(pathValue: string, label: string): void { + assertNonEmptyString(pathValue, label); + invariant(isAbsolute(pathValue), `${label} must be an absolute path`); +} + +function sanitizeFilenameComponent(value: string, label: string): string { + assertNonEmptyString(value, label); + + const sanitizedValue = value + .trim() + .replace(SAFE_FILENAME_COMPONENT_PATTERN, '-') + .replace(TRIMMED_HYPHEN_PATTERN, ''); + + invariant( + sanitizedValue.length > 0, + `${label} must contain at least one filename-safe character`, + ); + + return sanitizedValue; +} + +function artifactsDir(sessionDir: string): string { + assertAbsolutePath(sessionDir, 'sessionDir'); + + const normalizedSessionDir = resolve(sessionDir); + const directory = resolve(normalizedSessionDir, ARTIFACTS_DIRNAME); + + invariant( + dirname(directory) === normalizedSessionDir, + 'artifacts directory must stay within the session directory', + ); + + return directory; +} + +export function screenshotFilename(seq: number, profileName: string): string { + assertNonNegativeInteger(seq, 'seq'); + const sanitizedProfileName = sanitizeFilenameComponent( + profileName, + 'profileName', + ); + return `screenshot-${String(seq)}-${sanitizedProfileName}.png`; +} + +export function snapshotFilename( + seq: number, + format: 'structured' | 'text', +): string { + assertNonNegativeInteger(seq, 'seq'); + const sanitizedFormat = sanitizeFilenameComponent(format, 'format'); + return `snapshot-${String(seq)}-${sanitizedFormat}.json`; +} + +export function artifactPath(sessionDir: string, filename: string): string { + const directory = artifactsDir(sessionDir); + assertNonEmptyString(filename, 'filename'); + invariant( + !filename.includes('/') && !filename.includes('\\'), + 'filename must not contain path separators', + ); + + const resolvedArtifactPath = resolve(directory, filename); + invariant( + dirname(resolvedArtifactPath) === directory, + 'artifact path must be created directly within the artifacts directory', + ); + + return resolvedArtifactPath; +} + +export async function ensureArtifactsDir(sessionDir: string): Promise { + const directory = artifactsDir(sessionDir); + await mkdir(directory, { recursive: true }); + return directory; +} diff --git a/src/storage/manifests.ts b/src/storage/manifests.ts index b899bc8..a0e54ae 100644 --- a/src/storage/manifests.ts +++ b/src/storage/manifests.ts @@ -11,9 +11,33 @@ interface NodeError { code?: string; } -function assertAbsoluteManifestPath(path: string): void { - invariant(path.length > 0, 'manifest path must be a non-empty string'); - invariant(isAbsolute(path), 'manifest path must be absolute'); +export interface ReadValidatedJsonFileOptions { + path: string; + pathLabel: string; + allowMissing: boolean; + readErrorMessage: string; + invalidJsonMessage: string; + validate: (path: string, data: unknown) => T; +} + +export interface WriteValidatedJsonFileOptions { + path: string; + pathLabel: string; + data: T; + writeErrorMessage: string; + validate: (path: string, data: unknown) => T; +} + +export interface WriteTextFileAtomicOptions { + path: string; + pathLabel: string; + contents: string; + writeErrorMessage: string; +} + +function assertAbsoluteStoragePath(path: string, label: string): void { + invariant(path.length > 0, `${label} must be a non-empty string`); + invariant(isAbsolute(path), `${label} must be absolute`); } function isEnoentError(error: unknown): error is Error & NodeError { @@ -24,29 +48,18 @@ function isEnoentError(error: unknown): error is Error & NodeError { ); } -function validateManifestData(path: string, data: unknown): SessionRecord { - const parsedManifest = SessionRecordSchema.safeParse(data); - - if (parsedManifest.success) { - return parsedManifest.data; - } - - throw makeCliError(ERROR_CODES.MANIFEST_VALIDATION_ERROR, { - message: `Session manifest validation failed for ${path}.`, - details: { - path, - issues: parsedManifest.error.issues, - }, - }); -} - -function parseManifestJson(path: string, rawManifest: string): SessionRecord { +function parseValidatedJson( + path: string, + rawContents: string, + invalidJsonMessage: string, + validate: (path: string, data: unknown) => T, +): T { try { - return validateManifestData(path, JSON.parse(rawManifest) as unknown); + return validate(path, JSON.parse(rawContents) as unknown); } catch (error) { if (error instanceof SyntaxError) { throw makeCliError(ERROR_CODES.MANIFEST_VALIDATION_ERROR, { - message: `Session manifest contains invalid JSON at ${path}.`, + message: invalidJsonMessage, details: { path }, cause: error, }); @@ -56,28 +69,97 @@ function parseManifestJson(path: string, rawManifest: string): SessionRecord { } } -async function readManifestInternal( - path: string, - allowMissing: boolean, -): Promise { - assertAbsoluteManifestPath(path); +export async function readValidatedJsonFile( + options: ReadValidatedJsonFileOptions, +): Promise { + assertAbsoluteStoragePath(options.path, options.pathLabel); - let rawManifest: string; + let rawContents: string; try { - rawManifest = await readFile(path, 'utf8'); + rawContents = await readFile(options.path, 'utf8'); } catch (error) { - if (allowMissing && isEnoentError(error)) { + if (options.allowMissing && isEnoentError(error)) { return null; } throw makeCliError(ERROR_CODES.STORAGE_READ_ERROR, { - message: `Failed to read session manifest at ${path}.`, - details: { path }, + message: options.readErrorMessage, + details: { path: options.path }, + cause: error, + }); + } + + return parseValidatedJson( + options.path, + rawContents, + options.invalidJsonMessage, + options.validate, + ); +} + +export async function writeTextFileAtomic( + options: WriteTextFileAtomicOptions, +): Promise { + assertAbsoluteStoragePath(options.path, options.pathLabel); + + const outputDirectory = dirname(options.path); + const temporaryPath = `${options.path}.tmp-${randomUUID()}`; + + try { + await mkdir(outputDirectory, { recursive: true }); + await writeFile(temporaryPath, options.contents, 'utf8'); + await rename(temporaryPath, options.path); + } catch (error) { + await rm(temporaryPath, { force: true }).catch(() => undefined); + throw makeCliError(ERROR_CODES.STORAGE_WRITE_ERROR, { + message: options.writeErrorMessage, + details: { path: options.path }, cause: error, }); } +} + +export async function writeValidatedJsonFile( + options: WriteValidatedJsonFileOptions, +): Promise { + const validatedData = options.validate(options.path, options.data); - return parseManifestJson(path, rawManifest); + await writeTextFileAtomic({ + path: options.path, + pathLabel: options.pathLabel, + contents: `${JSON.stringify(validatedData, null, 2)}\n`, + writeErrorMessage: options.writeErrorMessage, + }); +} + +function validateManifestData(path: string, data: unknown): SessionRecord { + const parsedManifest = SessionRecordSchema.safeParse(data); + + if (parsedManifest.success) { + return parsedManifest.data; + } + + throw makeCliError(ERROR_CODES.MANIFEST_VALIDATION_ERROR, { + message: `Session manifest validation failed for ${path}.`, + details: { + path, + issues: parsedManifest.error.issues, + }, + }); +} + +async function readManifestInternal( + path: string, + allowMissing: boolean, +): Promise { + return readValidatedJsonFile({ + path, + pathLabel: 'manifest path', + allowMissing, + readErrorMessage: `Failed to read session manifest at ${path}.`, + invalidJsonMessage: `Session manifest contains invalid JSON at ${path}.`, + validate: validateManifestData, + }); } export async function readManifest(path: string): Promise { @@ -98,23 +180,11 @@ export async function writeManifest( path: string, record: SessionRecord, ): Promise { - assertAbsoluteManifestPath(path); - - const validatedRecord = validateManifestData(path, record); - const serializedManifest = `${JSON.stringify(validatedRecord, null, 2)}\n`; - const manifestDirectory = dirname(path); - const temporaryPath = `${path}.tmp-${randomUUID()}`; - - try { - await mkdir(manifestDirectory, { recursive: true }); - await writeFile(temporaryPath, serializedManifest, 'utf8'); - await rename(temporaryPath, path); - } catch (error) { - await rm(temporaryPath, { force: true }).catch(() => undefined); - throw makeCliError(ERROR_CODES.STORAGE_WRITE_ERROR, { - message: `Failed to write session manifest at ${path}.`, - details: { path }, - cause: error, - }); - } + await writeValidatedJsonFile({ + path, + pathLabel: 'manifest path', + data: record, + writeErrorMessage: `Failed to write session manifest at ${path}.`, + validate: validateManifestData, + }); } diff --git a/test/e2e/renderer-errors.test.ts b/test/e2e/renderer-errors.test.ts new file mode 100644 index 0000000..1b24377 --- /dev/null +++ b/test/e2e/renderer-errors.test.ts @@ -0,0 +1,212 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { ERROR_CODES } from '../../src/protocol/errors.js'; +import { + DEFAULT_CLI_TIMEOUT_MS, + cleanupHome, + createIsolatedHome, + createSession, + destroySession, + runCli, +} from './helpers.js'; + +interface ErrorEnvelope { + ok: false; + command: string; + timestamp: string; + error: { + code: string; + message: string; + retryable: boolean; + details?: Record; + }; +} + +function runCliErrorEnvelope( + args: string[], + env: Record, + timeout = DEFAULT_CLI_TIMEOUT_MS, +): ErrorEnvelope { + const result = runCli([...args, '--json'], env, timeout); + + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toBe(''); + expect(result.stdout.length).toBeGreaterThan(0); + + const envelope = JSON.parse(result.stdout) as ErrorEnvelope; + expect(envelope.ok).toBe(false); + return envelope; +} + +function repeatCharacter(length: number): string { + return 'x'.repeat(length); +} + +describe('renderer error paths e2e', { timeout: 120_000 }, () => { + let testHome = ''; + let createdSessionIds: string[] = []; + + beforeEach(async () => { + testHome = await createIsolatedHome(); + createdSessionIds = []; + }); + + afterEach(async () => { + for (const sessionId of createdSessionIds) { + destroySession(testHome, sessionId); + } + + await cleanupHome(testHome); + }); + + it('returns an error for unknown screenshot profiles', () => { + const sessionId = createSession(testHome); + createdSessionIds.push(sessionId); + + const envelope = runCliErrorEnvelope( + ['screenshot', sessionId, '--profile', 'nonexistent-profile'], + { AGENT_TERMINAL_HOME: testHome }, + ); + + expect(envelope.command).toBe('screenshot'); + expect(envelope.error.code).toBe(ERROR_CODES.INVALID_INPUT); + expect(envelope.error.message).toContain('unknown render profile'); + }); + + it('returns an error for screenshot profiles beyond the schema maximum length', () => { + const sessionId = createSession(testHome); + createdSessionIds.push(sessionId); + const oversizedProfile = repeatCharacter(101); + + const envelope = runCliErrorEnvelope( + ['screenshot', sessionId, '--profile', oversizedProfile], + { AGENT_TERMINAL_HOME: testHome }, + ); + + expect(envelope.command).toBe('screenshot'); + expect(envelope.error.code).toBe(ERROR_CODES.INVALID_INPUT); + expect(envelope.error.message).toContain('non-empty string'); + expect(envelope.error.details).toMatchObject({ + profile: oversizedProfile, + }); + }); + + it('returns an error for snapshot requests after the session has exited', () => { + const sessionId = createSession(testHome, [ + '/bin/sh', + '-c', + 'printf done\\n; exit 0', + ]); + createdSessionIds.push(sessionId); + + const waitResult = runCli( + ['wait', sessionId, '--exit', '--timeout', '10000', '--json'], + { AGENT_TERMINAL_HOME: testHome }, + 15_000, + ); + expect(waitResult.exitCode).toBe(0); + expect(waitResult.stderr).toBe(''); + + const envelope = runCliErrorEnvelope(['snapshot', sessionId], { + AGENT_TERMINAL_HOME: testHome, + }); + + expect(envelope.command).toBe('snapshot'); + expect(envelope.error.code).toBe(ERROR_CODES.SESSION_NOT_RUNNING); + expect(envelope.error.message).toContain('is not running'); + expect(envelope.error.details).toMatchObject({ + sessionId, + status: 'exited', + }); + }); + + it('returns an error for wait text beyond the schema maximum length', () => { + const sessionId = createSession(testHome); + createdSessionIds.push(sessionId); + + const envelope = runCliErrorEnvelope( + ['wait', sessionId, '--text', repeatCharacter(1001)], + { AGENT_TERMINAL_HOME: testHome }, + 15_000, + ); + + expect(envelope.command).toBe('wait'); + expect(envelope.error.code).toBe(ERROR_CODES.RPC_ERROR); + expect(envelope.error.message).toContain('1000'); + expect(envelope.error.message).toContain('text'); + }); + + it('returns an error for wait regex beyond the schema maximum length', () => { + const sessionId = createSession(testHome); + createdSessionIds.push(sessionId); + + const envelope = runCliErrorEnvelope( + ['wait', sessionId, '--regex', repeatCharacter(201)], + { AGENT_TERMINAL_HOME: testHome }, + 15_000, + ); + + expect(envelope.command).toBe('wait'); + expect(envelope.error.code).toBe(ERROR_CODES.RPC_ERROR); + expect(envelope.error.message).toContain('200'); + expect(envelope.error.message).toContain('regex'); + }); + + it('returns an error for wait regex patterns with nested quantifiers', () => { + const sessionId = createSession(testHome); + createdSessionIds.push(sessionId); + + const envelope = runCliErrorEnvelope( + ['wait', sessionId, '--regex', '(a+)+'], + { AGENT_TERMINAL_HOME: testHome }, + 15_000, + ); + + expect(envelope.command).toBe('wait'); + expect(envelope.error.code).toBe(ERROR_CODES.INVALID_INPUT); + expect(envelope.error.message).toContain('nested quantifiers'); + }); + + it('returns an error for malformed wait regex patterns', () => { + const sessionId = createSession(testHome); + createdSessionIds.push(sessionId); + + const envelope = runCliErrorEnvelope( + ['wait', sessionId, '--regex', '[invalid('], + { AGENT_TERMINAL_HOME: testHome }, + 15_000, + ); + + expect(envelope.command).toBe('wait'); + expect(envelope.error.code).toBe(ERROR_CODES.INVALID_INPUT); + expect(envelope.error.message).toContain('Invalid regex pattern'); + }); + + it('returns an error for mutually exclusive wait text and regex filters', () => { + const sessionId = createSession(testHome); + createdSessionIds.push(sessionId); + + const envelope = runCliErrorEnvelope( + ['wait', sessionId, '--text', 'hello', '--regex', 'world'], + { AGENT_TERMINAL_HOME: testHome }, + ); + + expect(envelope.command).toBe('wait'); + expect(envelope.error.code).toBe(ERROR_CODES.INVALID_INPUT); + expect(envelope.error.message).toContain('mutually exclusive'); + }); + + it('returns an error when mixing legacy and render wait flags', () => { + const sessionId = createSession(testHome); + createdSessionIds.push(sessionId); + + const envelope = runCliErrorEnvelope( + ['wait', sessionId, '--exit', '--text', 'hello'], + { AGENT_TERMINAL_HOME: testHome }, + ); + + expect(envelope.command).toBe('wait'); + expect(envelope.error.code).toBe(ERROR_CODES.INVALID_INPUT); + expect(envelope.error.message).toContain('Cannot mix legacy wait flags'); + }); +}); diff --git a/test/e2e/renderer-slice.test.ts b/test/e2e/renderer-slice.test.ts new file mode 100644 index 0000000..2183210 --- /dev/null +++ b/test/e2e/renderer-slice.test.ts @@ -0,0 +1,467 @@ +import { readFile, stat } from 'node:fs/promises'; + +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import type { DoctorResult } from '../../src/cli/commands/doctor.js'; +import type { + ScreenshotResult, + SnapshotResult, + WaitForRenderResult, +} from '../../src/protocol/messages.js'; +import { readArtifactManifest } from '../../src/storage/artifactManifest.js'; +import { sessionDir } from '../../src/storage/sessionPaths.js'; +import { + cleanupHome, + createIsolatedHome, + normalizeTerminalOutput, + readOutput, + runCli, + type SuccessEnvelope, + type WaitResult, +} from './helpers.js'; + +interface CreateResult { + sessionId: string; +} + +const DEFAULT_CLI_TIMEOUT_MS = 60_000; +const INITIAL_IDLE_MS = 200; +const INITIAL_WAIT_TIMEOUT_MS = 5_000; +const RENDER_WAIT_TIMEOUT_MS = 15_000; +const SCREEN_STABLE_MS = 1_000; +const PNG_MAGIC_HEX = '89504e470d0a1a0a'; + +function testEnv(home: string): Record { + return { AGENT_TERMINAL_HOME: home }; +} + +function withJsonFlag(args: string[]): string[] { + const commandSeparatorIndex = args.indexOf('--'); + + if (commandSeparatorIndex === -1) { + return [...args, '--json']; + } + + return [ + ...args.slice(0, commandSeparatorIndex), + '--json', + ...args.slice(commandSeparatorIndex), + ]; +} + +function runCliEnvelope( + args: string[], + env: Record, + timeout = DEFAULT_CLI_TIMEOUT_MS, +): SuccessEnvelope { + const result = runCli(withJsonFlag(args), env, timeout); + + expect(result.status).toBe(0); + expect(result.stderr).toBe(''); + expect(result.stdout.length).toBeGreaterThan(0); + + return JSON.parse(result.stdout) as SuccessEnvelope; +} + +function stateTransitionCommand(): string[] { + return [ + 'node', + '--import', + 'tsx', + 'test/fixtures/apps/state-transition/main.ts', + ]; +} + +async function createRendererSession( + home: string, + createdSessionIds: string[], +): Promise { + const env = testEnv(home); + const createEnvelope = runCliEnvelope( + ['create', '--', ...stateTransitionCommand()], + env, + ); + expect(createEnvelope.ok).toBe(true); + expect(createEnvelope.command).toBe('create'); + + const sessionId = createEnvelope.result.sessionId; + createdSessionIds.push(sessionId); + + const waitEnvelope = runCliEnvelope( + [ + 'wait', + sessionId, + '--idle-ms', + String(INITIAL_IDLE_MS), + '--timeout', + String(INITIAL_WAIT_TIMEOUT_MS), + ], + env, + ); + expect(waitEnvelope.ok).toBe(true); + expect(waitEnvelope.command).toBe('wait'); + expect(waitEnvelope.result.timedOut).toBe(false); + + await expect( + readOutput(home, sessionId).then((output) => + normalizeTerminalOutput(output), + ), + ).resolves.toContain('Loading...\n'); + + return sessionId; +} + +function expectStructuredSnapshot( + result: SnapshotResult, +): asserts result is Extract { + expect(result.format).toBe('structured'); + expect(result.capturedAtSeq).toBeGreaterThanOrEqual(0); + expect(result.cols).toBeGreaterThan(0); + expect(result.rows).toBeGreaterThan(0); + + if (result.format !== 'structured') { + throw new Error('expected structured snapshot result'); + } +} + +function expectTextSnapshot( + result: SnapshotResult, +): asserts result is Extract { + expect(result.format).toBe('text'); + expect(result.capturedAtSeq).toBeGreaterThanOrEqual(0); + expect(result.cols).toBeGreaterThan(0); + expect(result.rows).toBeGreaterThan(0); + + if (result.format !== 'text') { + throw new Error('expected text snapshot result'); + } +} + +describe('renderer slice e2e', { timeout: 120_000 }, () => { + let testHome = ''; + let createdSessionIds: string[] = []; + + beforeEach(async () => { + testHome = await createIsolatedHome(); + createdSessionIds = []; + }); + + afterEach(async () => { + const env = testEnv(testHome); + + for (const sessionId of createdSessionIds) { + runCli(['destroy', sessionId, '--force', '--json'], env); + } + + await cleanupHome(testHome); + }); + + it('captures a structured snapshot of visible terminal content', async () => { + const env = testEnv(testHome); + const sessionId = await createRendererSession(testHome, createdSessionIds); + + const waitEnvelope = runCliEnvelope( + [ + 'wait', + sessionId, + '--text', + 'Ready', + '--timeout', + String(RENDER_WAIT_TIMEOUT_MS), + ], + env, + 20_000, + ); + expect(waitEnvelope.ok).toBe(true); + expect(waitEnvelope.result.matched).toBe(true); + expect(waitEnvelope.result.timedOut).toBe(false); + expect(waitEnvelope.result.matchedText).toBe('Ready'); + + const snapshotEnvelope = runCliEnvelope( + ['snapshot', sessionId], + env, + ); + expect(snapshotEnvelope.ok).toBe(true); + expect(snapshotEnvelope.command).toBe('snapshot'); + expectStructuredSnapshot(snapshotEnvelope.result); + expect(snapshotEnvelope.result.sessionId).toBe(sessionId); + expect( + snapshotEnvelope.result.visibleLines.some((line) => + line.text.includes('3 items'), + ), + ).toBe(true); + expect( + snapshotEnvelope.result.visibleLines.some((line) => + line.text.includes('Ready'), + ), + ).toBe(true); + }); + + it('returns text snapshots with --format text', async () => { + const env = testEnv(testHome); + const sessionId = await createRendererSession(testHome, createdSessionIds); + + const waitEnvelope = runCliEnvelope( + [ + 'wait', + sessionId, + '--text', + 'Ready', + '--timeout', + String(RENDER_WAIT_TIMEOUT_MS), + ], + env, + 20_000, + ); + expect(waitEnvelope.result.matched).toBe(true); + expect(waitEnvelope.result.timedOut).toBe(false); + + const snapshotEnvelope = runCliEnvelope( + ['snapshot', sessionId, '--format', 'text'], + env, + ); + expect(snapshotEnvelope.ok).toBe(true); + expect(snapshotEnvelope.command).toBe('snapshot'); + expectTextSnapshot(snapshotEnvelope.result); + expect(snapshotEnvelope.result.sessionId).toBe(sessionId); + expect(snapshotEnvelope.result.text).toContain('3 items'); + expect(snapshotEnvelope.result.text).toContain('Ready'); + }); + + it('captures a screenshot PNG and records snapshot/screenshot artifacts', async () => { + const env = testEnv(testHome); + const sessionId = await createRendererSession(testHome, createdSessionIds); + + const readyEnvelope = runCliEnvelope( + [ + 'wait', + sessionId, + '--text', + 'Ready', + '--timeout', + String(RENDER_WAIT_TIMEOUT_MS), + ], + env, + 20_000, + ); + expect(readyEnvelope.result.matched).toBe(true); + expect(readyEnvelope.result.timedOut).toBe(false); + + const snapshotEnvelope = runCliEnvelope( + ['snapshot', sessionId], + env, + ); + expect(snapshotEnvelope.ok).toBe(true); + expectStructuredSnapshot(snapshotEnvelope.result); + + const screenshotEnvelope = runCliEnvelope( + ['screenshot', sessionId], + env, + DEFAULT_CLI_TIMEOUT_MS, + ); + expect(screenshotEnvelope.ok).toBe(true); + expect(screenshotEnvelope.command).toBe('screenshot'); + expect(screenshotEnvelope.result.sessionId).toBe(sessionId); + expect(screenshotEnvelope.result.profileName).toBe('reference-dark'); + expect(screenshotEnvelope.result.capturedAtSeq).toBeGreaterThanOrEqual(0); + expect(screenshotEnvelope.result.cols).toBeGreaterThan(0); + expect(screenshotEnvelope.result.rows).toBeGreaterThan(0); + expect(screenshotEnvelope.result.artifactPath).toMatch(/\.png$/); + expect(screenshotEnvelope.result.pngSizeBytes).toBeGreaterThan(0); + + const screenshotStats = await stat(screenshotEnvelope.result.artifactPath); + expect(screenshotStats.size).toBe(screenshotEnvelope.result.pngSizeBytes); + + const screenshotFile = await readFile( + screenshotEnvelope.result.artifactPath, + ); + expect(screenshotFile.subarray(0, 8).toString('hex')).toBe(PNG_MAGIC_HEX); + + const manifest = await readArtifactManifest( + sessionDir(testHome, sessionId), + ); + expect(manifest.sessionId).toBe(sessionId); + expect(manifest.artifacts).toHaveLength(2); + expect(manifest.artifacts.map((artifact) => artifact.kind)).toEqual([ + 'snapshot', + 'screenshot', + ]); + expect(manifest.artifacts[0]).toMatchObject({ + kind: 'snapshot', + sessionId, + capturedAtSeq: snapshotEnvelope.result.capturedAtSeq, + metadata: { + format: 'structured', + }, + }); + expect(manifest.artifacts[1]).toMatchObject({ + kind: 'screenshot', + sessionId, + capturedAtSeq: screenshotEnvelope.result.capturedAtSeq, + metadata: { + profileName: 'reference-dark', + pngSizeBytes: screenshotEnvelope.result.pngSizeBytes, + }, + }); + }); + + it('uses the requested screenshot profile', async () => { + const env = testEnv(testHome); + const sessionId = await createRendererSession(testHome, createdSessionIds); + + const readyEnvelope = runCliEnvelope( + [ + 'wait', + sessionId, + '--text', + 'Ready', + '--timeout', + String(RENDER_WAIT_TIMEOUT_MS), + ], + env, + 20_000, + ); + expect(readyEnvelope.result.matched).toBe(true); + expect(readyEnvelope.result.timedOut).toBe(false); + + const screenshotEnvelope = runCliEnvelope( + ['screenshot', sessionId, '--profile', 'reference-light'], + env, + DEFAULT_CLI_TIMEOUT_MS, + ); + expect(screenshotEnvelope.ok).toBe(true); + expect(screenshotEnvelope.command).toBe('screenshot'); + expect(screenshotEnvelope.result.sessionId).toBe(sessionId); + expect(screenshotEnvelope.result.profileName).toBe('reference-light'); + expect(screenshotEnvelope.result.pngSizeBytes).toBeGreaterThan(0); + + const manifest = await readArtifactManifest( + sessionDir(testHome, sessionId), + ); + expect(manifest.artifacts).toHaveLength(1); + expect(manifest.artifacts[0]).toMatchObject({ + kind: 'screenshot', + sessionId, + capturedAtSeq: screenshotEnvelope.result.capturedAtSeq, + metadata: { + profileName: 'reference-light', + pngSizeBytes: screenshotEnvelope.result.pngSizeBytes, + }, + }); + }); + + it('waits for text matches', async () => { + const env = testEnv(testHome); + const sessionId = await createRendererSession(testHome, createdSessionIds); + + const waitEnvelope = runCliEnvelope( + [ + 'wait', + sessionId, + '--text', + 'Ready', + '--timeout', + String(RENDER_WAIT_TIMEOUT_MS), + ], + env, + 20_000, + ); + + expect(waitEnvelope.ok).toBe(true); + expect(waitEnvelope.command).toBe('wait'); + expect(waitEnvelope.result.matched).toBe(true); + expect(waitEnvelope.result.timedOut).toBe(false); + expect(waitEnvelope.result.matchedText).toBe('Ready'); + expect(waitEnvelope.result.capturedAtSeq).toBeGreaterThanOrEqual(0); + }); + + it('waits for regex matches', async () => { + const env = testEnv(testHome); + const sessionId = await createRendererSession(testHome, createdSessionIds); + + const waitEnvelope = runCliEnvelope( + [ + 'wait', + sessionId, + '--regex', + '\\d+ items', + '--timeout', + String(RENDER_WAIT_TIMEOUT_MS), + ], + env, + 20_000, + ); + + expect(waitEnvelope.ok).toBe(true); + expect(waitEnvelope.command).toBe('wait'); + expect(waitEnvelope.result.matched).toBe(true); + expect(waitEnvelope.result.timedOut).toBe(false); + expect(waitEnvelope.result.matchedText).toBe('3 items'); + expect(waitEnvelope.result.capturedAtSeq).toBeGreaterThanOrEqual(0); + }); + + it('waits for the screen to stop changing', async () => { + const env = testEnv(testHome); + const sessionId = await createRendererSession(testHome, createdSessionIds); + + const readyEnvelope = runCliEnvelope( + [ + 'wait', + sessionId, + '--text', + 'Ready', + '--timeout', + String(RENDER_WAIT_TIMEOUT_MS), + ], + env, + 20_000, + ); + expect(readyEnvelope.result.matched).toBe(true); + expect(readyEnvelope.result.timedOut).toBe(false); + + const stableEnvelope = runCliEnvelope( + [ + 'wait', + sessionId, + '--screen-stable-ms', + String(SCREEN_STABLE_MS), + '--timeout', + String(RENDER_WAIT_TIMEOUT_MS), + ], + env, + 20_000, + ); + + expect(stableEnvelope.ok).toBe(true); + expect(stableEnvelope.command).toBe('wait'); + expect(stableEnvelope.result.matched).toBe(true); + expect(stableEnvelope.result.timedOut).toBe(false); + expect(stableEnvelope.result.matchedText).toBeUndefined(); + expect(stableEnvelope.result.capturedAtSeq).toBeGreaterThanOrEqual( + readyEnvelope.result.capturedAtSeq, + ); + }); + + it('reports renderer checks in doctor --json output', () => { + const doctorEnvelope = runCliEnvelope(['doctor'], {}, 90_000); + + expect(doctorEnvelope.ok).toBe(true); + expect(doctorEnvelope.command).toBe('doctor'); + expect(doctorEnvelope.result.ok).toBe(true); + expect(doctorEnvelope.result.checks.environment.length).toBeGreaterThan(0); + expect(doctorEnvelope.result.checks.renderer).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + name: 'playwright_available', + status: 'pass', + }), + expect.objectContaining({ name: 'browser_launch', status: 'pass' }), + expect.objectContaining({ + name: 'ghostty_web_available', + status: 'pass', + }), + expect.objectContaining({ name: 'screenshot_viable', status: 'pass' }), + ]), + ); + }); +}); diff --git a/test/fixtures/apps/state-transition/main.ts b/test/fixtures/apps/state-transition/main.ts new file mode 100644 index 0000000..d299796 --- /dev/null +++ b/test/fixtures/apps/state-transition/main.ts @@ -0,0 +1,17 @@ +import process from 'node:process'; + +const CLEAR_SCREEN_AND_HOME = '\u001b[2J\u001b[H'; +const FINAL_SCREEN = '3 items\nReady\n'; +const HOLD_OPEN_MS = 30_000; +const TRANSITION_DELAY_MS = 1_000; + +process.stdout.write('Loading...\n'); + +setTimeout(() => { + process.stdout.write(CLEAR_SCREEN_AND_HOME); + process.stdout.write(FINAL_SCREEN); +}, TRANSITION_DELAY_MS); + +setTimeout(() => { + process.stdin.resume(); +}, HOLD_OPEN_MS); diff --git a/test/integration/cli.test.ts b/test/integration/cli.test.ts index 041da69..7d70c39 100644 --- a/test/integration/cli.test.ts +++ b/test/integration/cli.test.ts @@ -23,12 +23,23 @@ describe('CLI integration', () => { expect(result.stderr).toBe(''); const parsed = JSON.parse(result.stdout) as SuccessEnvelope<{ - checks: Array<{ ok: boolean; name: string }>; + ok: boolean; + checks: { + environment: Array<{ name: string; status: string }>; + renderer: Array<{ name: string; status: string }>; + }; }>; + const allChecks = [ + ...parsed.result.checks.environment, + ...parsed.result.checks.renderer, + ]; + expect(parsed.ok).toBe(true); expect(parsed.command).toBe('doctor'); - expect(parsed.result.checks.length).toBeGreaterThan(0); - expect(parsed.result.checks.every((check) => check.ok)).toBe(true); + expect(parsed.result.ok).toBe(true); + expect(parsed.result.checks.environment.length).toBeGreaterThan(0); + expect(parsed.result.checks.renderer.length).toBeGreaterThan(0); + expect(allChecks.every((check) => check.status === 'pass')).toBe(true); }); }); diff --git a/test/integration/host-renderer-rpc.test.ts b/test/integration/host-renderer-rpc.test.ts new file mode 100644 index 0000000..289ded6 --- /dev/null +++ b/test/integration/host-renderer-rpc.test.ts @@ -0,0 +1,324 @@ +import { mkdtemp, readFile, stat } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { sendRpc } from '../../src/host/rpcClient.js'; +import type { + ScreenshotResult, + SnapshotResult, + WaitResult, +} from '../../src/protocol/messages.js'; +import { readArtifactManifest } from '../../src/storage/artifactManifest.js'; +import { + artifactPath, + screenshotFilename, + snapshotFilename, +} from '../../src/storage/artifactPaths.js'; +import { sessionDir, socketPath } from '../../src/storage/sessionPaths.js'; +import { + cleanupHome, + createSession, + destroySession, + readEvents, + runCli, + sleep, + type SuccessEnvelope, +} from '../helpers.js'; + +const SNAPSHOT_TIMEOUT_MS = 60_000; +const OUTPUT_MARKER = 'hello-structured'; + +async function waitForOutputMarker( + testHome: string, + sessionId: string, + marker: string, +): Promise { + const waitResult = runCli( + ['wait', sessionId, '--idle-ms', '2000', '--timeout', '10000', '--json'], + { AGENT_TERMINAL_HOME: testHome }, + 15_000, + ); + + expect(waitResult.status).toBe(0); + expect(waitResult.stderr).toBe(''); + const waitEnvelope = JSON.parse( + waitResult.stdout, + ) as SuccessEnvelope; + expect(waitEnvelope.ok).toBe(true); + expect(waitEnvelope.result.timedOut).toBe(false); + + const deadline = Date.now() + 10_000; + while (Date.now() < deadline) { + const events = await readEvents(testHome, sessionId).catch(() => []); + const output = events + .filter((event) => event.type === 'output') + .map((event) => { + const data = event.payload.data; + return typeof data === 'string' ? data : ''; + }) + .join(''); + + if (output.includes(marker)) { + return; + } + + await sleep(250); + } + + throw new Error(`timed out waiting for output marker ${marker}`); +} + +describe( + 'host renderer snapshot/screenshot RPC integration', + { timeout: 120_000 }, + () => { + let testHome = ''; + let sessionId = ''; + let rpcSocketPath = ''; + let sessDir = ''; + + beforeEach(async () => { + testHome = await mkdtemp(join(tmpdir(), 'agent-terminal-host-renderer-')); + sessionId = createSession(testHome, [ + '/bin/sh', + '-c', + `echo ${OUTPUT_MARKER}; exec cat`, + ]); + + await waitForOutputMarker(testHome, sessionId, OUTPUT_MARKER); + + sessDir = sessionDir(testHome, sessionId); + rpcSocketPath = socketPath(sessDir); + }); + + afterEach(async () => { + destroySession(testHome, sessionId); + await cleanupHome(testHome); + sessDir = ''; + sessionId = ''; + rpcSocketPath = ''; + testHome = ''; + }); + + it('returns structured snapshots over RPC', async () => { + const result = (await sendRpc( + rpcSocketPath, + 'snapshot', + { format: 'structured' }, + SNAPSHOT_TIMEOUT_MS, + )) as SnapshotResult; + + expect(result.format).toBe('structured'); + expect(result.sessionId).toBe(sessionId); + + if (result.format !== 'structured') { + throw new Error('expected structured snapshot result'); + } + + expect(Array.isArray(result.visibleLines)).toBe(true); + expect( + result.visibleLines.some((line) => line.text.includes(OUTPUT_MARKER)), + ).toBe(true); + + const filename = snapshotFilename(result.capturedAtSeq, 'structured'); + const manifest = await readArtifactManifest(sessDir); + const artifactContents = JSON.parse( + await readFile(artifactPath(sessDir, filename), 'utf8'), + ) as SnapshotResult; + + expect(artifactContents).toEqual(result); + expect(manifest.artifacts).toHaveLength(1); + expect(manifest.artifacts[0]).toMatchObject({ + kind: 'snapshot', + filename, + sessionId, + capturedAtSeq: result.capturedAtSeq, + metadata: { + format: 'structured', + cols: result.cols, + rows: result.rows, + cursorRow: result.cursorRow, + cursorCol: result.cursorCol, + }, + }); + }); + + it('returns text snapshots over RPC', async () => { + const result = (await sendRpc( + rpcSocketPath, + 'snapshot', + { format: 'text' }, + SNAPSHOT_TIMEOUT_MS, + )) as SnapshotResult; + + expect(result.format).toBe('text'); + expect(result.sessionId).toBe(sessionId); + + if (result.format !== 'text') { + throw new Error('expected text snapshot result'); + } + + expect(result.text).toContain(OUTPUT_MARKER); + + const filename = snapshotFilename(result.capturedAtSeq, 'text'); + const manifest = await readArtifactManifest(sessDir); + const artifactContents = JSON.parse( + await readFile(artifactPath(sessDir, filename), 'utf8'), + ) as SnapshotResult; + + expect(artifactContents).toEqual(result); + expect(manifest.artifacts).toHaveLength(1); + expect(manifest.artifacts[0]).toMatchObject({ + kind: 'snapshot', + filename, + sessionId, + capturedAtSeq: result.capturedAtSeq, + metadata: { + format: 'text', + cols: result.cols, + rows: result.rows, + cursorRow: result.cursorRow, + cursorCol: result.cursorCol, + }, + }); + }); + + it('defaults snapshot RPCs to structured format', async () => { + const result = (await sendRpc( + rpcSocketPath, + 'snapshot', + {}, + SNAPSHOT_TIMEOUT_MS, + )) as SnapshotResult; + + expect(result.format).toBe('structured'); + + if (result.format !== 'structured') { + throw new Error('expected structured snapshot result'); + } + + expect( + result.visibleLines.some((line) => line.text.includes(OUTPUT_MARKER)), + ).toBe(true); + }); + + it('captures screenshots with the default render profile', async () => { + const result = (await sendRpc( + rpcSocketPath, + 'screenshot', + {}, + SNAPSHOT_TIMEOUT_MS, + )) as ScreenshotResult; + const fileStats = await stat(result.artifactPath); + const filename = screenshotFilename( + result.capturedAtSeq, + result.profileName, + ); + const manifest = await readArtifactManifest(sessDir); + + expect(result.sessionId).toBe(sessionId); + expect(result.profileName).toBe('reference-dark'); + expect(result.artifactPath).toBe(artifactPath(sessDir, filename)); + expect(result.pngSizeBytes).toBeGreaterThan(0); + expect(fileStats.size).toBe(result.pngSizeBytes); + expect(manifest.artifacts).toHaveLength(1); + expect(manifest.artifacts[0]).toMatchObject({ + kind: 'screenshot', + filename, + sessionId, + capturedAtSeq: result.capturedAtSeq, + metadata: { + profileName: result.profileName, + cols: result.cols, + rows: result.rows, + pngSizeBytes: result.pngSizeBytes, + }, + }); + }); + + it('handles concurrent snapshot and screenshot requests', async () => { + const [snapshot, screenshot] = (await Promise.all([ + sendRpc( + rpcSocketPath, + 'snapshot', + { format: 'structured' }, + SNAPSHOT_TIMEOUT_MS, + ), + sendRpc(rpcSocketPath, 'screenshot', {}, SNAPSHOT_TIMEOUT_MS), + ])) as [SnapshotResult, ScreenshotResult]; + const screenshotStats = await stat(screenshot.artifactPath); + const manifest = await readArtifactManifest(sessDir); + + expect(snapshot.sessionId).toBe(sessionId); + expect(snapshot.format).toBe('structured'); + if (snapshot.format !== 'structured') { + throw new Error('expected structured snapshot result'); + } + expect( + snapshot.visibleLines.some((line) => line.text.includes(OUTPUT_MARKER)), + ).toBe(true); + + expect(screenshot.sessionId).toBe(sessionId); + expect(screenshot.profileName).toBe('reference-dark'); + expect(screenshot.pngSizeBytes).toBeGreaterThan(0); + expect(screenshotStats.size).toBe(screenshot.pngSizeBytes); + + expect(manifest.artifacts).toHaveLength(2); + expect(manifest.artifacts).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + kind: 'snapshot', + filename: snapshotFilename(snapshot.capturedAtSeq, 'structured'), + sessionId, + capturedAtSeq: snapshot.capturedAtSeq, + }), + expect.objectContaining({ + kind: 'screenshot', + filename: screenshotFilename( + screenshot.capturedAtSeq, + screenshot.profileName, + ), + sessionId, + capturedAtSeq: screenshot.capturedAtSeq, + }), + ]), + ); + }); + + it('captures screenshots with an explicit render profile', async () => { + const result = (await sendRpc( + rpcSocketPath, + 'screenshot', + { profile: 'reference-light' }, + SNAPSHOT_TIMEOUT_MS, + )) as ScreenshotResult; + const fileStats = await stat(result.artifactPath); + const filename = screenshotFilename( + result.capturedAtSeq, + result.profileName, + ); + const manifest = await readArtifactManifest(sessDir); + + expect(result.sessionId).toBe(sessionId); + expect(result.profileName).toBe('reference-light'); + expect(result.artifactPath).toBe(artifactPath(sessDir, filename)); + expect(result.pngSizeBytes).toBeGreaterThan(0); + expect(fileStats.size).toBe(result.pngSizeBytes); + expect(manifest.artifacts).toHaveLength(1); + expect(manifest.artifacts[0]).toMatchObject({ + kind: 'screenshot', + filename, + sessionId, + capturedAtSeq: result.capturedAtSeq, + metadata: { + profileName: result.profileName, + cols: result.cols, + rows: result.rows, + pngSizeBytes: result.pngSizeBytes, + }, + }); + }); + }, +); diff --git a/test/integration/renderer-backend.test.ts b/test/integration/renderer-backend.test.ts new file mode 100644 index 0000000..bfbd208 --- /dev/null +++ b/test/integration/renderer-backend.test.ts @@ -0,0 +1,245 @@ +import { mkdtemp, rm, stat } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { resolveProfile } from '../../src/renderer/profiles.js'; +import type { ReplayInput } from '../../src/renderer/types.js'; +import { GhosttyWebBackend } from '../../src/renderer/ghosttyWeb/index.js'; + +const PROFILE = resolveProfile('reference-dark'); +const SESSION_ID = 'renderer-backend-integration'; + +function timestampFor(seq: number): string { + return new Date(Date.UTC(2026, 2, 20, 12, 0, seq)).toISOString(); +} + +function createReplayInput( + events: ReplayInput['events'], + options: { + initialCols?: number; + initialRows?: number; + sessionId?: string; + targetSeq?: number; + } = {}, +): ReplayInput { + const targetSeq = options.targetSeq ?? events.at(-1)?.seq ?? -1; + + return { + sessionId: options.sessionId ?? SESSION_ID, + initialCols: options.initialCols ?? 80, + initialRows: options.initialRows ?? 24, + events, + targetSeq, + }; +} + +describe('GhosttyWebBackend integration', { timeout: 120_000 }, () => { + let backend: GhosttyWebBackend; + + beforeEach(() => { + backend = new GhosttyWebBackend(SESSION_ID, PROFILE); + }); + + afterEach(async () => { + await backend.dispose(); + }); + + it('boots and disposes cleanly', async () => { + expect(backend.isBooted).toBe(false); + + await backend.boot(); + + expect(backend.isBooted).toBe(true); + + await backend.dispose(); + + expect(backend.isBooted).toBe(false); + }); + + it('replays consecutive output events and flushes batches before target breaks', async () => { + await backend.boot(); + + const replayState = await backend.replayTo( + createReplayInput( + [ + { + seq: 0, + ts: timestampFor(0), + type: 'output', + payload: { data: 'hello ' }, + }, + { + seq: 1, + ts: timestampFor(1), + type: 'output', + payload: { data: 'from ' }, + }, + { + seq: 2, + ts: timestampFor(2), + type: 'output', + payload: { data: 'replay\r\n' }, + }, + { + seq: 3, + ts: timestampFor(3), + type: 'output', + payload: { data: 'should not be applied\r\n' }, + }, + ], + { targetSeq: 2 }, + ), + ); + + const snapshot = await backend.snapshot(); + const visibleText = snapshot.visibleLines + .map((line) => line.text) + .join('\n'); + + expect(replayState.lastSeq).toBe(2); + expect(snapshot.capturedAtSeq).toBe(2); + expect(visibleText).toContain('hello from replay'); + expect(visibleText).not.toContain('should not be applied'); + }); + + it('flushes output batches before resize events and preserves dimensions', async () => { + await backend.boot(); + + const replayState = await backend.replayTo( + createReplayInput([ + { + seq: 0, + ts: timestampFor(0), + type: 'output', + payload: { data: 'before resize\r\n' }, + }, + { + seq: 1, + ts: timestampFor(1), + type: 'resize', + payload: { cols: 40, rows: 12 }, + }, + { + seq: 2, + ts: timestampFor(2), + type: 'output', + payload: { data: 'after resize\r\n' }, + }, + ]), + ); + + const snapshot = await backend.snapshot(); + const visibleText = snapshot.visibleLines + .map((line) => line.text) + .join('\n'); + + expect(replayState.lastSeq).toBe(2); + expect(replayState.cols).toBe(40); + expect(replayState.rows).toBe(12); + expect(snapshot.cols).toBe(40); + expect(snapshot.rows).toBe(12); + expect(visibleText).toContain('before resize'); + expect(visibleText).toContain('after resize'); + }); + + it('ignores non-rendering replay event types without failing', async () => { + await backend.boot(); + + const replayState = await backend.replayTo( + createReplayInput([ + { + seq: 0, + ts: timestampFor(0), + type: 'output', + payload: { data: 'before ignored events\r\n' }, + }, + { + seq: 1, + ts: timestampFor(1), + type: 'input_text', + payload: { data: 'typed text' }, + }, + { + seq: 2, + ts: timestampFor(2), + type: 'input_keys', + payload: { keys: ['Enter'] }, + }, + { + seq: 3, + ts: timestampFor(3), + type: 'signal', + payload: { signal: 'SIGUSR1' }, + }, + ]), + ); + + const snapshot = await backend.snapshot(); + + expect(replayState.lastSeq).toBe(3); + expect( + snapshot.visibleLines.some((line) => + line.text.includes('before ignored events'), + ), + ).toBe(true); + }); + + it('returns visible text for the current viewport', async () => { + await backend.boot(); + await backend.replayTo( + createReplayInput([ + { + seq: 0, + ts: timestampFor(0), + type: 'output', + payload: { data: 'visible text marker\r\nsecond line\r\n' }, + }, + ]), + ); + + const visibleText = await backend.getVisibleText(); + + expect(visibleText).toContain('visible text marker'); + expect(visibleText).toContain('second line'); + }); + + it('captures screenshots to disk', async () => { + await backend.boot(); + await backend.replayTo( + createReplayInput([ + { + seq: 0, + ts: timestampFor(0), + type: 'output', + payload: { data: 'screenshot marker\r\n' }, + }, + ]), + ); + + const outputDir = await mkdtemp( + join(tmpdir(), 'agent-terminal-renderer-shot-'), + ); + const outputPath = join(outputDir, 'renderer.png'); + + try { + const screenshot = await backend.screenshot(outputPath); + const fileStats = await stat(outputPath); + + expect(screenshot.artifactPath).toBe(outputPath); + expect(screenshot.pngSizeBytes).toBeGreaterThan(0); + expect(fileStats.size).toBe(screenshot.pngSizeBytes); + } finally { + await rm(outputDir, { recursive: true, force: true }); + } + }); + + it('allows dispose to be called more than once', async () => { + await backend.boot(); + + await expect(backend.dispose()).resolves.toBeUndefined(); + await expect(backend.dispose()).resolves.toBeUndefined(); + expect(backend.isBooted).toBe(false); + }); +}); diff --git a/test/integration/wait-render.test.ts b/test/integration/wait-render.test.ts new file mode 100644 index 0000000..14a690d --- /dev/null +++ b/test/integration/wait-render.test.ts @@ -0,0 +1,322 @@ +import { mkdtemp } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { sendRpc } from '../../src/host/rpcClient.js'; +import type { WaitForRenderResult } from '../../src/protocol/messages.js'; +import { sessionDir, socketPath } from '../../src/storage/sessionPaths.js'; +import { + cleanupHome, + createSession, + destroySession, + readEvents, + runCli, + sleep, + type SuccessEnvelope, + type WaitResult, +} from '../helpers.js'; + +const SESSION_COMMAND = [ + '/bin/sh', + '-c', + "printf 'booting\\n'; sleep 1; printf '3 items\\n'; sleep 1; printf 'Ready\\n'; exec cat", +] as const; +const HOOK_TIMEOUT_MS = 30_000; + +interface ErrorEnvelope { + ok: false; + command: string; + timestamp: string; + error: { + code: string; + message: string; + retryable: boolean; + details?: Record; + }; +} + +async function waitForOutputMarker( + testHome: string, + sessionId: string, + marker: string, +): Promise { + const waitResult = runCli( + ['wait', sessionId, '--idle-ms', '200', '--timeout', '10000', '--json'], + { AGENT_TERMINAL_HOME: testHome }, + 15_000, + ); + + expect(waitResult.status).toBe(0); + expect(waitResult.stderr).toBe(''); + const waitEnvelope = JSON.parse( + waitResult.stdout, + ) as SuccessEnvelope; + expect(waitEnvelope.ok).toBe(true); + expect(waitEnvelope.result.timedOut).toBe(false); + + const deadline = Date.now() + 10_000; + while (Date.now() < deadline) { + const events = await readEvents(testHome, sessionId).catch(() => []); + const output = events + .filter((event) => event.type === 'output') + .map((event) => { + const data = event.payload.data; + return typeof data === 'string' ? data : ''; + }) + .join(''); + + if (output.includes(marker)) { + return; + } + + await sleep(100); + } + + throw new Error(`timed out waiting for output marker ${marker}`); +} + +describe('wait render integration', { timeout: 120_000 }, () => { + let testHome = ''; + let sessionId = ''; + let rpcSocketPath = ''; + + beforeEach(async () => { + testHome = await mkdtemp(join(tmpdir(), 'agent-terminal-wait-render-')); + sessionId = createSession(testHome, [...SESSION_COMMAND]); + await waitForOutputMarker(testHome, sessionId, 'booting'); + + const sessDir = sessionDir(testHome, sessionId); + rpcSocketPath = socketPath(sessDir); + }, HOOK_TIMEOUT_MS); + + afterEach(async () => { + destroySession(testHome, sessionId); + await cleanupHome(testHome); + sessionId = ''; + rpcSocketPath = ''; + testHome = ''; + }, HOOK_TIMEOUT_MS); + + it('matches text via waitForRender RPC', async () => { + const result = (await sendRpc( + rpcSocketPath, + 'waitForRender', + { text: 'Ready', timeoutMs: 15_000 }, + 20_000, + )) as WaitForRenderResult; + + expect(result.matched).toBe(true); + expect(result.timedOut).toBe(false); + expect(result.matchedText).toBe('Ready'); + expect(result.capturedAtSeq).toBeGreaterThanOrEqual(0); + }); + + it('matches regex via waitForRender RPC', async () => { + const result = (await sendRpc( + rpcSocketPath, + 'waitForRender', + { regex: '\\d+ items', timeoutMs: 15_000 }, + 20_000, + )) as WaitForRenderResult; + + expect(result.matched).toBe(true); + expect(result.timedOut).toBe(false); + expect(result.matchedText).toBe('3 items'); + expect(result.capturedAtSeq).toBeGreaterThanOrEqual(0); + }); + + it('times out when text is not found', async () => { + const result = (await sendRpc( + rpcSocketPath, + 'waitForRender', + { text: 'MISSING_TEXT', timeoutMs: 2_000 }, + 10_000, + )) as WaitForRenderResult; + + expect(result.matched).toBe(false); + expect(result.timedOut).toBe(true); + expect(result.capturedAtSeq).toBeGreaterThanOrEqual(0); + }); + + it('detects screen stability via waitForRender RPC', async () => { + await sendRpc( + rpcSocketPath, + 'waitForRender', + { text: 'Ready', timeoutMs: 15_000 }, + 20_000, + ); + + const result = (await sendRpc( + rpcSocketPath, + 'waitForRender', + { screenStableMs: 1_000, timeoutMs: 10_000 }, + 15_000, + )) as WaitForRenderResult; + + expect(result.matched).toBe(true); + expect(result.timedOut).toBe(false); + expect(result.capturedAtSeq).toBeGreaterThanOrEqual(0); + }); + + it('matches text AND screen stability together', async () => { + await sendRpc( + rpcSocketPath, + 'waitForRender', + { regex: '\\d+ items', timeoutMs: 15_000 }, + 20_000, + ); + + const result = (await sendRpc( + rpcSocketPath, + 'waitForRender', + { text: 'Ready', screenStableMs: 500, timeoutMs: 15_000 }, + 20_000, + )) as WaitForRenderResult; + + expect(result.matched).toBe(true); + expect(result.timedOut).toBe(false); + expect(result.matchedText).toBe('Ready'); + expect(result.capturedAtSeq).toBeGreaterThanOrEqual(0); + }); + + it('matches text via CLI --text', () => { + const result = runCli( + ['wait', sessionId, '--text', 'Ready', '--timeout', '15000', '--json'], + { AGENT_TERMINAL_HOME: testHome }, + 20_000, + ); + + expect(result.exitCode).toBe(0); + expect(result.stderr).toBe(''); + const envelope = JSON.parse( + result.stdout, + ) as SuccessEnvelope; + expect(envelope.ok).toBe(true); + expect(envelope.result.matched).toBe(true); + expect(envelope.result.timedOut).toBe(false); + expect(envelope.result.matchedText).toBe('Ready'); + }); + + it('matches regex via CLI --regex', () => { + const result = runCli( + [ + 'wait', + sessionId, + '--regex', + '\\d+ items', + '--timeout', + '15000', + '--json', + ], + { AGENT_TERMINAL_HOME: testHome }, + 20_000, + ); + + expect(result.exitCode).toBe(0); + expect(result.stderr).toBe(''); + const envelope = JSON.parse( + result.stdout, + ) as SuccessEnvelope; + expect(envelope.ok).toBe(true); + expect(envelope.result.matched).toBe(true); + expect(envelope.result.timedOut).toBe(false); + expect(envelope.result.matchedText).toBe('3 items'); + }); + + it('detects screen stability via CLI --screen-stable-ms', () => { + const readyResult = runCli( + ['wait', sessionId, '--text', 'Ready', '--timeout', '15000', '--json'], + { AGENT_TERMINAL_HOME: testHome }, + 20_000, + ); + + expect(readyResult.exitCode).toBe(0); + expect(readyResult.stderr).toBe(''); + + const result = runCli( + [ + 'wait', + sessionId, + '--screen-stable-ms', + '1000', + '--timeout', + '10000', + '--json', + ], + { AGENT_TERMINAL_HOME: testHome }, + 15_000, + ); + + expect(result.exitCode).toBe(0); + expect(result.stderr).toBe(''); + const envelope = JSON.parse( + result.stdout, + ) as SuccessEnvelope; + expect(envelope.ok).toBe(true); + expect(envelope.result.matched).toBe(true); + expect(envelope.result.timedOut).toBe(false); + expect(envelope.result.matchedText).toBeUndefined(); + expect(envelope.result.capturedAtSeq).toBeGreaterThanOrEqual(0); + }); + + it('rejects mixing --exit with --text', () => { + const result = runCli( + ['wait', sessionId, '--exit', '--text', 'Ready', '--json'], + { AGENT_TERMINAL_HOME: testHome }, + ); + + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toBe(''); + const envelope = JSON.parse(result.stdout) as ErrorEnvelope; + expect(envelope.ok).toBe(false); + expect(envelope.error.message).toContain('Cannot mix'); + }); + + it('rejects --text and --regex together', () => { + const result = runCli( + ['wait', sessionId, '--text', 'foo', '--regex', 'bar', '--json'], + { AGENT_TERMINAL_HOME: testHome }, + ); + + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toBe(''); + const envelope = JSON.parse(result.stdout) as ErrorEnvelope; + expect(envelope.ok).toBe(false); + expect(envelope.error.message).toContain('mutually exclusive'); + }); + + it('legacy wait --idle-ms still works', () => { + const result = runCli( + ['wait', sessionId, '--idle-ms', '300', '--timeout', '10000', '--json'], + { AGENT_TERMINAL_HOME: testHome }, + 15_000, + ); + + expect(result.exitCode).toBe(0); + expect(result.stderr).toBe(''); + const envelope = JSON.parse(result.stdout) as SuccessEnvelope; + expect(envelope.ok).toBe(true); + expect(envelope.result.timedOut).toBe(false); + }); + + it('legacy wait --exit still works', () => { + const shortSessionId = createSession(testHome, [ + '/bin/sh', + '-c', + 'echo done; exit 0', + ]); + const result = runCli( + ['wait', shortSessionId, '--exit', '--timeout', '10000', '--json'], + { AGENT_TERMINAL_HOME: testHome }, + 15_000, + ); + + expect(result.exitCode).toBe(0); + expect(result.stderr).toBe(''); + const envelope = JSON.parse(result.stdout) as SuccessEnvelope; + expect(envelope.ok).toBe(true); + expect(envelope.result.timedOut).toBe(false); + }); +}); diff --git a/test/unit/commands/doctor.test.ts b/test/unit/commands/doctor.test.ts index 4bc60b6..2472edd 100644 --- a/test/unit/commands/doctor.test.ts +++ b/test/unit/commands/doctor.test.ts @@ -1,14 +1,62 @@ import { describe, expect, it } from 'vitest'; -import { runBaselineDoctorChecks } from '../../../src/cli/commands/doctor.js'; +import { + buildDoctorLines, + runDoctorChecks, +} from '../../../src/cli/commands/doctor.js'; describe('doctor command', () => { - it('returns unique passing checks', async () => { - const result = await runBaselineDoctorChecks(); - const checkNames = result.checks.map((check) => check.name); + it('returns unique passing checks across environment and renderer groups', async () => { + const result = await runDoctorChecks(); + const allChecks = [...result.checks.environment, ...result.checks.renderer]; + const checkNames = allChecks.map((check) => check.name); - expect(checkNames.length).toBeGreaterThan(0); + expect(result.ok).toBe(true); + expect(result.checks.environment.length).toBeGreaterThan(0); + expect(result.checks.renderer.length).toBeGreaterThan(0); expect(new Set(checkNames).size).toBe(checkNames.length); - expect(result.checks.every((check) => check.ok)).toBe(true); + expect(allChecks.every((check) => check.status === 'pass')).toBe(true); + expect( + allChecks.every((check) => typeof check.durationMs === 'number'), + ).toBe(true); + }); + + it('formats grouped human-readable output', () => { + const lines = buildDoctorLines({ + ok: false, + checks: { + environment: [ + { + name: 'node-runtime', + status: 'pass', + message: 'Node v24.1.0 ok', + durationMs: 1, + }, + ], + renderer: [ + { + name: 'playwright_available', + status: 'fail', + message: 'playwright missing', + durationMs: 2, + }, + { + name: 'screenshot_viable', + status: 'skip', + message: 'not attempted', + durationMs: 3, + }, + ], + }, + }); + + expect(lines).toEqual([ + 'Environment:', + ' ✓ node: Node v24.1.0 ok', + '', + 'Renderer:', + ' ✗ playwright: playwright missing', + ' ○ screenshot: not attempted', + ]); }); }); diff --git a/test/unit/commands/inspect.test.ts b/test/unit/commands/inspect.test.ts new file mode 100644 index 0000000..0983d3c --- /dev/null +++ b/test/unit/commands/inspect.test.ts @@ -0,0 +1,144 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { ERROR_CODES, makeCliError } from '../../../src/protocol/errors.js'; + +const mocks = vi.hoisted(() => ({ + emitSuccess: vi.fn(), + reconcileSession: vi.fn(), + sendRpc: vi.fn(), + readManifest: vi.fn(), + readManifestIfExists: vi.fn(), + resolveHome: vi.fn(), + sessionDir: vi.fn(), + manifestPath: vi.fn(), + socketPath: vi.fn(), +})); + +vi.mock('../../../src/cli/output.js', () => ({ + emitSuccess: mocks.emitSuccess, +})); + +vi.mock('../../../src/host/lifecycle.js', () => ({ + reconcileSession: mocks.reconcileSession, +})); + +vi.mock('../../../src/host/rpcClient.js', () => ({ + sendRpc: mocks.sendRpc, +})); + +vi.mock('../../../src/storage/manifests.js', () => ({ + readManifest: mocks.readManifest, + readManifestIfExists: mocks.readManifestIfExists, +})); + +vi.mock('../../../src/storage/home.js', () => ({ + resolveHome: mocks.resolveHome, +})); + +vi.mock('../../../src/storage/sessionPaths.js', () => ({ + sessionDir: mocks.sessionDir, + manifestPath: mocks.manifestPath, + socketPath: mocks.socketPath, +})); + +import { runInspectCommand } from '../../../src/cli/commands/inspect.js'; + +function createSessionRecord( + status: 'running' | 'exiting' | 'exited' = 'running', +) { + return { + version: 1, + sessionId: 'session-01', + createdAt: '2026-03-19T12:00:00.000Z', + updatedAt: '2026-03-19T12:00:01.000Z', + status, + command: ['/bin/sh'], + cwd: '/tmp/workspace', + cols: 80, + rows: 24, + hostPid: status === 'exited' ? null : 123, + childPid: status === 'exited' ? null : 456, + exitCode: status === 'exited' ? 0 : null, + exitSignal: null, + }; +} + +describe('inspect command', () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.resolveHome.mockReturnValue('/tmp/agent-terminal'); + mocks.sessionDir.mockImplementation( + (_home: string, sessionId: string) => + `/tmp/agent-terminal/sessions/${sessionId}`, + ); + mocks.manifestPath.mockImplementation( + (sessionDirectory: string) => `${sessionDirectory}/session.json`, + ); + mocks.socketPath.mockImplementation( + (sessionDirectory: string) => `${sessionDirectory}/rpc.sock`, + ); + mocks.readManifestIfExists.mockResolvedValue( + createSessionRecord('running'), + ); + mocks.readManifest.mockResolvedValue(createSessionRecord('exited')); + mocks.reconcileSession.mockResolvedValue(undefined); + }); + + it('uses live RPC inspect data when the session is active', async () => { + const liveSession = createSessionRecord('running'); + mocks.sendRpc.mockResolvedValue({ session: liveSession }); + + await runInspectCommand({ json: false, sessionId: 'session-01' }); + + expect(mocks.sendRpc).toHaveBeenCalledWith( + '/tmp/agent-terminal/sessions/session-01/rpc.sock', + 'inspect', + ); + expect(mocks.emitSuccess).toHaveBeenCalledWith( + expect.objectContaining({ + command: 'inspect', + json: false, + result: { session: liveSession }, + }), + ); + }); + + it('rejects malformed inspect RPC responses', async () => { + mocks.sendRpc.mockResolvedValue({ session: { sessionId: 'session-01' } }); + + await expect( + runInspectCommand({ json: false, sessionId: 'session-01' }), + ).rejects.toMatchObject({ + code: ERROR_CODES.PROTOCOL_ERROR, + message: 'Unexpected response from host', + details: { + issues: expect.any(Array) as unknown, + }, + }); + expect(mocks.emitSuccess).not.toHaveBeenCalled(); + }); + + it('falls back to reconciled manifest data when the host is unreachable', async () => { + mocks.sendRpc.mockRejectedValue( + makeCliError(ERROR_CODES.HOST_UNREACHABLE, { + message: 'Session host is unreachable.', + }), + ); + + await runInspectCommand({ json: true, sessionId: 'session-01' }); + + expect(mocks.reconcileSession).toHaveBeenCalledWith( + '/tmp/agent-terminal/sessions/session-01', + ); + expect(mocks.readManifest).toHaveBeenCalledWith( + '/tmp/agent-terminal/sessions/session-01/session.json', + ); + expect(mocks.emitSuccess).toHaveBeenCalledWith( + expect.objectContaining({ + command: 'inspect', + json: true, + result: { session: createSessionRecord('exited') }, + }), + ); + }); +}); diff --git a/test/unit/commands/screenshot.test.ts b/test/unit/commands/screenshot.test.ts new file mode 100644 index 0000000..1914af8 --- /dev/null +++ b/test/unit/commands/screenshot.test.ts @@ -0,0 +1,208 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { ERROR_CODES } from '../../../src/protocol/errors.js'; + +const mocks = vi.hoisted(() => ({ + emitSuccess: vi.fn(), + sendRpc: vi.fn(), + readManifestIfExists: vi.fn(), + resolveHome: vi.fn(), + sessionDir: vi.fn(), + manifestPath: vi.fn(), + socketPath: vi.fn(), +})); + +vi.mock('../../../src/cli/output.js', () => ({ + emitSuccess: mocks.emitSuccess, +})); + +vi.mock('../../../src/host/rpcClient.js', () => ({ + sendRpc: mocks.sendRpc, +})); + +vi.mock('../../../src/storage/manifests.js', () => ({ + readManifestIfExists: mocks.readManifestIfExists, +})); + +vi.mock('../../../src/storage/home.js', () => ({ + resolveHome: mocks.resolveHome, +})); + +vi.mock('../../../src/storage/sessionPaths.js', () => ({ + sessionDir: mocks.sessionDir, + manifestPath: mocks.manifestPath, + socketPath: mocks.socketPath, +})); + +import { runScreenshotCommand } from '../../../src/cli/commands/screenshot.js'; + +function createRunningSessionRecord() { + return { + version: 1, + sessionId: 'session-01', + createdAt: '2026-03-19T12:00:00.000Z', + updatedAt: '2026-03-19T12:00:01.000Z', + status: 'running' as const, + command: ['/bin/sh'], + cwd: '/tmp/workspace', + cols: 80, + rows: 24, + hostPid: 123, + childPid: 456, + exitCode: null, + exitSignal: null, + }; +} + +describe('screenshot command', () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.resolveHome.mockReturnValue('/tmp/agent-terminal'); + mocks.sessionDir.mockImplementation( + (_home: string, sessionId: string) => + `/tmp/agent-terminal/sessions/${sessionId}`, + ); + mocks.manifestPath.mockImplementation( + (sessionDirectory: string) => `${sessionDirectory}/session.json`, + ); + mocks.socketPath.mockImplementation( + (sessionDirectory: string) => `${sessionDirectory}/rpc.sock`, + ); + mocks.readManifestIfExists.mockResolvedValue(createRunningSessionRecord()); + }); + + it('requests screenshots with the default render profile', async () => { + const result = { + sessionId: 'session-01', + capturedAtSeq: 12, + profileName: 'reference-dark', + cols: 120, + rows: 40, + artifactPath: '/tmp/snapshot.png', + pngSizeBytes: 2048, + }; + mocks.sendRpc.mockResolvedValue(result); + + await runScreenshotCommand({ + json: false, + sessionId: 'session-01', + }); + + expect(mocks.sendRpc).toHaveBeenCalledWith( + '/tmp/agent-terminal/sessions/session-01/rpc.sock', + 'screenshot', + { profile: 'reference-dark' }, + ); + expect(mocks.emitSuccess).toHaveBeenCalledWith({ + command: 'screenshot', + json: false, + result, + lines: [ + 'Session ID: session-01', + 'Captured At Seq: 12', + 'Profile: reference-dark', + 'Size: 120x40', + 'PNG Path: /tmp/snapshot.png', + 'PNG Size: 2048 bytes', + ], + }); + }); + + it('uses an explicit render profile and preserves JSON mode', async () => { + const result = { + sessionId: 'session-01', + capturedAtSeq: 22, + profileName: 'reference-light', + cols: 80, + rows: 24, + artifactPath: '/tmp/light.png', + pngSizeBytes: 1024, + }; + mocks.sendRpc.mockResolvedValue(result); + + await runScreenshotCommand({ + json: true, + sessionId: 'session-01', + profile: 'reference-light', + }); + + expect(mocks.sendRpc).toHaveBeenCalledWith( + '/tmp/agent-terminal/sessions/session-01/rpc.sock', + 'screenshot', + { profile: 'reference-light' }, + ); + expect(mocks.emitSuccess).toHaveBeenCalledWith({ + command: 'screenshot', + json: true, + result, + lines: [ + 'Session ID: session-01', + 'Captured At Seq: 22', + 'Profile: reference-light', + 'Size: 80x24', + 'PNG Path: /tmp/light.png', + 'PNG Size: 1024 bytes', + ], + }); + }); + + it('rejects malformed screenshot RPC responses', async () => { + mocks.sendRpc.mockResolvedValue({ + sessionId: 'session-01', + capturedAtSeq: 12, + profileName: 'reference-dark', + cols: 120, + rows: 40, + artifactPath: '/tmp/snapshot.png', + }); + + await expect( + runScreenshotCommand({ + json: false, + sessionId: 'session-01', + }), + ).rejects.toMatchObject({ + code: ERROR_CODES.PROTOCOL_ERROR, + message: 'Unexpected response from host', + details: { + issues: expect.any(Array) as unknown, + }, + }); + expect(mocks.emitSuccess).not.toHaveBeenCalled(); + }); + + it('rejects invalid session identifiers before reading the manifest', async () => { + mocks.sessionDir.mockImplementation(() => { + throw new Error('sessionId must not contain path separators'); + }); + + await expect( + runScreenshotCommand({ + json: false, + sessionId: '../bad-session', + }), + ).rejects.toMatchObject({ + code: ERROR_CODES.INVALID_SESSION_ID, + details: { + sessionId: '../bad-session', + }, + }); + expect(mocks.readManifestIfExists).not.toHaveBeenCalled(); + }); + + it('rejects empty screenshot profile names', async () => { + await expect( + runScreenshotCommand({ + json: false, + sessionId: 'session-01', + profile: '', + }), + ).rejects.toMatchObject({ + code: ERROR_CODES.INVALID_INPUT, + details: { + profile: '', + }, + }); + expect(mocks.sendRpc).not.toHaveBeenCalled(); + }); +}); diff --git a/test/unit/commands/snapshot.test.ts b/test/unit/commands/snapshot.test.ts new file mode 100644 index 0000000..e6b42a6 --- /dev/null +++ b/test/unit/commands/snapshot.test.ts @@ -0,0 +1,220 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { ERROR_CODES } from '../../../src/protocol/errors.js'; + +const mocks = vi.hoisted(() => ({ + emitSuccess: vi.fn(), + sendRpc: vi.fn(), + readManifestIfExists: vi.fn(), + resolveHome: vi.fn(), + sessionDir: vi.fn(), + manifestPath: vi.fn(), + socketPath: vi.fn(), +})); + +vi.mock('../../../src/cli/output.js', () => ({ + emitSuccess: mocks.emitSuccess, +})); + +vi.mock('../../../src/host/rpcClient.js', () => ({ + sendRpc: mocks.sendRpc, +})); + +vi.mock('../../../src/storage/manifests.js', () => ({ + readManifestIfExists: mocks.readManifestIfExists, +})); + +vi.mock('../../../src/storage/home.js', () => ({ + resolveHome: mocks.resolveHome, +})); + +vi.mock('../../../src/storage/sessionPaths.js', () => ({ + sessionDir: mocks.sessionDir, + manifestPath: mocks.manifestPath, + socketPath: mocks.socketPath, +})); + +import { runSnapshotCommand } from '../../../src/cli/commands/snapshot.js'; + +function createRunningSessionRecord() { + return { + version: 1, + sessionId: 'session-01', + createdAt: '2026-03-19T12:00:00.000Z', + updatedAt: '2026-03-19T12:00:01.000Z', + status: 'running' as const, + command: ['/bin/sh'], + cwd: '/tmp/workspace', + cols: 80, + rows: 24, + hostPid: 123, + childPid: 456, + exitCode: null, + exitSignal: null, + }; +} + +describe('snapshot command', () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.resolveHome.mockReturnValue('/tmp/agent-terminal'); + mocks.sessionDir.mockImplementation( + (_home: string, sessionId: string) => + `/tmp/agent-terminal/sessions/${sessionId}`, + ); + mocks.manifestPath.mockImplementation( + (sessionDirectory: string) => `${sessionDirectory}/session.json`, + ); + mocks.socketPath.mockImplementation( + (sessionDirectory: string) => `${sessionDirectory}/rpc.sock`, + ); + mocks.readManifestIfExists.mockResolvedValue(createRunningSessionRecord()); + }); + + it('requests structured snapshots by default and formats human output', async () => { + const result = { + format: 'structured' as const, + sessionId: 'session-01', + capturedAtSeq: 12, + cols: 120, + rows: 40, + cursorRow: 4, + cursorCol: 5, + isAltScreen: false, + visibleLines: [ + { row: 0, text: 'hello' }, + { row: 1, text: 'world' }, + ], + }; + mocks.sendRpc.mockResolvedValue(result); + + await runSnapshotCommand({ + json: false, + sessionId: 'session-01', + }); + + expect(mocks.sendRpc).toHaveBeenCalledWith( + '/tmp/agent-terminal/sessions/session-01/rpc.sock', + 'snapshot', + { format: 'structured' }, + ); + expect(mocks.emitSuccess).toHaveBeenCalledWith({ + command: 'snapshot', + json: false, + result, + lines: [ + 'Session ID: session-01', + 'Captured At Seq: 12', + 'Format: structured', + 'Size: 120x40', + 'Cursor: row 4, col 5', + 'Alt Screen: no', + 'Visible Lines (2):', + ' [0] hello', + ' [1] world', + ], + }); + }); + + it('requests text snapshots when asked and preserves JSON mode', async () => { + const result = { + format: 'text' as const, + sessionId: 'session-01', + capturedAtSeq: 7, + cols: 80, + rows: 24, + cursorRow: 2, + cursorCol: 3, + text: 'hello\nworld', + }; + mocks.sendRpc.mockResolvedValue(result); + + await runSnapshotCommand({ + json: true, + sessionId: 'session-01', + format: 'text', + }); + + expect(mocks.sendRpc).toHaveBeenCalledWith( + '/tmp/agent-terminal/sessions/session-01/rpc.sock', + 'snapshot', + { format: 'text' }, + ); + expect(mocks.emitSuccess).toHaveBeenCalledWith({ + command: 'snapshot', + json: true, + result, + lines: [ + 'Session ID: session-01', + 'Captured At Seq: 7', + 'Format: text', + 'Size: 80x24', + 'Cursor: row 2, col 3', + 'Text:', + 'hello\nworld', + ], + }); + }); + + it('rejects malformed snapshot RPC responses', async () => { + mocks.sendRpc.mockResolvedValue({ + format: 'structured', + sessionId: 'session-01', + capturedAtSeq: 12, + cols: 120, + rows: 40, + cursorRow: 4, + isAltScreen: false, + visibleLines: [], + }); + + await expect( + runSnapshotCommand({ + json: false, + sessionId: 'session-01', + }), + ).rejects.toMatchObject({ + code: ERROR_CODES.PROTOCOL_ERROR, + message: 'Unexpected response from host', + details: { + issues: expect.any(Array) as unknown, + }, + }); + expect(mocks.emitSuccess).not.toHaveBeenCalled(); + }); + + it('rejects invalid session identifiers before reading the manifest', async () => { + mocks.sessionDir.mockImplementation(() => { + throw new Error('sessionId must not contain path separators'); + }); + + await expect( + runSnapshotCommand({ + json: false, + sessionId: '../bad-session', + }), + ).rejects.toMatchObject({ + code: ERROR_CODES.INVALID_SESSION_ID, + details: { + sessionId: '../bad-session', + }, + }); + expect(mocks.readManifestIfExists).not.toHaveBeenCalled(); + }); + + it('rejects unsupported snapshot formats', async () => { + await expect( + runSnapshotCommand({ + json: false, + sessionId: 'session-01', + format: 'binary', + }), + ).rejects.toMatchObject({ + code: ERROR_CODES.INVALID_INPUT, + details: { + format: 'binary', + }, + }); + expect(mocks.sendRpc).not.toHaveBeenCalled(); + }); +}); diff --git a/test/unit/commands/wait.test.ts b/test/unit/commands/wait.test.ts new file mode 100644 index 0000000..55b843a --- /dev/null +++ b/test/unit/commands/wait.test.ts @@ -0,0 +1,401 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { ERROR_CODES, makeCliError } from '../../../src/protocol/errors.js'; + +const mocks = vi.hoisted(() => ({ + emitSuccess: vi.fn(), + sendRpc: vi.fn(), + readManifestIfExists: vi.fn(), + resolveHome: vi.fn(), + sessionDir: vi.fn(), + manifestPath: vi.fn(), + socketPath: vi.fn(), +})); + +vi.mock('../../../src/cli/output.js', () => ({ + emitSuccess: mocks.emitSuccess, +})); + +vi.mock('../../../src/host/rpcClient.js', () => ({ + sendRpc: mocks.sendRpc, +})); + +vi.mock('../../../src/storage/manifests.js', () => ({ + readManifestIfExists: mocks.readManifestIfExists, +})); + +vi.mock('../../../src/storage/home.js', () => ({ + resolveHome: mocks.resolveHome, +})); + +vi.mock('../../../src/storage/sessionPaths.js', () => ({ + sessionDir: mocks.sessionDir, + manifestPath: mocks.manifestPath, + socketPath: mocks.socketPath, +})); + +import { runWaitCommand } from '../../../src/cli/commands/wait.js'; + +function createSessionRecord( + status: 'running' | 'exited' = 'running', + exitCode: number | null = null, +) { + return { + version: 1, + sessionId: 'session-01', + createdAt: '2026-03-19T12:00:00.000Z', + updatedAt: '2026-03-19T12:00:01.000Z', + status, + command: ['/bin/sh'], + cwd: '/tmp/workspace', + cols: 80, + rows: 24, + hostPid: status === 'running' ? 123 : null, + childPid: status === 'running' ? 456 : null, + exitCode, + exitSignal: null, + }; +} + +function createOptions( + overrides: Partial[0]> = {}, +) { + return { + json: false, + sessionId: 'session-01', + waitForExit: false, + idleMs: undefined, + timeout: undefined, + text: undefined, + regex: undefined, + screenStableMs: undefined, + ...overrides, + }; +} + +describe('wait command', () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.resolveHome.mockReturnValue('/tmp/agent-terminal'); + mocks.sessionDir.mockImplementation( + (_home: string, sessionId: string) => + `/tmp/agent-terminal/sessions/${sessionId}`, + ); + mocks.manifestPath.mockImplementation( + (sessionDirectory: string) => `${sessionDirectory}/session.json`, + ); + mocks.socketPath.mockImplementation( + (sessionDirectory: string) => `${sessionDirectory}/rpc.sock`, + ); + mocks.readManifestIfExists.mockResolvedValue(createSessionRecord()); + }); + + it('rejects --text and --regex together', async () => { + await expect( + runWaitCommand(createOptions({ text: 'hello', regex: 'world' })), + ).rejects.toMatchObject({ + code: ERROR_CODES.INVALID_INPUT, + message: '--text and --regex are mutually exclusive.', + }); + expect(mocks.sendRpc).not.toHaveBeenCalled(); + }); + + it('rejects mixing --exit with render wait flags', async () => { + const promise = runWaitCommand( + createOptions({ waitForExit: true, text: 'hello' }), + ); + + await expect(promise).rejects.toMatchObject({ + code: ERROR_CODES.INVALID_INPUT, + }); + await expect(promise).rejects.toHaveProperty( + 'message', + expect.stringContaining('Cannot mix legacy wait flags'), + ); + expect(mocks.sendRpc).not.toHaveBeenCalled(); + }); + + it('rejects mixing --idle-ms with render wait flags', async () => { + const promise = runWaitCommand( + createOptions({ idleMs: 500, regex: '\\d+' }), + ); + + await expect(promise).rejects.toMatchObject({ + code: ERROR_CODES.INVALID_INPUT, + }); + await expect(promise).rejects.toHaveProperty( + 'message', + expect.stringContaining('Cannot mix legacy wait flags'), + ); + expect(mocks.sendRpc).not.toHaveBeenCalled(); + }); + + it('rejects negative --screen-stable-ms values', async () => { + await expect( + runWaitCommand(createOptions({ screenStableMs: -1 })), + ).rejects.toMatchObject({ + code: ERROR_CODES.INVALID_DURATION, + details: { screenStableMs: -1 }, + }); + expect(mocks.sendRpc).not.toHaveBeenCalled(); + }); + + it('rejects non-integer --screen-stable-ms values', async () => { + await expect( + runWaitCommand(createOptions({ screenStableMs: 1.5 })), + ).rejects.toMatchObject({ + code: ERROR_CODES.INVALID_DURATION, + details: { screenStableMs: 1.5 }, + }); + expect(mocks.sendRpc).not.toHaveBeenCalled(); + }); + + it('accepts --timeout 0 for infinite render waits', async () => { + const result = { + matched: true, + timedOut: false, + matchedText: 'hello', + capturedAtSeq: 12, + }; + mocks.sendRpc.mockResolvedValue(result); + + await runWaitCommand(createOptions({ text: 'hello', timeout: 0 })); + + expect(mocks.sendRpc).toHaveBeenCalledWith( + '/tmp/agent-terminal/sessions/session-01/rpc.sock', + 'waitForRender', + { + text: 'hello', + regex: undefined, + screenStableMs: undefined, + timeoutMs: undefined, + }, + 0, + ); + expect(mocks.emitSuccess).toHaveBeenCalledWith( + expect.objectContaining({ + command: 'wait', + result, + }), + ); + }); + + it('rejects negative --timeout values for render waits', async () => { + await expect( + runWaitCommand(createOptions({ text: 'hello', timeout: -1 })), + ).rejects.toMatchObject({ + code: ERROR_CODES.INVALID_DURATION, + details: { timeout: -1 }, + }); + expect(mocks.sendRpc).not.toHaveBeenCalled(); + }); + + it('requires one wait mode when no flags are provided', async () => { + await expect(runWaitCommand(createOptions())).rejects.toMatchObject({ + code: ERROR_CODES.INVALID_DURATION, + message: 'Specify exactly one of --exit or --idle-ms.', + }); + expect(mocks.sendRpc).not.toHaveBeenCalled(); + }); + + it('routes --exit waits to the legacy wait RPC', async () => { + const result = { timedOut: false, exitCode: 0 }; + mocks.sendRpc.mockResolvedValue(result); + + await runWaitCommand(createOptions({ waitForExit: true })); + + expect(mocks.sendRpc).toHaveBeenCalledWith( + '/tmp/agent-terminal/sessions/session-01/rpc.sock', + 'wait', + { + exit: true, + idleMs: undefined, + timeoutMs: 600_000, + }, + 605_000, + ); + expect(mocks.emitSuccess).toHaveBeenCalledWith( + expect.objectContaining({ + command: 'wait', + result, + }), + ); + }); + + it('routes --idle-ms waits to the legacy wait RPC', async () => { + const result = { timedOut: false }; + mocks.sendRpc.mockResolvedValue(result); + + await runWaitCommand(createOptions({ idleMs: 500 })); + + expect(mocks.sendRpc).toHaveBeenCalledWith( + '/tmp/agent-terminal/sessions/session-01/rpc.sock', + 'wait', + { + exit: undefined, + idleMs: 500, + timeoutMs: 600_000, + }, + 605_000, + ); + expect(mocks.emitSuccess).toHaveBeenCalledWith( + expect.objectContaining({ + command: 'wait', + result, + }), + ); + }); + + it('routes --text waits to the render wait RPC', async () => { + const result = { + matched: true, + timedOut: false, + matchedText: 'hello', + capturedAtSeq: 7, + }; + mocks.sendRpc.mockResolvedValue(result); + + await runWaitCommand(createOptions({ text: 'hello' })); + + expect(mocks.sendRpc).toHaveBeenCalledWith( + '/tmp/agent-terminal/sessions/session-01/rpc.sock', + 'waitForRender', + { + text: 'hello', + regex: undefined, + screenStableMs: undefined, + timeoutMs: 600_000, + }, + 605_000, + ); + expect(mocks.emitSuccess).toHaveBeenCalledWith( + expect.objectContaining({ + command: 'wait', + result, + }), + ); + }); + + it('routes --regex waits to the render wait RPC', async () => { + const result = { + matched: true, + timedOut: false, + matchedText: '42', + capturedAtSeq: 9, + }; + mocks.sendRpc.mockResolvedValue(result); + + await runWaitCommand(createOptions({ regex: '\\d+' })); + + expect(mocks.sendRpc).toHaveBeenCalledWith( + '/tmp/agent-terminal/sessions/session-01/rpc.sock', + 'waitForRender', + { + text: undefined, + regex: '\\d+', + screenStableMs: undefined, + timeoutMs: 600_000, + }, + 605_000, + ); + expect(mocks.emitSuccess).toHaveBeenCalledWith( + expect.objectContaining({ + command: 'wait', + result, + }), + ); + }); + + it('surfaces RPC timeout errors for render waits', async () => { + mocks.sendRpc.mockRejectedValue( + makeCliError(ERROR_CODES.HOST_TIMEOUT, { + message: 'Session host timed out.', + }), + ); + + await expect( + runWaitCommand(createOptions({ text: 'hello', timeout: 1 })), + ).rejects.toMatchObject({ + code: ERROR_CODES.HOST_TIMEOUT, + message: 'Session host timed out.', + }); + expect(mocks.emitSuccess).not.toHaveBeenCalled(); + }); + + it('rejects malformed legacy wait RPC responses', async () => { + mocks.sendRpc.mockResolvedValue({ + timedOut: false, + exitCode: 1.5, + }); + + await expect( + runWaitCommand(createOptions({ waitForExit: true })), + ).rejects.toMatchObject({ + code: ERROR_CODES.PROTOCOL_ERROR, + message: 'Unexpected response from host', + details: { + issues: expect.any(Array) as unknown, + }, + }); + expect(mocks.emitSuccess).not.toHaveBeenCalled(); + }); + + it('rejects malformed render wait RPC responses', async () => { + mocks.sendRpc.mockResolvedValue({ + matched: true, + timedOut: false, + capturedAtSeq: '7', + }); + + await expect( + runWaitCommand(createOptions({ text: 'hello' })), + ).rejects.toMatchObject({ + code: ERROR_CODES.PROTOCOL_ERROR, + message: 'Unexpected response from host', + details: { + issues: expect.any(Array) as unknown, + }, + }); + expect(mocks.emitSuccess).not.toHaveBeenCalled(); + }); + + it('rejects missing sessions before contacting RPC', async () => { + mocks.readManifestIfExists.mockResolvedValue(null); + + await expect( + runWaitCommand(createOptions({ waitForExit: true })), + ).rejects.toMatchObject({ + code: ERROR_CODES.SESSION_NOT_FOUND, + details: { + sessionId: 'session-01', + manifestPath: '/tmp/agent-terminal/sessions/session-01/session.json', + }, + }); + expect(mocks.sendRpc).not.toHaveBeenCalled(); + }); + + it('surfaces render wait errors when the session is no longer running', async () => { + mocks.readManifestIfExists.mockResolvedValue( + createSessionRecord('exited', 0), + ); + mocks.sendRpc.mockRejectedValue( + makeCliError(ERROR_CODES.SESSION_NOT_RUNNING, { + message: 'Session "session-01" is not running.', + details: { + sessionId: 'session-01', + status: 'exited', + }, + }), + ); + + await expect( + runWaitCommand(createOptions({ text: 'hello' })), + ).rejects.toMatchObject({ + code: ERROR_CODES.SESSION_NOT_RUNNING, + details: { + sessionId: 'session-01', + status: 'exited', + }, + }); + }); +}); diff --git a/test/unit/host/eventLog.test.ts b/test/unit/host/eventLog.test.ts new file mode 100644 index 0000000..1c9c1a1 --- /dev/null +++ b/test/unit/host/eventLog.test.ts @@ -0,0 +1,224 @@ +import { mkdtemp, readFile, rm, truncate, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { + EventLog, + MAX_EVENT_BUFFER_ENTRIES, +} from '../../../src/host/eventLog.js'; +import { MAX_EVENT_LOG_SIZE } from '../../../src/host/replay.js'; + +let tempDir = ''; +let eventLogPath = ''; + +describe('EventLog', () => { + beforeEach(async () => { + tempDir = await mkdtemp(join(tmpdir(), 'agent-terminal-event-log-')); + eventLogPath = join(tempDir, 'events.jsonl'); + await writeFile(eventLogPath, '', 'utf8'); + }); + + afterEach(async () => { + if (tempDir.length > 0) { + await rm(tempDir, { recursive: true, force: true }); + } + }); + + it('readAll returns validated events in contiguous order', async () => { + const eventLog = await EventLog.open(eventLogPath); + + try { + await eventLog.append('output', { data: 'hello' }); + await eventLog.append('resize', { cols: 100, rows: 30 }); + await eventLog.append('signal', { signal: 'SIGTERM' }); + + const events = await eventLog.readAll(); + expect(events.map((event) => event.seq)).toEqual([0, 1, 2]); + expect(events.map((event) => event.type)).toEqual([ + 'output', + 'resize', + 'signal', + ]); + } finally { + await eventLog.close(); + } + }); + + it('returns buffered events without rereading the log file', async () => { + const eventLog = await EventLog.open(eventLogPath); + + try { + await eventLog.append('output', { data: 'hello' }); + await writeFile( + eventLogPath, + [ + JSON.stringify({ + seq: 0, + ts: '2026-03-19T12:00:00.000Z', + type: 'output', + payload: { data: 'disk-only' }, + }), + JSON.stringify({ + seq: 2, + ts: '2026-03-19T12:00:01.000Z', + type: 'output', + payload: { data: 'gap' }, + }), + '', + ].join('\n'), + 'utf8', + ); + + expect(eventLog.getEvents().map((event) => event.payload)).toEqual([ + { data: 'hello' }, + ]); + expect(eventLog.getEventsSince(-1).map((event) => event.seq)).toEqual([ + 0, + ]); + expect(eventLog.getEventsSince(0)).toEqual([]); + await expect(eventLog.readAll()).resolves.toEqual(eventLog.getEvents()); + } finally { + await eventLog.close(); + } + }); + + it('hydrates the in-memory buffer from an existing log on open', async () => { + await writeFile( + eventLogPath, + [ + JSON.stringify({ + seq: 0, + ts: '2026-03-19T12:00:00.000Z', + type: 'output', + payload: { data: 'hello' }, + }), + JSON.stringify({ + seq: 1, + ts: '2026-03-19T12:00:01.000Z', + type: 'resize', + payload: { cols: 100, rows: 30 }, + }), + '', + ].join('\n'), + 'utf8', + ); + + const eventLog = await EventLog.open(eventLogPath); + + try { + expect(eventLog.getEvents().map((event) => event.seq)).toEqual([0, 1]); + expect(eventLog.getEventsSince(0).map((event) => event.type)).toEqual([ + 'resize', + ]); + + await eventLog.append('signal', { signal: 'SIGTERM' }); + + expect((await eventLog.readAll()).map((event) => event.seq)).toEqual([ + 0, 1, 2, + ]); + } finally { + await eventLog.close(); + } + }); + + it('rejects oversized logs before reading them into memory', async () => { + await truncate(eventLogPath, MAX_EVENT_LOG_SIZE + 1); + + await expect(EventLog.open(eventLogPath)).rejects.toThrow( + `event log file exceeds size limit (${String(MAX_EVENT_LOG_SIZE + 1)} bytes, max ${String(MAX_EVENT_LOG_SIZE)})`, + ); + }); + + it('rejects appends when the in-memory buffer reaches the runtime cap', async () => { + const eventLog = await EventLog.open(eventLogPath); + const eventLogInternals = eventLog as unknown as { + eventBuffer: unknown[]; + nextSeq: number; + }; + + try { + eventLogInternals.eventBuffer = new Array( + MAX_EVENT_BUFFER_ENTRIES, + ) as unknown[]; + eventLogInternals.nextSeq = MAX_EVENT_BUFFER_ENTRIES; + + await expect( + eventLog.append('output', { data: 'overflow' }), + ).rejects.toThrow( + `event buffer exceeds ${String(MAX_EVENT_BUFFER_ENTRIES)} entries; session event log is too large`, + ); + + expect(eventLogInternals.eventBuffer).toHaveLength( + MAX_EVENT_BUFFER_ENTRIES, + ); + expect(eventLogInternals.nextSeq).toBe(MAX_EVENT_BUFFER_ENTRIES); + } finally { + await eventLog.close(); + } + }); + + it('rolls back buffered events when append disk writes fail', async () => { + const eventLog = await EventLog.open(eventLogPath); + const eventLogInternals = eventLog as unknown as { + fileHandle: { + appendFile: (data: string, encoding: BufferEncoding) => Promise; + }; + nextSeq: number; + writeQueue: Promise; + }; + + try { + await eventLog.append('output', { data: 'persisted' }); + const appendFileSpy = vi + .spyOn(eventLogInternals.fileHandle, 'appendFile') + .mockRejectedValueOnce(new Error('disk full')); + + await expect( + eventLog.append('signal', { signal: 'SIGTERM' }), + ).rejects.toThrow('disk full'); + + expect(eventLog.getEvents().map((event) => event.seq)).toEqual([0]); + expect(eventLog.getEvents().map((event) => event.type)).toEqual([ + 'output', + ]); + expect(eventLogInternals.nextSeq).toBe(1); + + const logContent = await readFile(eventLogPath, 'utf8'); + expect(logContent).toContain('"seq":0'); + expect(logContent).not.toContain('"seq":1'); + + appendFileSpy.mockRestore(); + eventLogInternals.writeQueue = Promise.resolve(); + } finally { + await eventLog.close(); + } + }); + + it('rejects gaps in stored sequence numbers when opening the log', async () => { + await writeFile( + eventLogPath, + [ + JSON.stringify({ + seq: 0, + ts: '2026-03-19T12:00:00.000Z', + type: 'output', + payload: { data: 'hello' }, + }), + JSON.stringify({ + seq: 2, + ts: '2026-03-19T12:00:01.000Z', + type: 'output', + payload: { data: 'world' }, + }), + '', + ].join('\n'), + 'utf8', + ); + + await expect(EventLog.open(eventLogPath)).rejects.toThrow( + 'event log seq values must increase by 1 without gaps', + ); + }); +}); diff --git a/test/unit/host/hostMain.test.ts b/test/unit/host/hostMain.test.ts new file mode 100644 index 0000000..778893f --- /dev/null +++ b/test/unit/host/hostMain.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from 'vitest'; + +import { + hasNestedQuantifiers, + MAX_CONSECUTIVE_POLL_FAILURES, + MAX_WAIT_FOR_RENDER_REGEX_TEXT_LENGTH, + safeRegexExec, +} from '../../../src/host/hostMain.js'; + +describe('hasNestedQuantifiers', () => { + it('rejects regex patterns with nested quantifiers', () => { + expect(hasNestedQuantifiers('(a+)+')).toBe(true); + expect(hasNestedQuantifiers('(a*)+')).toBe(true); + expect(hasNestedQuantifiers('(a+)*')).toBe(true); + expect(hasNestedQuantifiers('(a?){2}')).toBe(true); + expect(hasNestedQuantifiers('(.*)+')).toBe(true); + expect(hasNestedQuantifiers('([^)]*)+')).toBe(true); + }); + + it('allows regex patterns without nested quantifiers', () => { + expect(hasNestedQuantifiers('a+')).toBe(false); + expect(hasNestedQuantifiers('(abc)+')).toBe(false); + expect(hasNestedQuantifiers('\\d{3}')).toBe(false); + expect(hasNestedQuantifiers('[a-z]+')).toBe(false); + expect(hasNestedQuantifiers('(a|b)+')).toBe(false); + }); +}); + +describe('waitForRender polling limits', () => { + it('exports the consecutive renderer failure cap', () => { + expect(MAX_CONSECUTIVE_POLL_FAILURES).toBe(10); + }); +}); + +describe('safeRegexExec', () => { + it('searches the full text under 50KB and truncates longer text to the first 50KB', () => { + const underLimitText = `${'a'.repeat(100)}Z`; + const withinLimitBoundaryText = `${'a'.repeat(MAX_WAIT_FOR_RENDER_REGEX_TEXT_LENGTH - 1)}Z`; + const beyondLimitBoundaryText = `${'a'.repeat(MAX_WAIT_FOR_RENDER_REGEX_TEXT_LENGTH + 1)}Z`; + + expect(safeRegexExec(/Z/u, underLimitText)?.index).toBe(100); + expect(safeRegexExec(/Z/u, withinLimitBoundaryText)?.index).toBe( + MAX_WAIT_FOR_RENDER_REGEX_TEXT_LENGTH - 1, + ); + expect(safeRegexExec(/Z/u, beyondLimitBoundaryText)).toBeNull(); + }); +}); diff --git a/test/unit/host/renderer.test.ts b/test/unit/host/renderer.test.ts new file mode 100644 index 0000000..f388bc1 --- /dev/null +++ b/test/unit/host/renderer.test.ts @@ -0,0 +1,391 @@ +import { access, mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { isAbsolute, join, relative } from 'node:path'; +import { setImmediate as setImmediatePromise } from 'node:timers/promises'; + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { HostRendererManager } from '../../../src/host/renderer.js'; +import type { RendererBackend } from '../../../src/renderer/backend.js'; +import type { + RenderProfileConfig, + ReplayInput, + ReplayState, + ScreenshotResult, + SemanticSnapshot, +} from '../../../src/renderer/types.js'; + +type MockFn = ReturnType; + +type FakeRendererBackend = RendererBackend & { + bootMock: MockFn; + replayToMock: MockFn; + snapshotMock: MockFn; + screenshotMock: MockFn; + getVisibleTextMock: MockFn; + disposeMock: MockFn; + setBooted: (value: boolean) => void; +}; + +function createProfile(name = 'default'): RenderProfileConfig { + return { + name, + theme: 'dark', + fontFamily: 'Fira Code', + fontSize: 14, + cursorStyle: 'block', + backgroundColor: '#000000', + foregroundColor: '#ffffff', + }; +} + +function createReplayInput(overrides: Partial = {}): ReplayInput { + return { + sessionId: 'session-01', + initialCols: 80, + initialRows: 24, + events: [ + { + seq: 0, + ts: '2026-03-20T12:00:00.000Z', + type: 'output', + payload: { data: 'hello world' }, + }, + ], + targetSeq: 0, + ...overrides, + }; +} + +function createFakeBackend( + options: { + bootImplementation?: () => Promise; + } = {}, +): FakeRendererBackend { + let booted = false; + const bootMock = vi.fn((): Promise => { + if (options.bootImplementation !== undefined) { + return options.bootImplementation(); + } + + booted = true; + return Promise.resolve(); + }); + const replayToMock = vi.fn( + (input: ReplayInput): Promise => + Promise.resolve({ + lastSeq: input.targetSeq, + cols: input.initialCols, + rows: input.initialRows, + cursorRow: 0, + cursorCol: 0, + }), + ); + const snapshotMock = vi.fn( + (): Promise => + Promise.resolve({ + sessionId: 'session-01', + capturedAtSeq: 0, + cols: 80, + rows: 24, + cursorRow: 0, + cursorCol: 0, + isAltScreen: false, + visibleLines: [], + }), + ); + const screenshotMock = vi.fn( + (outputPath: string): Promise => + Promise.resolve({ + sessionId: 'session-01', + capturedAtSeq: 0, + profileName: 'default', + cols: 80, + rows: 24, + artifactPath: outputPath, + pngSizeBytes: 1, + }), + ); + const getVisibleTextMock = vi.fn((): Promise => Promise.resolve('')); + const disposeMock = vi.fn((): Promise => { + booted = false; + return Promise.resolve(); + }); + + return { + boot: bootMock, + bootMock, + replayTo: replayToMock, + replayToMock, + snapshot: snapshotMock, + snapshotMock, + screenshot: screenshotMock, + screenshotMock, + getVisibleText: getVisibleTextMock, + getVisibleTextMock, + dispose: disposeMock, + disposeMock, + get isBooted() { + return booted; + }, + setBooted(value: boolean) { + booted = value; + }, + }; +} + +function createDeferred(): { + promise: Promise; + resolve: (value: T | PromiseLike) => void; + reject: (reason?: unknown) => void; +} { + let resolve!: (value: T | PromiseLike) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise; + reject = rejectPromise; + }); + return { promise, resolve, reject }; +} + +async function flushAsyncQueue(): Promise { + await setImmediatePromise(); +} + +function getCreatedBackend( + backends: FakeRendererBackend[], + index: number, +): FakeRendererBackend { + const backend = backends[index]; + expect(backend).toBeDefined(); + + if (backend === undefined) { + throw new Error(`expected backend ${String(index)} to exist`); + } + + return backend; +} + +type BackendFactory = ( + sessionId: string, + profile: RenderProfileConfig, +) => RendererBackend; + +describe('HostRendererManager', () => { + let sessionDir: string; + let backends: FakeRendererBackend[]; + let backendFactory: ReturnType>; + + beforeEach(async () => { + sessionDir = await mkdtemp(join(tmpdir(), 'agent-terminal-renderer-')); + backends = []; + backendFactory = vi.fn(() => { + const backend = createFakeBackend(); + backends.push(backend); + return backend; + }); + }); + + afterEach(async () => { + await rm(sessionDir, { recursive: true, force: true }); + }); + + it('lazily boots exactly once across concurrent getBackend calls', async () => { + const manager = new HostRendererManager({ + sessionId: 'session-01', + sessionDir, + backendFactory, + }); + const bootDeferred = createDeferred(); + + backendFactory.mockImplementationOnce(() => { + const backend = createFakeBackend({ + bootImplementation: () => + bootDeferred.promise.then(() => { + backend.setBooted(true); + }), + }); + backends.push(backend); + return backend; + }); + + const first = manager.getBackend(createProfile(), null); + const second = manager.getBackend(createProfile(), null); + + await flushAsyncQueue(); + + expect(backendFactory).toHaveBeenCalledTimes(1); + expect(getCreatedBackend(backends, 0).bootMock).toHaveBeenCalledTimes(1); + + bootDeferred.resolve(undefined); + + const [firstBackend, secondBackend] = await Promise.all([first, second]); + + expect(firstBackend).toBe(secondBackend); + expect(getCreatedBackend(backends, 0).bootMock).toHaveBeenCalledTimes(1); + }); + + it('reuses the backend for repeated requests with the same profile name', async () => { + const manager = new HostRendererManager({ + sessionId: 'session-01', + sessionDir, + backendFactory, + }); + + const firstBackend = await manager.getBackend(createProfile(), null); + const secondBackend = await manager.getBackend(createProfile(), null); + + expect(firstBackend).toBe(secondBackend); + expect(backendFactory).toHaveBeenCalledTimes(1); + }); + + it('disposes and recreates the backend when the profile name changes', async () => { + const manager = new HostRendererManager({ + sessionId: 'session-01', + sessionDir, + backendFactory, + }); + + const firstBackend = await manager.getBackend(createProfile('dark'), null); + const secondBackend = await manager.getBackend( + createProfile('light'), + null, + ); + + expect(secondBackend).not.toBe(firstBackend); + expect(backendFactory).toHaveBeenCalledTimes(2); + expect(getCreatedBackend(backends, 0).disposeMock).toHaveBeenCalledTimes(1); + expect(getCreatedBackend(backends, 1)).toBe(secondBackend); + }); + + it('skips replay when the replay target sequence is -1', async () => { + const manager = new HostRendererManager({ + sessionId: 'session-01', + sessionDir, + backendFactory, + }); + + await manager.getBackend( + createProfile(), + createReplayInput({ events: [], targetSeq: -1 }), + ); + + expect(getCreatedBackend(backends, 0).replayToMock).not.toHaveBeenCalled(); + }); + + it('replays to the requested target sequence when replay input is provided', async () => { + const manager = new HostRendererManager({ + sessionId: 'session-01', + sessionDir, + backendFactory, + }); + const replayInput = createReplayInput(); + + await manager.getBackend(createProfile(), replayInput); + + expect(getCreatedBackend(backends, 0).replayToMock).toHaveBeenCalledTimes( + 1, + ); + expect(getCreatedBackend(backends, 0).replayToMock).toHaveBeenCalledWith( + replayInput, + ); + }); + + it('rebuilds the backend after a crash leaves it unbooted', async () => { + const manager = new HostRendererManager({ + sessionId: 'session-01', + sessionDir, + backendFactory, + }); + + const firstBackend = await manager.getBackend(createProfile(), null); + const crashedBackend = getCreatedBackend(backends, 0); + expect(crashedBackend).toBe(firstBackend); + crashedBackend.setBooted(false); + + const secondBackend = await manager.getBackend(createProfile(), null); + + expect(secondBackend).not.toBe(firstBackend); + expect(backendFactory).toHaveBeenCalledTimes(2); + expect(crashedBackend.disposeMock).toHaveBeenCalledTimes(1); + }); + + it('makes dispose idempotent after a backend has been created', async () => { + const manager = new HostRendererManager({ + sessionId: 'session-01', + sessionDir, + backendFactory, + }); + + await manager.getBackend(createProfile(), null); + + await expect(manager.dispose()).resolves.toBeUndefined(); + await expect(manager.dispose()).resolves.toBeUndefined(); + + expect(getCreatedBackend(backends, 0).disposeMock).toHaveBeenCalledTimes(1); + }); + + it('allows dispose before any backend is created', async () => { + const manager = new HostRendererManager({ + sessionId: 'session-01', + sessionDir, + backendFactory, + }); + + await expect(manager.dispose()).resolves.toBeUndefined(); + expect(backendFactory).not.toHaveBeenCalled(); + }); + + it('allocates screenshot paths inside the session screenshots directory', async () => { + const manager = new HostRendererManager({ + sessionId: 'session-01', + sessionDir, + backendFactory, + }); + const nowSpy = vi.spyOn(Date, 'now').mockReturnValue(123456789); + + try { + const outputPath = manager.screenshotPath('default'); + + expect(isAbsolute(outputPath)).toBe(true); + expect(relative(sessionDir, outputPath)).toBe( + join('screenshots', 'default-123456789.png'), + ); + await expect( + access(join(sessionDir, 'screenshots')), + ).resolves.toBeUndefined(); + } finally { + nowSpy.mockRestore(); + } + }); + + it('validates constructor arguments', () => { + expect( + () => + new HostRendererManager({ + sessionId: '', + sessionDir, + backendFactory, + }), + ).toThrow('sessionId must be a non-empty string'); + expect( + () => + new HostRendererManager({ + sessionId: 'session-01', + sessionDir: 'relative/path', + backendFactory, + }), + ).toThrow('sessionDir must be an absolute path'); + expect( + () => + new HostRendererManager({ + sessionId: 'session-01', + sessionDir, + backendFactory: null as unknown as ( + sessionId: string, + profile: RenderProfileConfig, + ) => RendererBackend, + }), + ).toThrow('backendFactory must be a function'); + }); +}); diff --git a/test/unit/host/replay.test.ts b/test/unit/host/replay.test.ts new file mode 100644 index 0000000..0d23eb0 --- /dev/null +++ b/test/unit/host/replay.test.ts @@ -0,0 +1,168 @@ +import { mkdtemp, open, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { + MAX_EVENT_LOG_SIZE, + buildReplayInput, + readEventLogRecords, +} from '../../../src/host/replay.js'; +import type { + EventRecord, + SessionRecord, +} from '../../../src/protocol/schemas.js'; + +function createManifest(overrides: Partial = {}): SessionRecord { + return { + version: 1, + sessionId: 'session-01', + createdAt: '2026-03-19T12:00:00.000Z', + updatedAt: '2026-03-19T12:00:01.000Z', + status: 'running', + command: ['/bin/sh'], + cwd: '/tmp/workspace', + cols: 80, + rows: 24, + hostPid: 123, + childPid: 456, + exitCode: null, + exitSignal: null, + ...overrides, + }; +} + +function createEvents(): EventRecord[] { + return [ + { + seq: 0, + ts: '2026-03-19T12:00:02.000Z', + type: 'output', + payload: { data: 'hello' }, + }, + { + seq: 1, + ts: '2026-03-19T12:00:03.000Z', + type: 'resize', + payload: { cols: 100, rows: 30 }, + }, + ]; +} + +let tempDir = ''; +let eventLogPath = ''; + +describe('replay helpers', () => { + beforeEach(async () => { + tempDir = await mkdtemp(join(tmpdir(), 'agent-terminal-replay-')); + eventLogPath = join(tempDir, 'events.jsonl'); + }); + + afterEach(async () => { + if (tempDir.length > 0) { + await rm(tempDir, { recursive: true, force: true }); + } + }); + + it('buildReplayInput constructs a replay input from manifest and events', () => { + const replayInput = buildReplayInput( + 'session-01', + createManifest(), + createEvents(), + ); + + expect(replayInput).toEqual({ + sessionId: 'session-01', + initialCols: 80, + initialRows: 24, + events: createEvents(), + targetSeq: 1, + }); + }); + + it('buildReplayInput respects an explicit target sequence', () => { + const replayInput = buildReplayInput( + 'session-01', + createManifest(), + createEvents(), + 0, + ); + + expect(replayInput.targetSeq).toBe(0); + }); + + it('buildReplayInput rejects out-of-order sequences', () => { + const firstEvent = createEvents().at(0); + expect(firstEvent).toBeDefined(); + + if (firstEvent === undefined) { + return; + } + + expect(() => + buildReplayInput('session-01', createManifest(), [ + firstEvent, + { + seq: 3, + ts: '2026-03-19T12:00:04.000Z', + type: 'output', + payload: { data: 'world' }, + }, + ]), + ).toThrow('replay events must have contiguous seq values'); + }); + + it('buildReplayInput rejects invalid session identifiers and dimensions', () => { + expect(() => + buildReplayInput('', createManifest(), createEvents()), + ).toThrow('sessionId must be a non-empty string'); + expect(() => + buildReplayInput( + 'session-01', + createManifest({ cols: 0 }), + createEvents(), + ), + ).toThrow('manifest must match SessionRecordSchema'); + }); + + it('readEventLogRecords rejects event logs larger than 50 MB', async () => { + const fileHandle = await open(eventLogPath, 'w'); + + try { + await fileHandle.truncate(MAX_EVENT_LOG_SIZE + 1); + } finally { + await fileHandle.close(); + } + + await expect(readEventLogRecords(eventLogPath)).rejects.toThrow( + `event log file exceeds 50 MB size limit (${String(MAX_EVENT_LOG_SIZE + 1)} bytes)`, + ); + }); + + it('readEventLogRecords rejects malformed JSONL lines', async () => { + await writeFile( + eventLogPath, + `${JSON.stringify(createEvents()[0])}\n{"seq":1`, + 'utf8', + ); + + await expect(readEventLogRecords(eventLogPath)).rejects.toThrow( + 'event log line 2 must be valid JSON', + ); + }); + + it('readEventLogRecords parses and validates JSONL event logs', async () => { + await writeFile( + eventLogPath, + createEvents() + .map((event) => JSON.stringify(event)) + .concat('') + .join('\n'), + 'utf8', + ); + + const events = await readEventLogRecords(eventLogPath); + expect(events).toEqual(createEvents()); + }); +}); diff --git a/test/unit/protocol/messages.test.ts b/test/unit/protocol/messages.test.ts index c3288a3..0f76596 100644 --- a/test/unit/protocol/messages.test.ts +++ b/test/unit/protocol/messages.test.ts @@ -8,8 +8,14 @@ import { RpcMethodSchemas, RpcRequestSchema, RpcResponseSchema, + ScreenshotParamsSchema, + ScreenshotResultSchema, SendKeysParamsSchema, + SnapshotParamsSchema, + SnapshotResultSchema, TypeParamsSchema, + WaitForRenderParamsSchema, + WaitForRenderResultSchema, WaitParamsSchema, WaitResultSchema, } from '../../../src/protocol/messages.js'; @@ -63,12 +69,23 @@ describe('protocol schemas', () => { expect(result.success).toBe(true); }); + it('rejects an event record with a mismatched payload shape', () => { + const result = EventRecordSchema.safeParse({ + seq: 0, + ts: '2026-03-19T12:00:02.000Z', + type: 'resize', + payload: { cols: 120 }, + }); + + expect(result.success).toBe(false); + }); + it('rejects an event record with a negative sequence', () => { const result = EventRecordSchema.safeParse({ seq: -1, ts: '2026-03-19T12:00:02.000Z', type: 'resize', - payload: {}, + payload: { cols: 120, rows: 40 }, }); expect(result.success).toBe(false); @@ -136,6 +153,156 @@ describe('RPC message schemas', () => { expect(result.success).toBe(true); }); + it('accepts snapshot params and discriminated snapshot results', () => { + expect(SnapshotParamsSchema.safeParse({}).success).toBe(true); + expect(SnapshotParamsSchema.safeParse({ format: 'text' }).success).toBe( + true, + ); + expect( + SnapshotResultSchema.safeParse({ + format: 'structured', + sessionId: 'session-01', + capturedAtSeq: 5, + cols: 80, + rows: 24, + cursorRow: 2, + cursorCol: 4, + isAltScreen: false, + visibleLines: [ + { + row: 0, + text: 'hello', + }, + ], + }).success, + ).toBe(true); + expect( + SnapshotResultSchema.safeParse({ + format: 'text', + sessionId: 'session-01', + capturedAtSeq: 5, + cols: 80, + rows: 24, + cursorRow: 2, + cursorCol: 4, + text: 'hello\nworld', + }).success, + ).toBe(true); + }); + + it('rejects snapshot results with invalid discriminants or extra fields', () => { + expect( + SnapshotResultSchema.safeParse({ + format: 'structured', + sessionId: 'session-01', + capturedAtSeq: 5, + cols: 80, + rows: 24, + cursorRow: 2, + cursorCol: 4, + isAltScreen: false, + visibleLines: [], + text: 'unexpected', + }).success, + ).toBe(false); + expect( + SnapshotResultSchema.safeParse({ + format: 'binary', + }).success, + ).toBe(false); + }); + + it('accepts screenshot params and results', () => { + expect(ScreenshotParamsSchema.safeParse({}).success).toBe(true); + expect( + ScreenshotParamsSchema.safeParse({ profile: 'reference-dark' }).success, + ).toBe(true); + expect( + ScreenshotResultSchema.safeParse({ + sessionId: 'session-01', + capturedAtSeq: 5, + profileName: 'reference-dark', + cols: 80, + rows: 24, + artifactPath: '/tmp/screenshot.png', + pngSizeBytes: 1024, + }).success, + ).toBe(true); + }); + + it('rejects empty screenshot profile names', () => { + expect(ScreenshotParamsSchema.safeParse({ profile: '' }).success).toBe( + false, + ); + }); + + it('accepts screenshot profiles at the maximum length', () => { + expect( + ScreenshotParamsSchema.safeParse({ profile: 'x'.repeat(100) }).success, + ).toBe(true); + }); + + it('rejects screenshot profiles beyond the maximum length', () => { + expect( + ScreenshotParamsSchema.safeParse({ profile: 'x'.repeat(101) }).success, + ).toBe(false); + }); + + it('accepts waitForRender text and regex at their maximum lengths', () => { + expect( + WaitForRenderParamsSchema.safeParse({ text: 'x'.repeat(1000) }).success, + ).toBe(true); + expect( + WaitForRenderParamsSchema.safeParse({ regex: 'x'.repeat(200) }).success, + ).toBe(true); + }); + + it('rejects waitForRender text and regex beyond their maximum lengths', () => { + expect( + WaitForRenderParamsSchema.safeParse({ text: 'x'.repeat(1001) }).success, + ).toBe(false); + expect( + WaitForRenderParamsSchema.safeParse({ regex: 'x'.repeat(201) }).success, + ).toBe(false); + }); + + it('accepts waitForRender params for text, regex, and stable-screen waits', () => { + expect( + WaitForRenderParamsSchema.safeParse({ text: 'Ready', timeoutMs: 1000 }) + .success, + ).toBe(true); + expect( + WaitForRenderParamsSchema.safeParse({ regex: 'Ready|Done' }).success, + ).toBe(true); + expect( + WaitForRenderParamsSchema.safeParse({ screenStableMs: 250 }).success, + ).toBe(true); + }); + + it('rejects invalid waitForRender params', () => { + expect(WaitForRenderParamsSchema.safeParse({}).success).toBe(false); + expect( + WaitForRenderParamsSchema.safeParse({ + text: 'Ready', + regex: 'Done', + }).success, + ).toBe(false); + expect( + WaitForRenderParamsSchema.safeParse({ screenStableMs: 0 }).success, + ).toBe(false); + }); + + it('accepts waitForRender results with replay metadata', () => { + expect( + WaitForRenderResultSchema.safeParse({ + matched: true, + timedOut: false, + matchedText: 'Ready', + capturedAtSeq: 7, + }).success, + ).toBe(true); + }); + it('rejects empty key arrays for sendKeys', () => { const result = SendKeysParamsSchema.safeParse({ keys: [], @@ -206,15 +373,18 @@ describe('RPC message schemas', () => { expect(DestroyParamsSchema.safeParse({ force: true }).success).toBe(true); }); - it('exposes method schemas for every Week 1 RPC method', () => { + it('exposes method schemas for every RPC method', () => { expect(Object.keys(RpcMethodSchemas)).toEqual([ 'inspect', + 'snapshot', + 'screenshot', 'type', 'paste', 'sendKeys', 'resize', 'signal', 'wait', + 'waitForRender', 'destroy', ]); }); diff --git a/test/unit/renderer/ghosttyWebBackend.test.ts b/test/unit/renderer/ghosttyWebBackend.test.ts new file mode 100644 index 0000000..e15b2c0 --- /dev/null +++ b/test/unit/renderer/ghosttyWebBackend.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { resolveProfile } from '../../../src/renderer/profiles.js'; +import { GhosttyWebBackend } from '../../../src/renderer/ghosttyWeb/index.js'; + +const PROFILE = resolveProfile('reference-dark'); + +function createBackend(): GhosttyWebBackend { + return new GhosttyWebBackend('renderer-unit-session', PROFILE); +} + +describe('GhosttyWebBackend unit guards', () => { + it('splits large output batches before bridging them into the page', async () => { + const backend = createBackend(); + const recordedBatchSizes: number[] = []; + const recordedChunks: string[] = []; + + ( + backend as unknown as { + writeBatchBridge: (page: object, dataChunks: string[]) => Promise; + } + ).writeBatchBridge = vi.fn((_page: object, dataChunks: string[]) => { + recordedBatchSizes.push(dataChunks.length); + recordedChunks.push(...dataChunks); + return Promise.resolve(); + }); + + const chunks = Array.from( + { length: 2_501 }, + (_, index) => `chunk-${String(index)}`, + ); + + await ( + backend as unknown as { + flushOutputBatch: (page: object, dataChunks: string[]) => Promise; + } + ).flushOutputBatch({}, chunks); + + expect(recordedBatchSizes).toEqual([1000, 1000, 501]); + expect(recordedChunks).toEqual(chunks); + }); + + it('rejects oversized bridge batches before page evaluation', async () => { + const backend = createBackend(); + const evaluate = vi.fn(); + + await expect( + ( + backend as unknown as { + writeBatchBridge: ( + page: { evaluate: typeof evaluate }, + dataChunks: string[], + ) => Promise; + } + ).writeBatchBridge( + { evaluate }, + Array.from({ length: 1001 }, () => 'x'), + ), + ).rejects.toThrow( + 'writeBatchBridge batch size must not exceed MAX_REPLAY_BATCH_SIZE', + ); + expect(evaluate).not.toHaveBeenCalled(); + }); + + it('times out screenshot paint waits after 5 seconds', async () => { + vi.useFakeTimers(); + const backend = createBackend(); + const evaluate = vi.fn(() => new Promise(() => {})); + + try { + const waitForPaintPromise = ( + backend as unknown as { + waitForScreenshotPaint: (page: { + evaluate: typeof evaluate; + }) => Promise; + } + ).waitForScreenshotPaint({ evaluate }); + const rejectionExpectation = expect(waitForPaintPromise).rejects.toThrow( + 'Screenshot paint wait timed out after 5s', + ); + + await vi.advanceTimersByTimeAsync(5_000); + await rejectionExpectation; + expect(evaluate).toHaveBeenCalledTimes(1); + } finally { + vi.useRealTimers(); + } + }); +}); diff --git a/test/unit/renderer/profiles.test.ts b/test/unit/renderer/profiles.test.ts new file mode 100644 index 0000000..37774f7 --- /dev/null +++ b/test/unit/renderer/profiles.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, it } from 'vitest'; + +import { + BUILTIN_PROFILE_NAMES, + getBuiltinProfile, + resolveProfile, +} from '../../../src/renderer/profiles.js'; + +describe('renderer profiles', () => { + it('exposes the built-in reference profiles', () => { + expect(BUILTIN_PROFILE_NAMES).toEqual([ + 'reference-dark', + 'reference-light', + ]); + }); + + it('returns cloned built-in profiles by name', () => { + const profile = getBuiltinProfile('reference-dark'); + + expect(profile).toEqual({ + name: 'reference-dark', + theme: 'dark', + fontFamily: 'monospace', + fontSize: 14, + cursorStyle: 'block', + backgroundColor: '#1e1e2e', + foregroundColor: '#cdd6f4', + }); + + expect(profile).not.toBeUndefined(); + + const secondRead = getBuiltinProfile('reference-dark'); + expect(secondRead).not.toBeUndefined(); + + if (profile === undefined || secondRead === undefined) { + throw new Error('expected reference-dark to be available'); + } + + profile.fontFamily = 'mutated'; + + expect(secondRead.fontFamily).toBe('monospace'); + }); + + it('resolves built-in and custom profiles', () => { + expect(resolveProfile('reference-dark')).toEqual({ + name: 'reference-dark', + theme: 'dark', + fontFamily: 'monospace', + fontSize: 14, + cursorStyle: 'block', + backgroundColor: '#1e1e2e', + foregroundColor: '#cdd6f4', + }); + + expect( + resolveProfile({ + name: 'custom', + theme: 'light', + fontFamily: 'monospace', + fontSize: 16, + cursorStyle: 'underline', + backgroundColor: '#ffffff', + foregroundColor: '#000000', + }), + ).toEqual({ + name: 'custom', + theme: 'light', + fontFamily: 'monospace', + fontSize: 16, + cursorStyle: 'underline', + backgroundColor: '#ffffff', + foregroundColor: '#000000', + }); + }); + + it('throws clearly for unknown or invalid profiles', () => { + expect(() => resolveProfile('nonexistent')).toThrow( + /unknown render profile: nonexistent/u, + ); + expect(() => resolveProfile('')).toThrow( + /profile name must be a non-empty string/u, + ); + expect(() => + resolveProfile({ + name: 'broken', + theme: 'dark', + fontFamily: 'monospace', + fontSize: 0, + cursorStyle: 'block', + backgroundColor: '#1e1e2e', + foregroundColor: '#cdd6f4', + }), + ).toThrow(/Too small/u); + }); +}); diff --git a/test/unit/renderer/types.test.ts b/test/unit/renderer/types.test.ts new file mode 100644 index 0000000..606a8e0 --- /dev/null +++ b/test/unit/renderer/types.test.ts @@ -0,0 +1,219 @@ +import { describe, expect, it } from 'vitest'; + +import { + ReplayEventSchema, + ReplayInputSchema, + RenderProfileConfigSchema, + ScreenshotResultSchema, + SemanticSnapshotSchema, + TextSnapshotSchema, +} from '../../../src/renderer/types.js'; + +function createReplayEvents() { + return [ + { + seq: 0, + ts: '2026-03-19T12:00:00.000Z', + type: 'output' as const, + payload: { data: 'hello' }, + }, + { + seq: 1, + ts: '2026-03-19T12:00:01.000Z', + type: 'resize' as const, + payload: { cols: 100, rows: 30 }, + }, + { + seq: 2, + ts: '2026-03-19T12:00:02.000Z', + type: 'input_keys' as const, + payload: { keys: ['Enter'] }, + }, + { + seq: 3, + ts: '2026-03-19T12:00:03.000Z', + type: 'exit' as const, + payload: { exitCode: 0, exitSignal: null }, + }, + ]; +} + +describe('renderer schemas', () => { + it('accepts replay events for every supported event shape', () => { + const events = [ + { + seq: 0, + ts: '2026-03-19T12:00:00.000Z', + type: 'output', + payload: { data: 'stdout' }, + }, + { + seq: 1, + ts: '2026-03-19T12:00:01.000Z', + type: 'input_text', + payload: { data: 'ls' }, + }, + { + seq: 2, + ts: '2026-03-19T12:00:02.000Z', + type: 'input_paste', + payload: { data: 'echo hi' }, + }, + { + seq: 3, + ts: '2026-03-19T12:00:03.000Z', + type: 'input_keys', + payload: { keys: ['Ctrl+C'] }, + }, + { + seq: 4, + ts: '2026-03-19T12:00:04.000Z', + type: 'resize', + payload: { cols: 120, rows: 40 }, + }, + { + seq: 5, + ts: '2026-03-19T12:00:05.000Z', + type: 'signal', + payload: { signal: 'SIGINT' }, + }, + { + seq: 6, + ts: '2026-03-19T12:00:06.000Z', + type: 'exit', + payload: { exitCode: 0, exitSignal: null }, + }, + ]; + + for (const event of events) { + expect(ReplayEventSchema.safeParse(event).success).toBe(true); + } + }); + + it('rejects replay events with mismatched payloads', () => { + const result = ReplayEventSchema.safeParse({ + seq: 0, + ts: '2026-03-19T12:00:00.000Z', + type: 'resize', + payload: { data: 'nope' }, + }); + + expect(result.success).toBe(false); + }); + + it('accepts a valid replay input', () => { + const result = ReplayInputSchema.safeParse({ + sessionId: 'session-01', + initialCols: 80, + initialRows: 24, + events: createReplayEvents(), + targetSeq: 3, + }); + + expect(result.success).toBe(true); + }); + + it('rejects replay inputs with invalid construction invariants', () => { + expect( + ReplayInputSchema.safeParse({ + sessionId: '', + initialCols: 80, + initialRows: 24, + events: createReplayEvents(), + targetSeq: 3, + }).success, + ).toBe(false); + expect( + ReplayInputSchema.safeParse({ + sessionId: 'session-01', + initialCols: 0, + initialRows: 24, + events: createReplayEvents(), + targetSeq: 3, + }).success, + ).toBe(false); + expect( + ReplayInputSchema.safeParse({ + sessionId: 'session-01', + initialCols: 80, + initialRows: 24, + events: [createReplayEvents()[1], createReplayEvents()[0]], + targetSeq: 3, + }).success, + ).toBe(false); + expect( + ReplayInputSchema.safeParse({ + sessionId: 'session-01', + initialCols: 80, + initialRows: 24, + events: createReplayEvents(), + targetSeq: -1, + }).success, + ).toBe(false); + }); + + it('accepts semantic snapshots, text snapshots, screenshots, and profiles', () => { + expect( + SemanticSnapshotSchema.safeParse({ + sessionId: 'session-01', + capturedAtSeq: 3, + cols: 80, + rows: 24, + cursorRow: 2, + cursorCol: 4, + isAltScreen: false, + visibleLines: [ + { row: 0, text: '$ echo hello' }, + { row: 1, text: 'hello' }, + ], + }).success, + ).toBe(true); + expect( + TextSnapshotSchema.safeParse({ + sessionId: 'session-01', + capturedAtSeq: 3, + cols: 80, + rows: 24, + cursorRow: 2, + cursorCol: 4, + text: '$ echo hello\nhello', + }).success, + ).toBe(true); + expect( + ScreenshotResultSchema.safeParse({ + sessionId: 'session-01', + capturedAtSeq: 3, + profileName: 'reference-dark', + cols: 80, + rows: 24, + artifactPath: '/tmp/screenshot.png', + pngSizeBytes: 1024, + }).success, + ).toBe(true); + expect( + RenderProfileConfigSchema.safeParse({ + name: 'custom-profile', + theme: 'dark', + fontFamily: 'monospace', + fontSize: 14, + cursorStyle: 'block', + backgroundColor: '#1e1e2e', + foregroundColor: '#cdd6f4', + }).success, + ).toBe(true); + }); + + it('rejects invalid render profile colors', () => { + const result = RenderProfileConfigSchema.safeParse({ + name: 'broken-profile', + theme: 'dark', + fontFamily: 'monospace', + fontSize: 14, + cursorStyle: 'block', + backgroundColor: 'blue', + foregroundColor: '#cdd6f4', + }); + + expect(result.success).toBe(false); + }); +}); diff --git a/test/unit/storage/artifactStorage.test.ts b/test/unit/storage/artifactStorage.test.ts new file mode 100644 index 0000000..4d8f020 --- /dev/null +++ b/test/unit/storage/artifactStorage.test.ts @@ -0,0 +1,182 @@ +import { access, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { afterEach, describe, expect, it } from 'vitest'; + +import type { + ArtifactEntry, + ArtifactManifest, +} from '../../../src/storage/artifactManifest.js'; +import { + appendArtifact, + readArtifactManifest, + writeArtifactManifest, +} from '../../../src/storage/artifactManifest.js'; +import { + artifactPath, + ensureArtifactsDir, + screenshotFilename, + snapshotFilename, +} from '../../../src/storage/artifactPaths.js'; + +const temporaryDirectories: string[] = []; + +afterEach(async () => { + await Promise.all( + temporaryDirectories + .splice(0) + .map((directory) => rm(directory, { recursive: true, force: true })), + ); +}); + +async function createSessionDir(sessionId = 'session-01'): Promise { + const home = await mkdtemp(join(tmpdir(), 'agent-terminal-artifacts-')); + temporaryDirectories.push(home); + return join(home, sessionId); +} + +function createArtifactEntry( + overrides: Partial = {}, +): ArtifactEntry { + return { + id: '01JQ0000000000000000000000', + kind: 'snapshot', + filename: 'snapshot-4-structured.json', + sessionId: 'session-01', + capturedAtSeq: 4, + createdAt: '2026-03-20T12:00:00.000Z', + metadata: { + format: 'structured', + cols: 80, + rows: 24, + cursorRow: 1, + cursorCol: 2, + }, + ...overrides, + }; +} + +describe('artifact paths', () => { + it('builds deterministic sanitized filenames and session artifact paths', async () => { + const sessionDir = await createSessionDir(); + const screenshot = screenshotFilename(7, 'reference dark / baseline'); + const snapshot = snapshotFilename(7, 'structured'); + + expect(screenshot).toBe('screenshot-7-reference-dark-baseline.png'); + expect(snapshot).toBe('snapshot-7-structured.json'); + expect(artifactPath(sessionDir, screenshot)).toBe( + join(sessionDir, 'artifacts', screenshot), + ); + + const artifactsDir = await ensureArtifactsDir(sessionDir); + + expect(artifactsDir).toBe(join(sessionDir, 'artifacts')); + await expect(access(artifactsDir)).resolves.toBeUndefined(); + }); + + it('asserts on invalid helper inputs', () => { + expect(() => screenshotFilename(-1, 'reference-dark')).toThrow( + /seq must be a non-negative integer/u, + ); + expect(() => screenshotFilename(0, '')).toThrow( + /profileName must be a non-empty string/u, + ); + expect(() => artifactPath('relative/session', 'capture.png')).toThrow( + /sessionDir must be an absolute path/u, + ); + expect(() => artifactPath('/tmp/session-01', 'nested/capture.png')).toThrow( + /filename must not contain path separators/u, + ); + }); +}); + +describe('artifact manifest storage', () => { + it('returns an empty manifest when none exists and appends new artifacts', async () => { + const sessionDir = await createSessionDir(); + + await expect(readArtifactManifest(sessionDir)).resolves.toEqual({ + version: 1, + sessionId: 'session-01', + artifacts: [], + }); + + await appendArtifact( + sessionDir, + createArtifactEntry({ + id: '01JQ0000000000000000000001', + kind: 'screenshot', + filename: 'screenshot-4-reference-dark.png', + metadata: { + profileName: 'reference-dark', + cols: 80, + rows: 24, + pngSizeBytes: 2048, + }, + }), + ); + + await expect(readArtifactManifest(sessionDir)).resolves.toEqual({ + version: 1, + sessionId: 'session-01', + artifacts: [ + createArtifactEntry({ + id: '01JQ0000000000000000000001', + kind: 'screenshot', + filename: 'screenshot-4-reference-dark.png', + metadata: { + profileName: 'reference-dark', + cols: 80, + rows: 24, + pngSizeBytes: 2048, + }, + }), + ], + }); + }); + + it('writes and reads artifact manifests with validation', async () => { + const sessionDir = await createSessionDir(); + const manifest: ArtifactManifest = { + version: 1, + sessionId: 'session-01', + artifacts: [createArtifactEntry()], + }; + + await writeArtifactManifest(sessionDir, manifest); + + await expect(readArtifactManifest(sessionDir)).resolves.toEqual(manifest); + await expect( + readFile(artifactPath(sessionDir, 'manifest.json'), 'utf8'), + ).resolves.toMatch(/\n$/u); + }); + + it('rejects invalid manifest contents and mismatched entries', async () => { + const sessionDir = await createSessionDir(); + + await ensureArtifactsDir(sessionDir); + await writeFile( + artifactPath(sessionDir, 'manifest.json'), + JSON.stringify({ + version: 1, + sessionId: 'other-session', + artifacts: [], + }), + 'utf8', + ); + + await expect(readArtifactManifest(sessionDir)).rejects.toMatchObject({ + code: 'MANIFEST_VALIDATION_ERROR', + }); + await expect( + appendArtifact( + sessionDir, + createArtifactEntry({ + sessionId: 'other-session', + }), + ), + ).rejects.toMatchObject({ + code: 'MANIFEST_VALIDATION_ERROR', + }); + }); +});