From 6fb2f511dd6ac7a0708a44fb47b31d6232a7a857 Mon Sep 17 00:00:00 2001 From: Jacob Bolda Date: Sat, 18 Jul 2026 23:36:41 -0500 Subject: [PATCH 1/4] new workflow proposal --- .github/workflows/check.yml | 51 ++++++++++++++++++++++++++++++++++++ .github/workflows/format.yml | 13 --------- package.json | 1 + 3 files changed, 52 insertions(+), 13 deletions(-) create mode 100644 .github/workflows/check.yml delete mode 100644 .github/workflows/format.yml diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml new file mode 100644 index 0000000..82cb009 --- /dev/null +++ b/.github/workflows/check.yml @@ -0,0 +1,51 @@ +name: check + +on: + push: + branches: [main] + pull_request: + +permissions: {} + +jobs: + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + - uses: jdx/mise-action@dad1bfd3df957f44999b559dd69dc1671cb4e9ea # v4.2.1 + with: + mise_toml: | + [settings] + idiomatic_version_file_enable_tools = ["node", "pnpm"] + - run: pnpm install --frozen-lockfile + - run: pnpm lint + + format: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + - uses: jdx/mise-action@dad1bfd3df957f44999b559dd69dc1671cb4e9ea # v4.2.1 + with: + mise_toml: | + [settings] + idiomatic_version_file_enable_tools = ["node", "pnpm"] + - run: pnpm install --frozen-lockfile + - run: pnpm format:check + + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + - uses: jdx/mise-action@dad1bfd3df957f44999b559dd69dc1671cb4e9ea # v4.2.1 + with: + mise_toml: | + [settings] + idiomatic_version_file_enable_tools = ["node", "pnpm"] + - run: pnpm install --frozen-lockfile + - run: pnpm test diff --git a/.github/workflows/format.yml b/.github/workflows/format.yml deleted file mode 100644 index a96768c..0000000 --- a/.github/workflows/format.yml +++ /dev/null @@ -1,13 +0,0 @@ -name: Format - -on: - workflow_dispatch: - push: - branches: - - main - -jobs: - format: - if: github.repository_owner == 'bombshell-dev' - uses: bombshell-dev/automation/.github/workflows/format.yml@main - secrets: inherit diff --git a/package.json b/package.json index 0498153..da4f060 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ "scripts": { "playground": "NODE_NO_WARNINGS=1 node --experimental-transform-types ./scripts/playground.ts", "format": "bsh format", + "format:check": "bsh format --check", "lint": "bsh lint .", "test": "bsh test --exclude 'experiments/ghostwright/**'" }, From 70de9af0a821ab52815b6f67a29b7990fc8d143c Mon Sep 17 00:00:00 2001 From: Jacob Bolda Date: Sat, 18 Jul 2026 23:37:28 -0500 Subject: [PATCH 2/4] format ghostwrite --- experiments/ghostwright/AGENTS.md | 84 +- experiments/ghostwright/HOST-COMPARISON.md | 6 +- experiments/ghostwright/PERFORMANCE.md | 12 +- experiments/ghostwright/README.md | 70 +- .../ghostwright/docs/agent-quickstart.md | 82 +- .../ghostwright/docs/choosing-assertions.md | 68 +- .../ghostwright/docs/debugging-failures.md | 24 +- .../ghostwright/docs/interaction-recipes.md | 104 +- .../examples/async/agent-closes-vi.test.ts | 66 +- .../examples/async/bash-vi-roundtrip.test.ts | 128 +- .../examples/async/simple-cli.test.ts | 38 +- .../effection/agent-closes-vi.test.ts | 72 +- .../effection/bash-vi-roundtrip.test.ts | 134 +- .../examples/effection/simple-cli.test.ts | 44 +- experiments/ghostwright/ghostty.lock.json | 288 +- experiments/ghostwright/package.json | 112 +- experiments/ghostwright/scripts/benchmark.ts | 72 +- .../ghostwright/scripts/build-artifacts.ts | 6 +- .../ghostwright/scripts/build-ghostty-vt.ts | 24 +- .../ghostwright/scripts/build-host-c.ts | 46 +- .../ghostwright/scripts/build-host-rust.ts | 20 +- .../ghostwright/scripts/compare-hosts.ts | 204 +- .../ghostwright/scripts/fetch-ghostty.ts | 16 +- .../ghostwright/scripts/fix-declarations.ts | 22 +- .../ghostwright/scripts/update-manifest.ts | 218 +- .../ghostwright/scripts/verify-artifacts.ts | 66 +- .../ghostwright/src/assertions/index.ts | 440 +-- .../src/assertions/types-internal.ts | 38 +- experiments/ghostwright/src/async.ts | 72 +- .../ghostwright/src/effection/index.ts | 282 +- experiments/ghostwright/src/errors.ts | 74 +- experiments/ghostwright/src/index.ts | 60 +- experiments/ghostwright/src/profile.ts | 236 +- experiments/ghostwright/src/pty/client.ts | 422 +-- experiments/ghostwright/src/pty/protocol.ts | 372 +-- .../ghostwright/src/terminal/session.ts | 1902 ++++++------ experiments/ghostwright/src/terminal/wasm.ts | 2682 ++++++++--------- experiments/ghostwright/src/tracing/replay.ts | 168 +- experiments/ghostwright/src/tracing/trace.ts | 288 +- experiments/ghostwright/src/types.ts | 638 ++-- .../ghostwright/test/assertions-trace.test.ts | 306 +- .../ghostwright/test/conformance.test.ts | 230 +- .../ghostwright/test/effection.test.ts | 62 +- experiments/ghostwright/test/host-contract.ts | 208 +- .../ghostwright/test/observability.test.ts | 346 +-- experiments/ghostwright/test/preload-host.ts | 6 +- experiments/ghostwright/test/process.test.ts | 112 +- experiments/ghostwright/test/protocol.test.ts | 92 +- .../ghostwright/test/runtime-smoke.mjs | 20 +- experiments/ghostwright/test/session.test.ts | 60 +- experiments/ghostwright/tsconfig.build.json | 32 +- 51 files changed, 5581 insertions(+), 5593 deletions(-) diff --git a/experiments/ghostwright/AGENTS.md b/experiments/ghostwright/AGENTS.md index 0826f30..5b9bf82 100644 --- a/experiments/ghostwright/AGENTS.md +++ b/experiments/ghostwright/AGENTS.md @@ -20,31 +20,31 @@ Test the application from the outside. Launch its real command under a PTY, inte ## Canonical async template ```ts -import { expect, test } from "bun:test"; -import { expectTerminal, withTerminalAsync } from "ghostwright"; - -test("interactive happy path", async () => { - await withTerminalAsync( - { - command: "bun", - args: ["src/cli.ts"], - cwd: process.cwd(), - viewport: { columns: 80, rows: 24 }, - }, - async (terminal) => { - await expectTerminal(terminal.getByText("Ready")).toBePresent(); - - const action = await terminal.keyboard.press("Enter"); - - await expectTerminal(terminal).toHaveShownText("Working", { - since: action, - }); - await expectTerminal(terminal.getByText("Complete")).toBeStable(); - - const status = await terminal.process.waitForExit(); - expect(status.exitCode).toBe(0); - }, - ); +import { expect, test } from 'bun:test'; +import { expectTerminal, withTerminalAsync } from 'ghostwright'; + +test('interactive happy path', async () => { + await withTerminalAsync( + { + command: 'bun', + args: ['src/cli.ts'], + cwd: process.cwd(), + viewport: { columns: 80, rows: 24 }, + }, + async (terminal) => { + await expectTerminal(terminal.getByText('Ready')).toBePresent(); + + const action = await terminal.keyboard.press('Enter'); + + await expectTerminal(terminal).toHaveShownText('Working', { + since: action, + }); + await expectTerminal(terminal.getByText('Complete')).toBeStable(); + + const status = await terminal.process.waitForExit(); + expect(status.exitCode).toBe(0); + }, + ); }); ``` @@ -52,14 +52,14 @@ Replace the command, arguments, and visible strings with values from the target ## Assertion selection -| Intent | Use | -|---|---| -| First appearance or readiness barrier | `expectTerminal(locator).toBePresent()` | -| Final visually settled state | `expectTerminal(locator).toBeStable()` | -| Text must remain absent | `expectTerminal(locator).toBeAbsent()` | -| Compound stable screen condition | `expectTerminal(terminal).toSatisfy(predicate)` | +| Intent | Use | +| ----------------------------------------- | ------------------------------------------------------------ | +| First appearance or readiness barrier | `expectTerminal(locator).toBePresent()` | +| Final visually settled state | `expectTerminal(locator).toBeStable()` | +| Text must remain absent | `expectTerminal(locator).toBeAbsent()` | +| Compound stable screen condition | `expectTerminal(terminal).toSatisfy(predicate)` | | Fleeting screen condition after an action | `expectTerminal(terminal).toHaveShown(predicate, { since })` | -| Fleeting text after an action | `expectTerminal(terminal).toHaveShownText(text, { since })` | +| Fleeting text after an action | `expectTerminal(terminal).toHaveShownText(text, { since })` | Assertions are revision-driven. Do not add fixed sleeps for readiness or convergence. A sleep is acceptable only when elapsed time itself is the behavior under test or a negative assertion has no positive revision signal; leave a comment explaining that choice. @@ -75,18 +75,18 @@ Assertions are revision-driven. Do not add fixed sleeps for readiness or converg ## Actions ```ts -await terminal.keyboard.press("Enter"); -await terminal.keyboard.press({ key: "c", control: true }); -await terminal.keyboard.type("hello"); -await terminal.keyboard.paste("multiline\ntext"); +await terminal.keyboard.press('Enter'); +await terminal.keyboard.press({ key: 'c', control: true }); +await terminal.keyboard.type('hello'); +await terminal.keyboard.paste('multiline\ntext'); await terminal.keyboard.write(new Uint8Array([0x1b])); await terminal.mouse.move({ column: 4, row: 2 }); -await terminal.mouse.click({ column: 4, row: 2 }, { button: "left" }); +await terminal.mouse.click({ column: 4, row: 2 }, { button: 'left' }); await terminal.mouse.wheel({ column: 4, row: 2, deltaRows: 1 }); await terminal.resize({ columns: 100, rows: 30 }); -await terminal.process.signal("SIGTERM", "process-group"); +await terminal.process.signal('SIGTERM', 'process-group'); ``` `keyboard.press({ key: "c", control: true })` travels through terminal input and line discipline. It is not equivalent to `process.signal("SIGINT")`. @@ -96,12 +96,12 @@ await terminal.process.signal("SIGTERM", "process-group"); Save an action receipt when the target state may appear and disappear quickly: ```ts -const action = await terminal.keyboard.press("Enter"); +const action = await terminal.keyboard.press('Enter'); -await expectTerminal(terminal).toHaveShownText("Saving", { - since: action, +await expectTerminal(terminal).toHaveShownText('Saving', { + since: action, }); -await expectTerminal(terminal.getByText("Saved")).toBeStable(); +await expectTerminal(terminal.getByText('Saved')).toBeStable(); ``` Do not replace `toHaveShownText` with a sleep followed by a current-screen read. The current snapshot may already have overwritten the transient state. diff --git a/experiments/ghostwright/HOST-COMPARISON.md b/experiments/ghostwright/HOST-COMPARISON.md index 30d84dd..3c43545 100644 --- a/experiments/ghostwright/HOST-COMPARISON.md +++ b/experiments/ghostwright/HOST-COMPARISON.md @@ -5,9 +5,9 @@ Generated on 2026-07-15T09:03:59.623Z by `bun run compare:hosts` on darwin-arm64 Both candidates passed the same GWPT/PTY contract before measurement. Candidate outputs are generated under the ignored `.cache/hosts` directory and are not included in the npm artifact inventory. | Implementation | Source files | Nonblank LOC | `unsafe` tokens | Stripped binary | Warm build | Median launch/exit | Raw 1 MiB transport | -|---|---:|---:|---:|---:|---:|---:|---:| -| Pure C | 5 | 1083 | 0 | 36.1 KiB | 710.9 ms | 18.6 ms | 36.1 MiB/s | -| Rust | 3 | 979 | 21 | 345.0 KiB | 57.4 ms | 19.8 ms | 38.3 MiB/s | +| -------------- | -----------: | -----------: | --------------: | --------------: | ---------: | -----------------: | ------------------: | +| Pure C | 5 | 1083 | 0 | 36.1 KiB | 710.9 ms | 18.6 ms | 36.1 MiB/s | +| Rust | 3 | 979 | 21 | 345.0 KiB | 57.4 ms | 19.8 ms | 38.3 MiB/s | ## Pure C diff --git a/experiments/ghostwright/PERFORMANCE.md b/experiments/ghostwright/PERFORMANCE.md index 2131e79..9e5b61c 100644 --- a/experiments/ghostwright/PERFORMANCE.md +++ b/experiments/ghostwright/PERFORMANCE.md @@ -6,12 +6,12 @@ Measured 2026-07-14 on macOS arm64, Bun 1.3.9, Node 22.21.1, with the bundled Da bun packages/ghostwright/scripts/benchmark.ts ``` -| Scenario | Iterations | Median | Range | -|---|---:|---:|---:| -| PTY launch, `/usr/bin/true` exit, and cleanup | 10 | 14.1 ms | 12.4–25.3 ms | -| 1 MiB unbroken PTY output into a 120×40 viewport | 5 | 31.1 s | 30.7–31.4 s | -| Migrated Solid blackbox selection (69 tests) | 1 | 69.7 s | single run | -| Prototype harness, same sources and 69 tests | 1 | 70.7 s | single run | +| Scenario | Iterations | Median | Range | +| ------------------------------------------------ | ---------: | ------: | -----------: | +| PTY launch, `/usr/bin/true` exit, and cleanup | 10 | 14.1 ms | 12.4–25.3 ms | +| 1 MiB unbroken PTY output into a 120×40 viewport | 5 | 31.1 s | 30.7–31.4 s | +| Migrated Solid blackbox selection (69 tests) | 1 | 69.7 s | single run | +| Prototype harness, same sources and 69 tests | 1 | 70.7 s | single run | Initialization and ordinary interactive launch latency are practical. The deliberately adversarial 1 MiB burst is materially slow because Darwin commonly returns many small PTY reads; REQ-039 requires every read to remain a distinct Ghostty revision boundary, and each boundary currently extracts a complete immutable styled grid through the upstream C ABI. Ghostwright does not hide this by coalescing output frames. diff --git a/experiments/ghostwright/README.md b/experiments/ghostwright/README.md index 7ee4017..2617331 100644 --- a/experiments/ghostwright/README.md +++ b/experiments/ghostwright/README.md @@ -27,30 +27,30 @@ Consumers receive prebuilt WASM, terminfo, and the native host for each supporte ## First async test ```ts -import { expect, test } from "bun:test"; -import { expectTerminal, withTerminalAsync } from "ghostwright"; - -test("interactive CLI", async () => { - await withTerminalAsync( - { - command: "bun", - args: ["src/cli.ts"], - cwd: process.cwd(), - viewport: { columns: 80, rows: 24 }, - }, - async (terminal) => { - await expectTerminal(terminal.getByText("Ready")).toBePresent(); - - const action = await terminal.keyboard.press("Enter"); - await expectTerminal(terminal).toHaveShownText("Working", { - since: action, - }); - await expectTerminal(terminal.getByText("Complete")).toBeStable(); - - const status = await terminal.process.waitForExit(); - expect(status.exitCode).toBe(0); - }, - ); +import { expect, test } from 'bun:test'; +import { expectTerminal, withTerminalAsync } from 'ghostwright'; + +test('interactive CLI', async () => { + await withTerminalAsync( + { + command: 'bun', + args: ['src/cli.ts'], + cwd: process.cwd(), + viewport: { columns: 80, rows: 24 }, + }, + async (terminal) => { + await expectTerminal(terminal.getByText('Ready')).toBePresent(); + + const action = await terminal.keyboard.press('Enter'); + await expectTerminal(terminal).toHaveShownText('Working', { + since: action, + }); + await expectTerminal(terminal.getByText('Complete')).toBeStable(); + + const status = await terminal.process.waitForExit(); + expect(status.exitCode).toBe(0); + }, + ); }); ``` @@ -62,14 +62,14 @@ Effection users get the same operations and lifecycle through `withTerminal`; se Ghostwright assertions are revision-driven rather than polling-based: -| Intent | API | -|---|---| -| First visible appearance / readiness | `toBePresent()` | -| Final visually settled state | `toBeStable()` | -| Stable disappearance | `toBeAbsent()` | -| Compound stable screen condition | `toSatisfy()` | -| Fleeting screen state after an action | `toHaveShown()` | -| Fleeting text after an action | `toHaveShownText()` | +| Intent | API | +| ------------------------------------- | ------------------- | +| First visible appearance / readiness | `toBePresent()` | +| Final visually settled state | `toBeStable()` | +| Stable disappearance | `toBeAbsent()` | +| Compound stable screen condition | `toSatisfy()` | +| Fleeting screen state after an action | `toHaveShown()` | +| Fleeting text after an action | `toHaveShownText()` | Text locators are lazy, current-visible-viewport only, grapheme-aware, and strict. Zero matches wait; multiple matches fail with candidate geometry. Use `.nth()` or `.region()` to disambiguate deliberately. @@ -82,8 +82,8 @@ Retained revision ranges use an explicit exclusive baseline, so animation tests ```ts const samples = terminal.screen.revisions({ since: action }); const collection = await terminal.revisions.collect({ - since: action, - until: (snapshot) => snapshot.lines.some((line) => line.text.includes("Complete")), + since: action, + until: (snapshot) => snapshot.lines.some((line) => line.text.includes('Complete')), }); ``` @@ -91,7 +91,7 @@ Terminal scrollback is a serialized, bounded observation of Ghostty history; it ```ts const page = await terminal.history.read({ count: 200 }); -const matches = await terminal.history.findText("tool completed", { direction: "newest-first" }); +const matches = await terminal.history.findText('tool completed', { direction: 'newest-first' }); ``` `ScreenSnapshot.graphics` exposes active-screen renderer-ready Kitty placement/image metadata. The shipped deterministic profile accepts bounded direct raw RGB/RGBA/gray transfers (64 MiB per screen by default), hashes decoded pixels, and rejects file/shared-memory media. PNG transport/playback support is not enabled in this artifact. Graphics inspection proves Ghostty accepted and prepared image data for rendering; it does not prove font/GPU-composited pixels. diff --git a/experiments/ghostwright/docs/agent-quickstart.md b/experiments/ghostwright/docs/agent-quickstart.md index 7f30990..693430e 100644 --- a/experiments/ghostwright/docs/agent-quickstart.md +++ b/experiments/ghostwright/docs/agent-quickstart.md @@ -62,30 +62,30 @@ Do not combine an executable and arguments into an implicit shell string. ## First Bun test ```ts -import { expect, test } from "bun:test"; -import { expectTerminal, withTerminalAsync } from "ghostwright"; - -test("CLI starts and accepts input", async () => { - await withTerminalAsync( - { - command: "bun", - args: ["src/cli.ts"], - cwd: process.cwd(), - viewport: { columns: 80, rows: 24 }, - }, - async (terminal) => { - await expectTerminal(terminal.getByText("Ready")).toBePresent(); - - await terminal.keyboard.type("hello"); - await terminal.keyboard.press("Enter"); - - await expectTerminal(terminal.getByText("Received: hello")).toBeStable(); - - const status = await terminal.process.waitForExit(); - expect(status.exitCode).toBe(0); - expect(status.ptyEof).toBe(true); - }, - ); +import { expect, test } from 'bun:test'; +import { expectTerminal, withTerminalAsync } from 'ghostwright'; + +test('CLI starts and accepts input', async () => { + await withTerminalAsync( + { + command: 'bun', + args: ['src/cli.ts'], + cwd: process.cwd(), + viewport: { columns: 80, rows: 24 }, + }, + async (terminal) => { + await expectTerminal(terminal.getByText('Ready')).toBePresent(); + + await terminal.keyboard.type('hello'); + await terminal.keyboard.press('Enter'); + + await expectTerminal(terminal.getByText('Received: hello')).toBeStable(); + + const status = await terminal.process.waitForExit(); + expect(status.exitCode).toBe(0); + expect(status.ptyEof).toBe(true); + }, + ); }); ``` @@ -100,19 +100,19 @@ bun test test/cli.blackbox.test.ts Ghostwright assertions throw ordinary typed errors and require no runner plugin: ```ts -import assert from "node:assert/strict"; -import test from "node:test"; -import { expectTerminal, withTerminalAsync } from "ghostwright"; - -test("CLI help", async () => { - await withTerminalAsync( - { command: "node", args: ["dist/cli.js", "--help"] }, - async (terminal) => { - await expectTerminal(terminal.getByText("Usage:")).toBePresent(); - const status = await terminal.process.waitForExit(); - assert.equal(status.exitCode, 0); - }, - ); +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { expectTerminal, withTerminalAsync } from 'ghostwright'; + +test('CLI help', async () => { + await withTerminalAsync( + { command: 'node', args: ['dist/cli.js', '--help'] }, + async (terminal) => { + await expectTerminal(terminal.getByText('Usage:')).toBePresent(); + const status = await terminal.process.waitForExit(); + assert.equal(status.exitCode, 0); + }, + ); }); ``` @@ -145,16 +145,16 @@ Wait for readiness, perform one keyboard or mouse interaction, and assert the fi Save the action receipt, assert the fleeting progress state from history, then assert the final state: ```ts -const action = await terminal.keyboard.press("Enter"); -await expectTerminal(terminal).toHaveShownText("Working", { since: action }); -await expectTerminal(terminal.getByText("Done")).toBeStable(); +const action = await terminal.keyboard.press('Enter'); +await expectTerminal(terminal).toHaveShownText('Working', { since: action }); +await expectTerminal(terminal.getByText('Done')).toBeStable(); ``` ### 4. Resize ```ts await terminal.resize({ columns: 100, rows: 30 }); -await expectTerminal(terminal.getByText("100x30")).toBeStable(); +await expectTerminal(terminal.getByText('100x30')).toBeStable(); ``` Ghostwright uses real PTY resize and `SIGWINCH`; it does not inject synthetic resize bytes. diff --git a/experiments/ghostwright/docs/choosing-assertions.md b/experiments/ghostwright/docs/choosing-assertions.md index aafb843..ed98150 100644 --- a/experiments/ghostwright/docs/choosing-assertions.md +++ b/experiments/ghostwright/docs/choosing-assertions.md @@ -4,22 +4,22 @@ Ghostwright separates first appearance, visual convergence, stable absence, and ## Decision table -| Question | Assertion | -|---|---| -| Has this text appeared yet? | `toBePresent()` | -| Has the final visible UI settled? | `toBeStable()` | -| Has this text remained gone? | `toBeAbsent()` | -| Have several visible conditions converged together? | `toSatisfy()` | -| Did a fleeting screen state occur after an action? | `toHaveShown()` | -| Did fleeting text occur after an action? | `toHaveShownText()` | +| Question | Assertion | +| --------------------------------------------------- | ------------------- | +| Has this text appeared yet? | `toBePresent()` | +| Has the final visible UI settled? | `toBeStable()` | +| Has this text remained gone? | `toBeAbsent()` | +| Have several visible conditions converged together? | `toSatisfy()` | +| Did a fleeting screen state occur after an action? | `toHaveShown()` | +| Did fleeting text occur after an action? | `toHaveShownText()` | All waits evaluate current state and subscribe to revisions. They do not use fixed-interval polling. ## `toBePresent`: readiness and first appearance ```ts -await expectTerminal(terminal.getByText("Ready")).toBePresent({ - timeoutMs: 5_000, +await expectTerminal(terminal.getByText('Ready')).toBePresent({ + timeoutMs: 5_000, }); ``` @@ -28,9 +28,9 @@ Use this as a readiness barrier or when first appearance is sufficient. It compl ## `toBeStable`: final visible state ```ts -await expectTerminal(terminal.getByText("Saved")).toBeStable({ - timeoutMs: 5_000, - settleMs: 100, +await expectTerminal(terminal.getByText('Saved')).toBeStable({ + timeoutMs: 5_000, + settleMs: 100, }); ``` @@ -48,9 +48,9 @@ The settle clock starts at the session's last visual change, not at assertion in ## `toBeAbsent`: stable disappearance ```ts -await expectTerminal(terminal.getByText("Loading")).toBeAbsent({ - timeoutMs: 5_000, - settleMs: 100, +await expectTerminal(terminal.getByText('Loading')).toBeAbsent({ + timeoutMs: 5_000, + settleMs: 100, }); ``` @@ -62,11 +62,11 @@ Use a terminal predicate when one stable screen must satisfy several conditions: ```ts await expectTerminal(terminal).toSatisfy( - (snapshot) => { - const text = snapshot.lines.map((line) => line.text).join("\n"); - return text.includes("Status: complete") && text.includes("Items: 12"); - }, - { timeoutMs: 5_000, settleMs: 100 }, + (snapshot) => { + const text = snapshot.lines.map((line) => line.text).join('\n'); + return text.includes('Status: complete') && text.includes('Items: 12'); + }, + { timeoutMs: 5_000, settleMs: 100 }, ); ``` @@ -85,19 +85,19 @@ idle → saving → complete Capture the action receipt and search retained revisions from that boundary: ```ts -const action = await terminal.keyboard.press("Enter"); +const action = await terminal.keyboard.press('Enter'); await expectTerminal(terminal).toHaveShown( - (snapshot) => snapshot.lines.some((line) => line.text.includes("Saving")), - { since: action, timeoutMs: 5_000 }, + (snapshot) => snapshot.lines.some((line) => line.text.includes('Saving')), + { since: action, timeoutMs: 5_000 }, ); ``` For text only: ```ts -await expectTerminal(terminal).toHaveShownText("Saving", { - since: action, +await expectTerminal(terminal).toHaveShownText('Saving', { + since: action, }); ``` @@ -108,7 +108,7 @@ Transient assertions search revision history; they do not require the matching s ## Strict text locators ```ts -const locator = terminal.getByText("Open"); +const locator = terminal.getByText('Open'); ``` Locators are: @@ -125,11 +125,9 @@ Zero matches wait until timeout. Multiple matches fail immediately with candidat Resolve duplicates deliberately: ```ts -terminal.getByText("Open").nth(1); +terminal.getByText('Open').nth(1); -terminal - .getByText("Open") - .region({ column: 40, row: 0, width: 40, height: 24 }); +terminal.getByText('Open').region({ column: 40, row: 0, width: 40, height: 24 }); ``` `nth()` uses zero-based row-major ordering. `region()` uses zero-based cell coordinates. @@ -137,7 +135,7 @@ terminal Exact matching compares the full physical row after trailing spaces are removed: ```ts -terminal.getByText("Ready", { exact: true }); +terminal.getByText('Ready', { exact: true }); ``` Ghostwright does not normalize Unicode, fold case, cross rows, or search scrollback for actionable locators. @@ -173,8 +171,8 @@ Ghostwright never debounces or combines separate sidecar output frames. Do not poll: ```ts -while (!terminal.screen.getText().includes("Ready")) { - await sleep(25); +while (!terminal.screen.getText().includes('Ready')) { + await sleep(25); } ``` @@ -182,7 +180,7 @@ Do not add readiness sleeps: ```ts await sleep(500); -expect(terminal.screen.getText()).toContain("Ready"); +expect(terminal.screen.getText()).toContain('Ready'); ``` Use the corresponding revision assertion instead. A real sleep is appropriate only when elapsed time itself is the behavior under test or a negative scenario has no positive revision signal; document why it is necessary. diff --git a/experiments/ghostwright/docs/debugging-failures.md b/experiments/ghostwright/docs/debugging-failures.md index 9976ff0..9af6cd3 100644 --- a/experiments/ghostwright/docs/debugging-failures.md +++ b/experiments/ghostwright/docs/debugging-failures.md @@ -8,13 +8,13 @@ The default trace policy is `retain-on-failure`: ```ts await withTerminalAsync( - { - command: "my-cli", - name: "save-flow", - }, - async (terminal) => { - // ... - }, + { + command: 'my-cli', + name: 'save-flow', + }, + async (terminal) => { + // ... + }, ); ``` @@ -120,11 +120,9 @@ Environment values and the complete inherited environment are not serialized. The query matched more than one visible range. Read the candidate ranges and disambiguate: ```ts -terminal.getByText("Open").nth(1); +terminal.getByText('Open').nth(1); -terminal - .getByText("Open") - .region({ column: 40, row: 0, width: 40, height: 24 }); +terminal.getByText('Open').region({ column: 40, row: 0, width: 40, height: 24 }); ``` Do not choose an arbitrary duplicate implicitly. @@ -176,8 +174,8 @@ Run again with the exact path-scoped `--allow-read`, `--allow-run`, and environm Common secret-like environment keys are redacted from trace metadata. Mark typed or pasted input explicitly: ```ts -await terminal.keyboard.type(password, { trace: "redact" }); -await terminal.keyboard.paste(token, { trace: "redact" }); +await terminal.keyboard.type(password, { trace: 'redact' }); +await terminal.keyboard.paste(token, { trace: 'redact' }); ``` Redacted events retain type and length but not content. Application output, unmarked input, command arguments, and cwd may still contain secrets. Use argument-index redaction and `trace: "off"` where appropriate. diff --git a/experiments/ghostwright/docs/interaction-recipes.md b/experiments/ghostwright/docs/interaction-recipes.md index 1495804..b519ec8 100644 --- a/experiments/ghostwright/docs/interaction-recipes.md +++ b/experiments/ghostwright/docs/interaction-recipes.md @@ -6,16 +6,16 @@ These recipes use the async API. With Effection, replace `await` with `yield*` a ```ts await withTerminalAsync( - { - command: "my-cli", - args: ["--interactive"], - cwd: process.cwd(), - env: { APP_MODE: "test" }, - viewport: { columns: 80, rows: 24 }, - }, - async (terminal) => { - await expectTerminal(terminal.getByText("Ready")).toBePresent(); - }, + { + command: 'my-cli', + args: ['--interactive'], + cwd: process.cwd(), + env: { APP_MODE: 'test' }, + viewport: { columns: 80, rows: 24 }, + }, + async (terminal) => { + await expectTerminal(terminal.getByText('Ready')).toBePresent(); + }, ); ``` @@ -24,10 +24,10 @@ The callback owns the session. Normal return, throw, assertion failure, and canc ## Type into a canonical shell prompt ```ts -await expectTerminal(terminal.getByText("Name: ")).toBePresent(); -await terminal.keyboard.type("Ada"); -await terminal.keyboard.press("Enter"); -await expectTerminal(terminal.getByText("Hello, Ada!")).toBeStable(); +await expectTerminal(terminal.getByText('Name: ')).toBePresent(); +await terminal.keyboard.type('Ada'); +await terminal.keyboard.press('Enter'); +await expectTerminal(terminal.getByText('Hello, Ada!')).toBeStable(); ``` `keyboard.type()` sends user key input without an implicit delay. `Enter` is a separate key action. @@ -35,9 +35,9 @@ await expectTerminal(terminal.getByText("Hello, Ada!")).toBeStable(); ## Keyboard shortcuts ```ts -await terminal.keyboard.press({ key: "c", control: true }); -await terminal.keyboard.press({ key: "Tab", shift: true }); -await terminal.keyboard.press({ key: "x", alt: true }); +await terminal.keyboard.press({ key: 'c', control: true }); +await terminal.keyboard.press({ key: 'Tab', shift: true }); +await terminal.keyboard.press({ key: 'x', alt: true }); ``` Keyboard encoding uses current Ghostty terminal modes, including application cursor keys, backarrow mode, and Kitty keyboard flags. @@ -45,14 +45,14 @@ Keyboard encoding uses current Ghostty terminal modes, including application cur User Control-C is terminal input. In canonical mode with `ISIG`, line discipline normally delivers `SIGINT`; in raw mode the application receives byte `0x03`. Administrative signaling is separate: ```ts -await terminal.process.signal("SIGINT", "child"); -await terminal.process.signal("SIGTERM", "process-group"); +await terminal.process.signal('SIGINT', 'child'); +await terminal.process.signal('SIGTERM', 'process-group'); ``` ## Paste ```ts -await terminal.keyboard.paste("first line\nsecond line"); +await terminal.keyboard.paste('first line\nsecond line'); ``` Paste uses Ghostty paste encoding and active bracketed-paste mode. It is not implemented as delayed key-by-key typing. @@ -60,8 +60,8 @@ Paste uses Ghostty paste encoding and active bracketed-paste mode. It is not imp Redact sensitive input from traces: ```ts -await terminal.keyboard.type(secret, { trace: "redact" }); -await terminal.keyboard.paste(secret, { trace: "redact" }); +await terminal.keyboard.type(secret, { trace: 'redact' }); +await terminal.keyboard.paste(secret, { trace: 'redact' }); ``` Application output can still expose the value. @@ -69,9 +69,7 @@ Application output can still expose the value. ## Raw bytes ```ts -await terminal.keyboard.write( - new Uint8Array([0x1b, 0x5b, 0x41]), -); +await terminal.keyboard.write(new Uint8Array([0x1b, 0x5b, 0x41])); ``` Use raw input only when exact bytes are part of the test. Prefer `press`, `type`, or `paste` for mode-aware interaction. @@ -79,12 +77,12 @@ Use raw input only when exact bytes are part of the test. Prefer `press`, `type` ## Click visible text ```ts -const action = await terminal.getByText("Save", { exact: true }).click(); +const action = await terminal.getByText('Save', { exact: true }).click(); -await expectTerminal(terminal).toHaveShownText("Saving", { - since: action, +await expectTerminal(terminal).toHaveShownText('Saving', { + since: action, }); -await expectTerminal(terminal.getByText("Saved")).toBeStable(); +await expectTerminal(terminal.getByText('Saved')).toBeStable(); ``` The locator resolves to cell geometry and performs ordinary terminal mouse behavior. If mouse reporting is disabled, no bytes are delivered to the child and the receipt reports `deliveredToChild: false`. @@ -93,14 +91,10 @@ The locator resolves to cell geometry and performs ordinary terminal mouse behav ```ts await terminal.mouse.move({ column: 10, row: 4 }); -await terminal.mouse.down({ column: 10, row: 4 }, { button: "left" }); -await terminal.mouse.up({ column: 10, row: 4 }, { button: "left" }); +await terminal.mouse.down({ column: 10, row: 4 }, { button: 'left' }); +await terminal.mouse.up({ column: 10, row: 4 }, { button: 'left' }); -await terminal.mouse.drag( - { column: 2, row: 3 }, - { column: 20, row: 3 }, - { button: "left" }, -); +await terminal.mouse.drag({ column: 2, row: 3 }, { column: 20, row: 3 }, { button: 'left' }); ``` Coordinates are zero-based terminal cells. Out-of-range coordinates fail before input is sent. @@ -109,9 +103,9 @@ Coordinates are zero-based terminal cells. Out-of-range coordinates fail before ```ts await terminal.mouse.wheel({ - column: 10, - row: 4, - deltaRows: 1, + column: 10, + row: 4, + deltaRows: 1, }); ``` @@ -121,15 +115,13 @@ Positive row deltas scroll down; negative deltas scroll up. `deltaColumns` sends ```ts await terminal.resize({ - columns: 100, - rows: 30, + columns: 100, + rows: 30, }); await expectTerminal(terminal).toSatisfy( - (snapshot) => - snapshot.viewport.columns === 100 && - snapshot.viewport.rows === 30, - { timeoutMs: 5_000, settleMs: 100 }, + (snapshot) => snapshot.viewport.columns === 100 && snapshot.viewport.rows === 30, + { timeoutMs: 5_000, settleMs: 100 }, ); ``` @@ -141,7 +133,7 @@ Ghostwright updates both the Ghostty engine and kernel PTY dimensions. The foreg const running = terminal.process.status(); const status = await terminal.process.waitForExit({ - timeoutMs: 5_000, + timeoutMs: 5_000, }); expect(status.exitCode).toBe(0); @@ -157,11 +149,11 @@ Direct-child exit and PTY EOF are tracked separately. Ghostwright drains final o const snapshot = terminal.screen.current(); const cell = terminal.screen.getCell({ column: 4, row: 2 }); -expect(cell.text).toBe("A"); +expect(cell.text).toBe('A'); expect(cell.style.bold).toBe(true); expect(cell.style.foreground).toEqual({ - kind: "palette", - index: 42, + kind: 'palette', + index: 42, }); ``` @@ -185,15 +177,15 @@ Runnable versions: ## Effection form ```ts -import { run } from "effection"; -import { expectTerminal, withTerminal } from "ghostwright"; +import { run } from 'effection'; +import { expectTerminal, withTerminal } from 'ghostwright'; await run(function* () { - return yield* withTerminal(options, function* (terminal) { - yield* expectTerminal(terminal.getByText("Ready")).toBePresent(); - yield* terminal.keyboard.press("Enter"); - yield* expectTerminal(terminal.getByText("Done")).toBeStable(); - }); + return yield* withTerminal(options, function* (terminal) { + yield* expectTerminal(terminal.getByText('Ready')).toBePresent(); + yield* terminal.keyboard.press('Enter'); + yield* expectTerminal(terminal.getByText('Done')).toBeStable(); + }); }); ``` diff --git a/experiments/ghostwright/examples/async/agent-closes-vi.test.ts b/experiments/ghostwright/examples/async/agent-closes-vi.test.ts index 93516df..eb66473 100644 --- a/experiments/ghostwright/examples/async/agent-closes-vi.test.ts +++ b/experiments/ghostwright/examples/async/agent-closes-vi.test.ts @@ -1,38 +1,38 @@ -import { expect, test } from "bun:test"; -import { mkdtemp, rm, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { expectTerminal, withTerminalAsync } from "../../src/index.ts"; +import { expect, test } from 'bun:test'; +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { expectTerminal, withTerminalAsync } from '../../src/index.ts'; -test("a coding agent can successfully close vi", async () => { - const directory = await mkdtemp(join(tmpdir(), "ghostwright-vi-")), - fixture = join(directory, "agent-test.txt"); - await writeFile(fixture, "GHOSTWRIGHT_VI_MARKER\n"); +test('a coding agent can successfully close vi', async () => { + const directory = await mkdtemp(join(tmpdir(), 'ghostwright-vi-')), + fixture = join(directory, 'agent-test.txt'); + await writeFile(fixture, 'GHOSTWRIGHT_VI_MARKER\n'); - try { - await withTerminalAsync( - { - command: "vi", - args: [fixture], - env: { HOME: directory, EXINIT: "", VIMINIT: "" }, - viewport: { columns: 80, rows: 24 }, - trace: "off", - }, - async (terminal) => { - await expectTerminal(terminal.getByText("GHOSTWRIGHT_VI_MARKER")).toBePresent({ - timeoutMs: 5_000, - }); + try { + await withTerminalAsync( + { + command: 'vi', + args: [fixture], + env: { HOME: directory, EXINIT: '', VIMINIT: '' }, + viewport: { columns: 80, rows: 24 }, + trace: 'off', + }, + async (terminal) => { + await expectTerminal(terminal.getByText('GHOSTWRIGHT_VI_MARKER')).toBePresent({ + timeoutMs: 5_000, + }); - await terminal.keyboard.press("Escape"); - await terminal.keyboard.type(":q!"); - await terminal.keyboard.press("Enter"); + await terminal.keyboard.press('Escape'); + await terminal.keyboard.type(':q!'); + await terminal.keyboard.press('Enter'); - const status = await terminal.process.waitForExit({ timeoutMs: 5_000 }); - expect(status.exitCode).toBe(0); - expect(status.ptyEof).toBe(true); - }, - ); - } finally { - await rm(directory, { recursive: true, force: true }); - } + const status = await terminal.process.waitForExit({ timeoutMs: 5_000 }); + expect(status.exitCode).toBe(0); + expect(status.ptyEof).toBe(true); + }, + ); + } finally { + await rm(directory, { recursive: true, force: true }); + } }); diff --git a/experiments/ghostwright/examples/async/bash-vi-roundtrip.test.ts b/experiments/ghostwright/examples/async/bash-vi-roundtrip.test.ts index 39e1319..7e6d6bd 100644 --- a/experiments/ghostwright/examples/async/bash-vi-roundtrip.test.ts +++ b/experiments/ghostwright/examples/async/bash-vi-roundtrip.test.ts @@ -1,72 +1,72 @@ -import { expect, test } from "bun:test"; -import { mkdtemp, rm, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { expectTerminal, withTerminalAsync } from "../../src/index.ts"; +import { expect, test } from 'bun:test'; +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { expectTerminal, withTerminalAsync } from '../../src/index.ts'; -test("interactive bash restores its screen after vi exits", async () => { - const directory = await mkdtemp(join(tmpdir(), "ghostwright-bash-vi-")), - fixture = join(directory, "agent-test.txt"); - await writeFile(fixture, "GHOSTWRIGHT_VI_MARKER\n"); +test('interactive bash restores its screen after vi exits', async () => { + const directory = await mkdtemp(join(tmpdir(), 'ghostwright-bash-vi-')), + fixture = join(directory, 'agent-test.txt'); + await writeFile(fixture, 'GHOSTWRIGHT_VI_MARKER\n'); - try { - await withTerminalAsync( - { - command: "bash", - args: ["--noprofile", "--norc", "-i"], - env: { - HOME: directory, - PS1: "GHOSTWRIGHT_PROMPT> ", - PS2: "GHOSTWRIGHT_CONTINUE> ", - PROMPT_COMMAND: "", - EXINIT: "", - VIMINIT: "", - }, - viewport: { columns: 80, rows: 24 }, - trace: "off", - }, - async (terminal) => { - await expectTerminal(terminal).toSatisfy( - (snapshot) => snapshot.lines.some((line) => line.text.includes("GHOSTWRIGHT_PROMPT> ")), - { timeoutMs: 5_000, settleMs: 100 }, - ); + try { + await withTerminalAsync( + { + command: 'bash', + args: ['--noprofile', '--norc', '-i'], + env: { + HOME: directory, + PS1: 'GHOSTWRIGHT_PROMPT> ', + PS2: 'GHOSTWRIGHT_CONTINUE> ', + PROMPT_COMMAND: '', + EXINIT: '', + VIMINIT: '', + }, + viewport: { columns: 80, rows: 24 }, + trace: 'off', + }, + async (terminal) => { + await expectTerminal(terminal).toSatisfy( + (snapshot) => snapshot.lines.some((line) => line.text.includes('GHOSTWRIGHT_PROMPT> ')), + { timeoutMs: 5_000, settleMs: 100 }, + ); - await terminal.keyboard.type("echo hello world"); - await terminal.keyboard.press("Enter"); - await expectTerminal(terminal).toSatisfy( - (snapshot) => snapshot.lines.some((line) => line.text.trim() === "hello world"), - { timeoutMs: 5_000, settleMs: 100 }, - ); + await terminal.keyboard.type('echo hello world'); + await terminal.keyboard.press('Enter'); + await expectTerminal(terminal).toSatisfy( + (snapshot) => snapshot.lines.some((line) => line.text.trim() === 'hello world'), + { timeoutMs: 5_000, settleMs: 100 }, + ); - await terminal.keyboard.type(`vi ${fixture}`); - await terminal.keyboard.press("Enter"); - await expectTerminal(terminal.getByText("GHOSTWRIGHT_VI_MARKER")).toBePresent({ - timeoutMs: 5_000, - }); - expect(terminal.screen.current().activeBuffer).toBe("alternate"); + await terminal.keyboard.type(`vi ${fixture}`); + await terminal.keyboard.press('Enter'); + await expectTerminal(terminal.getByText('GHOSTWRIGHT_VI_MARKER')).toBePresent({ + timeoutMs: 5_000, + }); + expect(terminal.screen.current().activeBuffer).toBe('alternate'); - await terminal.keyboard.press("Escape"); - await terminal.keyboard.type(":q!"); - await terminal.keyboard.press("Enter"); + await terminal.keyboard.press('Escape'); + await terminal.keyboard.type(':q!'); + await terminal.keyboard.press('Enter'); - await expectTerminal(terminal).toSatisfy( - (snapshot) => snapshot.lines.some((line) => line.text.includes("GHOSTWRIGHT_PROMPT> ")), - { timeoutMs: 5_000, settleMs: 100 }, - ); - expect(terminal.screen.current().activeBuffer).toBe("primary"); - await expectTerminal(terminal).toSatisfy( - (snapshot) => snapshot.lines.some((line) => line.text.trim() === "hello world"), - { timeoutMs: 5_000, settleMs: 100 }, - ); + await expectTerminal(terminal).toSatisfy( + (snapshot) => snapshot.lines.some((line) => line.text.includes('GHOSTWRIGHT_PROMPT> ')), + { timeoutMs: 5_000, settleMs: 100 }, + ); + expect(terminal.screen.current().activeBuffer).toBe('primary'); + await expectTerminal(terminal).toSatisfy( + (snapshot) => snapshot.lines.some((line) => line.text.trim() === 'hello world'), + { timeoutMs: 5_000, settleMs: 100 }, + ); - await terminal.keyboard.type("exit"); - await terminal.keyboard.press("Enter"); - const status = await terminal.process.waitForExit({ timeoutMs: 5_000 }); - expect(status.exitCode).toBe(0); - expect(status.ptyEof).toBe(true); - }, - ); - } finally { - await rm(directory, { recursive: true, force: true }); - } + await terminal.keyboard.type('exit'); + await terminal.keyboard.press('Enter'); + const status = await terminal.process.waitForExit({ timeoutMs: 5_000 }); + expect(status.exitCode).toBe(0); + expect(status.ptyEof).toBe(true); + }, + ); + } finally { + await rm(directory, { recursive: true, force: true }); + } }); diff --git a/experiments/ghostwright/examples/async/simple-cli.test.ts b/experiments/ghostwright/examples/async/simple-cli.test.ts index 5b697f0..1ed9cc4 100644 --- a/experiments/ghostwright/examples/async/simple-cli.test.ts +++ b/experiments/ghostwright/examples/async/simple-cli.test.ts @@ -1,27 +1,27 @@ -import { expect, test } from "bun:test"; -import { expectTerminal, withTerminalAsync } from "../../src/index.ts"; +import { expect, test } from 'bun:test'; +import { expectTerminal, withTerminalAsync } from '../../src/index.ts'; const cli = { - command: "/bin/sh", - args: [ - "-c", - `printf 'What is your name? '; IFS= read -r name; printf '\r\nHello, %s!\r\n' "$name"`, - ], - viewport: { columns: 40, rows: 6 }, - trace: "off" as const, + command: '/bin/sh', + args: [ + '-c', + `printf 'What is your name? '; IFS= read -r name; printf '\r\nHello, %s!\r\n' "$name"`, + ], + viewport: { columns: 40, rows: 6 }, + trace: 'off' as const, }; -test("async API drives a portable interactive shell CLI", async () => { - await withTerminalAsync(cli, async (terminal) => { - await expectTerminal(terminal.getByText("What is your name?")).toBePresent(); +test('async API drives a portable interactive shell CLI', async () => { + await withTerminalAsync(cli, async (terminal) => { + await expectTerminal(terminal.getByText('What is your name?')).toBePresent(); - await terminal.keyboard.type("Ada"); - await terminal.keyboard.press("Enter"); + await terminal.keyboard.type('Ada'); + await terminal.keyboard.press('Enter'); - await expectTerminal(terminal.getByText("Hello, Ada!")).toBeStable(); + await expectTerminal(terminal.getByText('Hello, Ada!')).toBeStable(); - const status = await terminal.process.waitForExit(); - expect(status.exitCode).toBe(0); - expect(status.ptyEof).toBe(true); - }); + const status = await terminal.process.waitForExit(); + expect(status.exitCode).toBe(0); + expect(status.ptyEof).toBe(true); + }); }); diff --git a/experiments/ghostwright/examples/effection/agent-closes-vi.test.ts b/experiments/ghostwright/examples/effection/agent-closes-vi.test.ts index 190e112..d048dd3 100644 --- a/experiments/ghostwright/examples/effection/agent-closes-vi.test.ts +++ b/experiments/ghostwright/examples/effection/agent-closes-vi.test.ts @@ -1,41 +1,41 @@ -import { expect, test } from "bun:test"; -import { mkdtemp, rm, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { run } from "effection"; -import { expectTerminal, withTerminal } from "../../src/index.ts"; +import { expect, test } from 'bun:test'; +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { run } from 'effection'; +import { expectTerminal, withTerminal } from '../../src/index.ts'; -test("a coding agent can successfully close vi with Effection", async () => { - const directory = await mkdtemp(join(tmpdir(), "ghostwright-vi-")), - fixture = join(directory, "agent-test.txt"); - await writeFile(fixture, "GHOSTWRIGHT_VI_MARKER\n"); +test('a coding agent can successfully close vi with Effection', async () => { + const directory = await mkdtemp(join(tmpdir(), 'ghostwright-vi-')), + fixture = join(directory, 'agent-test.txt'); + await writeFile(fixture, 'GHOSTWRIGHT_VI_MARKER\n'); - try { - await run(function* () { - return yield* withTerminal( - { - command: "vi", - args: [fixture], - env: { HOME: directory, EXINIT: "", VIMINIT: "" }, - viewport: { columns: 80, rows: 24 }, - trace: "off", - }, - function* (terminal) { - yield* expectTerminal(terminal.getByText("GHOSTWRIGHT_VI_MARKER")).toBePresent({ - timeoutMs: 5_000, - }); + try { + await run(function* () { + return yield* withTerminal( + { + command: 'vi', + args: [fixture], + env: { HOME: directory, EXINIT: '', VIMINIT: '' }, + viewport: { columns: 80, rows: 24 }, + trace: 'off', + }, + function* (terminal) { + yield* expectTerminal(terminal.getByText('GHOSTWRIGHT_VI_MARKER')).toBePresent({ + timeoutMs: 5_000, + }); - yield* terminal.keyboard.press("Escape"); - yield* terminal.keyboard.type(":q!"); - yield* terminal.keyboard.press("Enter"); + yield* terminal.keyboard.press('Escape'); + yield* terminal.keyboard.type(':q!'); + yield* terminal.keyboard.press('Enter'); - const status = yield* terminal.process.waitForExit({ timeoutMs: 5_000 }); - expect(status.exitCode).toBe(0); - expect(status.ptyEof).toBe(true); - }, - ); - }); - } finally { - await rm(directory, { recursive: true, force: true }); - } + const status = yield* terminal.process.waitForExit({ timeoutMs: 5_000 }); + expect(status.exitCode).toBe(0); + expect(status.ptyEof).toBe(true); + }, + ); + }); + } finally { + await rm(directory, { recursive: true, force: true }); + } }); diff --git a/experiments/ghostwright/examples/effection/bash-vi-roundtrip.test.ts b/experiments/ghostwright/examples/effection/bash-vi-roundtrip.test.ts index 54c8815..871c211 100644 --- a/experiments/ghostwright/examples/effection/bash-vi-roundtrip.test.ts +++ b/experiments/ghostwright/examples/effection/bash-vi-roundtrip.test.ts @@ -1,75 +1,75 @@ -import { expect, test } from "bun:test"; -import { mkdtemp, rm, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { run } from "effection"; -import { expectTerminal, withTerminal } from "../../src/index.ts"; +import { expect, test } from 'bun:test'; +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { run } from 'effection'; +import { expectTerminal, withTerminal } from '../../src/index.ts'; -test("interactive bash restores its screen after vi exits with Effection", async () => { - const directory = await mkdtemp(join(tmpdir(), "ghostwright-bash-vi-")), - fixture = join(directory, "agent-test.txt"); - await writeFile(fixture, "GHOSTWRIGHT_VI_MARKER\n"); +test('interactive bash restores its screen after vi exits with Effection', async () => { + const directory = await mkdtemp(join(tmpdir(), 'ghostwright-bash-vi-')), + fixture = join(directory, 'agent-test.txt'); + await writeFile(fixture, 'GHOSTWRIGHT_VI_MARKER\n'); - try { - await run(function* () { - return yield* withTerminal( - { - command: "bash", - args: ["--noprofile", "--norc", "-i"], - env: { - HOME: directory, - PS1: "GHOSTWRIGHT_PROMPT> ", - PS2: "GHOSTWRIGHT_CONTINUE> ", - PROMPT_COMMAND: "", - EXINIT: "", - VIMINIT: "", - }, - viewport: { columns: 80, rows: 24 }, - trace: "off", - }, - function* (terminal) { - yield* expectTerminal(terminal).toSatisfy( - (snapshot) => snapshot.lines.some((line) => line.text.includes("GHOSTWRIGHT_PROMPT> ")), - { timeoutMs: 5_000, settleMs: 100 }, - ); + try { + await run(function* () { + return yield* withTerminal( + { + command: 'bash', + args: ['--noprofile', '--norc', '-i'], + env: { + HOME: directory, + PS1: 'GHOSTWRIGHT_PROMPT> ', + PS2: 'GHOSTWRIGHT_CONTINUE> ', + PROMPT_COMMAND: '', + EXINIT: '', + VIMINIT: '', + }, + viewport: { columns: 80, rows: 24 }, + trace: 'off', + }, + function* (terminal) { + yield* expectTerminal(terminal).toSatisfy( + (snapshot) => snapshot.lines.some((line) => line.text.includes('GHOSTWRIGHT_PROMPT> ')), + { timeoutMs: 5_000, settleMs: 100 }, + ); - yield* terminal.keyboard.type("echo hello world"); - yield* terminal.keyboard.press("Enter"); - yield* expectTerminal(terminal).toSatisfy( - (snapshot) => snapshot.lines.some((line) => line.text.trim() === "hello world"), - { timeoutMs: 5_000, settleMs: 100 }, - ); + yield* terminal.keyboard.type('echo hello world'); + yield* terminal.keyboard.press('Enter'); + yield* expectTerminal(terminal).toSatisfy( + (snapshot) => snapshot.lines.some((line) => line.text.trim() === 'hello world'), + { timeoutMs: 5_000, settleMs: 100 }, + ); - yield* terminal.keyboard.type(`vi ${fixture}`); - yield* terminal.keyboard.press("Enter"); - yield* expectTerminal(terminal.getByText("GHOSTWRIGHT_VI_MARKER")).toBePresent({ - timeoutMs: 5_000, - }); - expect(terminal.screen.current().activeBuffer).toBe("alternate"); + yield* terminal.keyboard.type(`vi ${fixture}`); + yield* terminal.keyboard.press('Enter'); + yield* expectTerminal(terminal.getByText('GHOSTWRIGHT_VI_MARKER')).toBePresent({ + timeoutMs: 5_000, + }); + expect(terminal.screen.current().activeBuffer).toBe('alternate'); - yield* terminal.keyboard.press("Escape"); - yield* terminal.keyboard.type(":q!"); - yield* terminal.keyboard.press("Enter"); + yield* terminal.keyboard.press('Escape'); + yield* terminal.keyboard.type(':q!'); + yield* terminal.keyboard.press('Enter'); - yield* expectTerminal(terminal).toSatisfy( - (snapshot) => snapshot.lines.some((line) => line.text.includes("GHOSTWRIGHT_PROMPT> ")), - { timeoutMs: 5_000, settleMs: 100 }, - ); - expect(terminal.screen.current().activeBuffer).toBe("primary"); - yield* expectTerminal(terminal).toSatisfy( - (snapshot) => snapshot.lines.some((line) => line.text.trim() === "hello world"), - { timeoutMs: 5_000, settleMs: 100 }, - ); + yield* expectTerminal(terminal).toSatisfy( + (snapshot) => snapshot.lines.some((line) => line.text.includes('GHOSTWRIGHT_PROMPT> ')), + { timeoutMs: 5_000, settleMs: 100 }, + ); + expect(terminal.screen.current().activeBuffer).toBe('primary'); + yield* expectTerminal(terminal).toSatisfy( + (snapshot) => snapshot.lines.some((line) => line.text.trim() === 'hello world'), + { timeoutMs: 5_000, settleMs: 100 }, + ); - yield* terminal.keyboard.type("exit"); - yield* terminal.keyboard.press("Enter"); - const status = yield* terminal.process.waitForExit({ timeoutMs: 5_000 }); - expect(status.exitCode).toBe(0); - expect(status.ptyEof).toBe(true); - }, - ); - }); - } finally { - await rm(directory, { recursive: true, force: true }); - } + yield* terminal.keyboard.type('exit'); + yield* terminal.keyboard.press('Enter'); + const status = yield* terminal.process.waitForExit({ timeoutMs: 5_000 }); + expect(status.exitCode).toBe(0); + expect(status.ptyEof).toBe(true); + }, + ); + }); + } finally { + await rm(directory, { recursive: true, force: true }); + } }); diff --git a/experiments/ghostwright/examples/effection/simple-cli.test.ts b/experiments/ghostwright/examples/effection/simple-cli.test.ts index 61d741d..bb8916b 100644 --- a/experiments/ghostwright/examples/effection/simple-cli.test.ts +++ b/experiments/ghostwright/examples/effection/simple-cli.test.ts @@ -1,30 +1,30 @@ -import { expect, test } from "bun:test"; -import { run } from "effection"; -import { expectTerminal, withTerminal } from "../../src/index.ts"; +import { expect, test } from 'bun:test'; +import { run } from 'effection'; +import { expectTerminal, withTerminal } from '../../src/index.ts'; const cli = { - command: "/bin/sh", - args: [ - "-c", - `printf 'What is your name? '; IFS= read -r name; printf '\r\nHello, %s!\r\n' "$name"`, - ], - viewport: { columns: 40, rows: 6 }, - trace: "off" as const, + command: '/bin/sh', + args: [ + '-c', + `printf 'What is your name? '; IFS= read -r name; printf '\r\nHello, %s!\r\n' "$name"`, + ], + viewport: { columns: 40, rows: 6 }, + trace: 'off' as const, }; -test("Effection API drives a portable interactive shell CLI", async () => { - await run(function* () { - return yield* withTerminal(cli, function* (terminal) { - yield* expectTerminal(terminal.getByText("What is your name?")).toBePresent(); +test('Effection API drives a portable interactive shell CLI', async () => { + await run(function* () { + return yield* withTerminal(cli, function* (terminal) { + yield* expectTerminal(terminal.getByText('What is your name?')).toBePresent(); - yield* terminal.keyboard.type("Grace"); - yield* terminal.keyboard.press("Enter"); + yield* terminal.keyboard.type('Grace'); + yield* terminal.keyboard.press('Enter'); - yield* expectTerminal(terminal.getByText("Hello, Grace!")).toBeStable(); + yield* expectTerminal(terminal.getByText('Hello, Grace!')).toBeStable(); - const status = yield* terminal.process.waitForExit(); - expect(status.exitCode).toBe(0); - expect(status.ptyEof).toBe(true); - }); - }); + const status = yield* terminal.process.waitForExit(); + expect(status.exitCode).toBe(0); + expect(status.ptyEof).toBe(true); + }); + }); }); diff --git a/experiments/ghostwright/ghostty.lock.json b/experiments/ghostwright/ghostty.lock.json index 534ae61..ecaaa45 100644 --- a/experiments/ghostwright/ghostty.lock.json +++ b/experiments/ghostwright/ghostty.lock.json @@ -1,146 +1,146 @@ { - "schemaVersion": 1, - "ghostty": { - "repository": "https://github.com/ghostty-org/ghostty", - "commit": "f8041e849b36efbbb9736b6ecf0ccfcb01d94e69" - }, - "zigVersion": "0.15.2", - "buildFlags": ["-Demit-lib-vt", "-Dtarget=wasm32-freestanding", "ReleaseSmall"], - "ptyHostImplementation": "c", - "ptyHostBuildFlags": ["clang-or-musl-gcc", "-std=c17", "-O2", "linux:-static"], - "targets": ["darwin-arm64", "darwin-x64", "linux-arm64", "linux-x64"], - "protocolVersion": 1, - "bindingVersion": 2, - "graphics": { - "kittyGraphics": true, - "profile": "direct-raw-only", - "freestandingPatch": "patches/0001-freestanding-kitty-direct-only.patch", - "freestandingPatchSha256": "161f37ac5e056c8bd2f3daee4128d7868c8d861247da1ee131c56f72196478f2", - "pngDecoder": "unsupported", - "clock": { - "requiredFor": "future Kitty animation/playback paths only", - "directRawStorageAndPlacement": "does not invoke a clock in pinned commit f8041e849b36efbbb9736b6ecf0ccfcb01d94e69" - } - }, - "licenses": [ - { - "component": "ghostwright", - "license": "MIT", - "notice": "LICENSE" - }, - { - "component": "libghostty-vt", - "license": "MIT", - "notice": "Ghostty source pin recorded above" - } - ], - "requiredWasmExports": [ - "memory", - "__indirect_function_table", - "ghostty_type_json", - "ghostty_build_info", - "ghostty_terminal_new", - "ghostty_terminal_free", - "ghostty_terminal_set", - "ghostty_terminal_get", - "ghostty_terminal_vt_write", - "ghostty_terminal_resize", - "ghostty_terminal_mode_get", - "ghostty_terminal_grid_ref", - "ghostty_grid_ref_cell", - "ghostty_grid_ref_row", - "ghostty_grid_ref_graphemes", - "ghostty_grid_ref_hyperlink_uri", - "ghostty_grid_ref_style", - "ghostty_cell_get", - "ghostty_cell_get_multi", - "ghostty_row_get", - "ghostty_formatter_terminal_new", - "ghostty_formatter_format_buf", - "ghostty_formatter_free", - "ghostty_render_state_new", - "ghostty_render_state_update", - "ghostty_render_state_get", - "ghostty_render_state_row_iterator_new", - "ghostty_render_state_row_iterator_next", - "ghostty_render_state_row_iterator_free", - "ghostty_render_state_row_get", - "ghostty_render_state_row_cells_new", - "ghostty_render_state_row_cells_next", - "ghostty_render_state_row_cells_get_multi", - "ghostty_render_state_row_cells_free", - "ghostty_render_state_free", - "ghostty_key_event_new", - "ghostty_key_event_free", - "ghostty_key_encoder_new", - "ghostty_key_encoder_free", - "ghostty_key_encoder_setopt_from_terminal", - "ghostty_key_encoder_encode", - "ghostty_mouse_event_new", - "ghostty_mouse_event_free", - "ghostty_mouse_encoder_new", - "ghostty_mouse_encoder_free", - "ghostty_mouse_encoder_setopt", - "ghostty_mouse_encoder_setopt_from_terminal", - "ghostty_mouse_encoder_encode", - "ghostty_paste_encode", - "ghostty_focus_encode", - "ghostty_kitty_graphics_get", - "ghostty_kitty_graphics_image", - "ghostty_kitty_graphics_image_get", - "ghostty_kitty_graphics_placement_iterator_new", - "ghostty_kitty_graphics_placement_iterator_free", - "ghostty_kitty_graphics_placement_next", - "ghostty_kitty_graphics_placement_get", - "ghostty_kitty_graphics_placement_render_info" - ], - "abi": { - "structSizes": { - "GhosttyTerminalOptions": 8, - "GhosttyFormatterTerminalOptions": 40, - "GhosttyPoint": 24, - "GhosttyPointCoordinate": 8, - "GhosttyGridRef": 12, - "GhosttyStyle": 72, - "GhosttyStyleColor": 16, - "GhosttyMouseEncoderSize": 36, - "GhosttyMousePosition": 8, - "GhosttyString": 8, - "GhosttySizeReportSize": 12, - "GhosttyDeviceAttributes": 148, - "GhosttyClipboardWrite": 16 - }, - "enumValues": { - "terminalOptionWritePty": 1, - "terminalOptionClipboardWrite": 26, - "terminalDataActiveScreen": 6, - "terminalDataTitle": 12, - "terminalDataPwd": 13, - "cellDataWide": 3, - "cellDataHasHyperlink": 7 - } - }, - "artifacts": { - "artifacts/ghostty-vt.wasm": { - "sha256": "9cc284061558b47237e478107dbbe8eabd1f6139038f61b1e403bb97e74ced1f" - }, - "artifacts/pty-host-darwin-arm64": { - "sha256": "947dc314df01a11eb47a507288f8216f8cef149c27c09587a41db6e44242ea6a" - }, - "artifacts/pty-host-darwin-x64": { - "sha256": "2e997b0cee77b9f61a5694c783e18de3cd6e36bcec2e794db43c2a4a269942b4" - }, - "artifacts/pty-host-linux-arm64": { - "sha256": "d3a9fee7159eb298449b61d8c1842f80add5003e17b9924045c582393e9f49c7" - }, - "artifacts/pty-host-linux-x64": { - "sha256": "554b5e74a24e698582c61e9c16ccd82421cd68f8857dd4422912391b610cf937" - }, - "artifacts/terminfo/67/ghostty": { - "sha256": "8ac69a6a57378edd05bcca8769ff49ce3d01e9496ff134781af5b9ee1d934b7b" - }, - "artifacts/terminfo/78/xterm-ghostty": { - "sha256": "8ac69a6a57378edd05bcca8769ff49ce3d01e9496ff134781af5b9ee1d934b7b" - } - } + "schemaVersion": 1, + "ghostty": { + "repository": "https://github.com/ghostty-org/ghostty", + "commit": "f8041e849b36efbbb9736b6ecf0ccfcb01d94e69" + }, + "zigVersion": "0.15.2", + "buildFlags": ["-Demit-lib-vt", "-Dtarget=wasm32-freestanding", "ReleaseSmall"], + "ptyHostImplementation": "c", + "ptyHostBuildFlags": ["clang-or-musl-gcc", "-std=c17", "-O2", "linux:-static"], + "targets": ["darwin-arm64", "darwin-x64", "linux-arm64", "linux-x64"], + "protocolVersion": 1, + "bindingVersion": 2, + "graphics": { + "kittyGraphics": true, + "profile": "direct-raw-only", + "freestandingPatch": "patches/0001-freestanding-kitty-direct-only.patch", + "freestandingPatchSha256": "161f37ac5e056c8bd2f3daee4128d7868c8d861247da1ee131c56f72196478f2", + "pngDecoder": "unsupported", + "clock": { + "requiredFor": "future Kitty animation/playback paths only", + "directRawStorageAndPlacement": "does not invoke a clock in pinned commit f8041e849b36efbbb9736b6ecf0ccfcb01d94e69" + } + }, + "licenses": [ + { + "component": "ghostwright", + "license": "MIT", + "notice": "LICENSE" + }, + { + "component": "libghostty-vt", + "license": "MIT", + "notice": "Ghostty source pin recorded above" + } + ], + "requiredWasmExports": [ + "memory", + "__indirect_function_table", + "ghostty_type_json", + "ghostty_build_info", + "ghostty_terminal_new", + "ghostty_terminal_free", + "ghostty_terminal_set", + "ghostty_terminal_get", + "ghostty_terminal_vt_write", + "ghostty_terminal_resize", + "ghostty_terminal_mode_get", + "ghostty_terminal_grid_ref", + "ghostty_grid_ref_cell", + "ghostty_grid_ref_row", + "ghostty_grid_ref_graphemes", + "ghostty_grid_ref_hyperlink_uri", + "ghostty_grid_ref_style", + "ghostty_cell_get", + "ghostty_cell_get_multi", + "ghostty_row_get", + "ghostty_formatter_terminal_new", + "ghostty_formatter_format_buf", + "ghostty_formatter_free", + "ghostty_render_state_new", + "ghostty_render_state_update", + "ghostty_render_state_get", + "ghostty_render_state_row_iterator_new", + "ghostty_render_state_row_iterator_next", + "ghostty_render_state_row_iterator_free", + "ghostty_render_state_row_get", + "ghostty_render_state_row_cells_new", + "ghostty_render_state_row_cells_next", + "ghostty_render_state_row_cells_get_multi", + "ghostty_render_state_row_cells_free", + "ghostty_render_state_free", + "ghostty_key_event_new", + "ghostty_key_event_free", + "ghostty_key_encoder_new", + "ghostty_key_encoder_free", + "ghostty_key_encoder_setopt_from_terminal", + "ghostty_key_encoder_encode", + "ghostty_mouse_event_new", + "ghostty_mouse_event_free", + "ghostty_mouse_encoder_new", + "ghostty_mouse_encoder_free", + "ghostty_mouse_encoder_setopt", + "ghostty_mouse_encoder_setopt_from_terminal", + "ghostty_mouse_encoder_encode", + "ghostty_paste_encode", + "ghostty_focus_encode", + "ghostty_kitty_graphics_get", + "ghostty_kitty_graphics_image", + "ghostty_kitty_graphics_image_get", + "ghostty_kitty_graphics_placement_iterator_new", + "ghostty_kitty_graphics_placement_iterator_free", + "ghostty_kitty_graphics_placement_next", + "ghostty_kitty_graphics_placement_get", + "ghostty_kitty_graphics_placement_render_info" + ], + "abi": { + "structSizes": { + "GhosttyTerminalOptions": 8, + "GhosttyFormatterTerminalOptions": 40, + "GhosttyPoint": 24, + "GhosttyPointCoordinate": 8, + "GhosttyGridRef": 12, + "GhosttyStyle": 72, + "GhosttyStyleColor": 16, + "GhosttyMouseEncoderSize": 36, + "GhosttyMousePosition": 8, + "GhosttyString": 8, + "GhosttySizeReportSize": 12, + "GhosttyDeviceAttributes": 148, + "GhosttyClipboardWrite": 16 + }, + "enumValues": { + "terminalOptionWritePty": 1, + "terminalOptionClipboardWrite": 26, + "terminalDataActiveScreen": 6, + "terminalDataTitle": 12, + "terminalDataPwd": 13, + "cellDataWide": 3, + "cellDataHasHyperlink": 7 + } + }, + "artifacts": { + "artifacts/ghostty-vt.wasm": { + "sha256": "9cc284061558b47237e478107dbbe8eabd1f6139038f61b1e403bb97e74ced1f" + }, + "artifacts/pty-host-darwin-arm64": { + "sha256": "947dc314df01a11eb47a507288f8216f8cef149c27c09587a41db6e44242ea6a" + }, + "artifacts/pty-host-darwin-x64": { + "sha256": "2e997b0cee77b9f61a5694c783e18de3cd6e36bcec2e794db43c2a4a269942b4" + }, + "artifacts/pty-host-linux-arm64": { + "sha256": "d3a9fee7159eb298449b61d8c1842f80add5003e17b9924045c582393e9f49c7" + }, + "artifacts/pty-host-linux-x64": { + "sha256": "554b5e74a24e698582c61e9c16ccd82421cd68f8857dd4422912391b610cf937" + }, + "artifacts/terminfo/67/ghostty": { + "sha256": "8ac69a6a57378edd05bcca8769ff49ce3d01e9496ff134781af5b9ee1d934b7b" + }, + "artifacts/terminfo/78/xterm-ghostty": { + "sha256": "8ac69a6a57378edd05bcca8769ff49ce3d01e9496ff134781af5b9ee1d934b7b" + } + } } diff --git a/experiments/ghostwright/package.json b/experiments/ghostwright/package.json index 8870cd7..32e1a1b 100644 --- a/experiments/ghostwright/package.json +++ b/experiments/ghostwright/package.json @@ -1,58 +1,58 @@ { - "name": "ghostwright", - "version": "0.1.0", - "description": "Outside-in terminal automation using a real PTY and libghostty-vt WebAssembly", - "license": "MIT", - "files": [ - "src", - "docs", - "examples", - "dist", - "artifacts", - "ghostty.lock.json", - "README.md", - "AGENTS.md", - "PERFORMANCE.md", - "HOST-COMPARISON.md", - "LICENSE" - ], - "type": "module", - "main": "dist/index.js", - "types": "dist/types/index.d.ts", - "exports": { - ".": { - "types": "./dist/types/index.d.ts", - "import": "./dist/index.js" - }, - "./async": { - "types": "./dist/types/async.d.ts", - "import": "./dist/async.js" - }, - "./protocol": { - "types": "./dist/types/pty/protocol.d.ts", - "import": "./dist/pty/protocol.js" - } - }, - "scripts": { - "build": "rm -rf dist && bun build src/index.ts src/async.ts src/pty/protocol.ts --outdir dist --target node --format esm --packages external --sourcemap=external && bunx tsc -p tsconfig.build.json && bun scripts/fix-declarations.ts", - "fetch:ghostty": "bun scripts/fetch-ghostty.ts", - "build:ghostty-vt": "bun scripts/build-ghostty-vt.ts", - "build:host:c": "bun scripts/build-host-c.ts", - "build:host:rust": "bun scripts/build-host-rust.ts", - "test:hosts": "bun test/host-contract.ts .cache/hosts/pty-host-c && bun test/host-contract.ts .cache/hosts/pty-host-rust", - "test:host:rust:full": "GHOSTWRIGHT_CONTRACT_HOST=.cache/hosts/pty-host-rust bun test --preload ./test/preload-host.ts .", - "compare:hosts": "bun scripts/compare-hosts.ts", - "build:artifacts": "bun run fetch:ghostty && bun run build:ghostty-vt && bun scripts/build-artifacts.ts", - "verify:artifacts": "bun scripts/verify-artifacts.ts", - "test": "bun test", - "test:examples": "bun test examples" - }, - "dependencies": { - "effection": "^4.0.2" - }, - "engines": { - "bun": ">=1.2.0", - "deno": ">=2.2.0", - "node": ">=22" - } + "name": "ghostwright", + "version": "0.1.0", + "description": "Outside-in terminal automation using a real PTY and libghostty-vt WebAssembly", + "license": "MIT", + "files": [ + "src", + "docs", + "examples", + "dist", + "artifacts", + "ghostty.lock.json", + "README.md", + "AGENTS.md", + "PERFORMANCE.md", + "HOST-COMPARISON.md", + "LICENSE" + ], + "type": "module", + "main": "dist/index.js", + "types": "dist/types/index.d.ts", + "exports": { + ".": { + "types": "./dist/types/index.d.ts", + "import": "./dist/index.js" + }, + "./async": { + "types": "./dist/types/async.d.ts", + "import": "./dist/async.js" + }, + "./protocol": { + "types": "./dist/types/pty/protocol.d.ts", + "import": "./dist/pty/protocol.js" + } + }, + "scripts": { + "build": "rm -rf dist && bun build src/index.ts src/async.ts src/pty/protocol.ts --outdir dist --target node --format esm --packages external --sourcemap=external && bunx tsc -p tsconfig.build.json && bun scripts/fix-declarations.ts", + "fetch:ghostty": "bun scripts/fetch-ghostty.ts", + "build:ghostty-vt": "bun scripts/build-ghostty-vt.ts", + "build:host:c": "bun scripts/build-host-c.ts", + "build:host:rust": "bun scripts/build-host-rust.ts", + "test:hosts": "bun test/host-contract.ts .cache/hosts/pty-host-c && bun test/host-contract.ts .cache/hosts/pty-host-rust", + "test:host:rust:full": "GHOSTWRIGHT_CONTRACT_HOST=.cache/hosts/pty-host-rust bun test --preload ./test/preload-host.ts .", + "compare:hosts": "bun scripts/compare-hosts.ts", + "build:artifacts": "bun run fetch:ghostty && bun run build:ghostty-vt && bun scripts/build-artifacts.ts", + "verify:artifacts": "bun scripts/verify-artifacts.ts", + "test": "bun test", + "test:examples": "bun test examples" + }, + "dependencies": { + "effection": "^4.0.2" + }, + "engines": { + "bun": ">=1.2.0", + "deno": ">=2.2.0", + "node": ">=22" + } } diff --git a/experiments/ghostwright/scripts/benchmark.ts b/experiments/ghostwright/scripts/benchmark.ts index 67135c0..2c6345b 100644 --- a/experiments/ghostwright/scripts/benchmark.ts +++ b/experiments/ghostwright/scripts/benchmark.ts @@ -1,45 +1,45 @@ -import { TerminalSession } from "../src/terminal/session.ts"; +import { TerminalSession } from '../src/terminal/session.ts'; async function measure(name: string, operation: () => Promise, iterations: number) { - const samples: number[] = []; - for (let index = 0; index < iterations; index++) { - const started = performance.now(); - await operation(); - samples.push(performance.now() - started); - } - samples.sort((a, b) => a - b); - console.log( - JSON.stringify({ - name, - iterations, - medianMs: samples[Math.floor(samples.length / 2)], - minMs: samples[0], - maxMs: samples.at(-1), - }), - ); + const samples: number[] = []; + for (let index = 0; index < iterations; index++) { + const started = performance.now(); + await operation(); + samples.push(performance.now() - started); + } + samples.sort((a, b) => a - b); + console.log( + JSON.stringify({ + name, + iterations, + medianMs: samples[Math.floor(samples.length / 2)], + minMs: samples[0], + maxMs: samples.at(-1), + }), + ); } await measure( - "launch-exit-cleanup", - async () => { - const terminal = await TerminalSession.launch({ command: "/usr/bin/true", trace: "off" }); - await terminal.process.waitForExit(); - await terminal.close(); - }, - 10, + 'launch-exit-cleanup', + async () => { + const terminal = await TerminalSession.launch({ command: '/usr/bin/true', trace: 'off' }); + await terminal.process.waitForExit(); + await terminal.close(); + }, + 10, ); await measure( - "one-megabyte-output", - async () => { - const terminal = await TerminalSession.launch({ - command: process.execPath, - args: ["-e", `process.stdout.write("x".repeat(1024 * 1024))`], - viewport: { columns: 120, rows: 40 }, - trace: "off", - }); - await terminal.process.waitForExit(); - await terminal.close(); - }, - 5, + 'one-megabyte-output', + async () => { + const terminal = await TerminalSession.launch({ + command: process.execPath, + args: ['-e', `process.stdout.write("x".repeat(1024 * 1024))`], + viewport: { columns: 120, rows: 40 }, + trace: 'off', + }); + await terminal.process.waitForExit(); + await terminal.close(); + }, + 5, ); diff --git a/experiments/ghostwright/scripts/build-artifacts.ts b/experiments/ghostwright/scripts/build-artifacts.ts index cad9ad2..c2003d8 100644 --- a/experiments/ghostwright/scripts/build-artifacts.ts +++ b/experiments/ghostwright/scripts/build-artifacts.ts @@ -1,7 +1,7 @@ -import { $ } from "bun"; +import { $ } from 'bun'; -const root = new URL("..", import.meta.url).pathname, - artifacts = `${root}/artifacts`; +const root = new URL('..', import.meta.url).pathname, + artifacts = `${root}/artifacts`; // The packaged default remains the pure-C implementation while the Rust host // is evaluated side by side. This script never invokes Zig for PTY-host code. diff --git a/experiments/ghostwright/scripts/build-ghostty-vt.ts b/experiments/ghostwright/scripts/build-ghostty-vt.ts index 789bc27..815d1fb 100644 --- a/experiments/ghostwright/scripts/build-ghostty-vt.ts +++ b/experiments/ghostwright/scripts/build-ghostty-vt.ts @@ -1,21 +1,21 @@ -import { $ } from "bun"; -import { readFile } from "node:fs/promises"; -import { createHash } from "node:crypto"; +import { $ } from 'bun'; +import { readFile } from 'node:fs/promises'; +import { createHash } from 'node:crypto'; -const root = new URL("..", import.meta.url).pathname, - source = `${root}/.cache/ghostty`, - lock = JSON.parse(await readFile(`${root}/ghostty.lock.json`, "utf8")), - zig = (await $`zig version`.text()).trim(); +const root = new URL('..', import.meta.url).pathname, + source = `${root}/.cache/ghostty`, + lock = JSON.parse(await readFile(`${root}/ghostty.lock.json`, 'utf8')), + zig = (await $`zig version`.text()).trim(); if (zig !== lock.zigVersion) - throw new Error(`Ghostwright artifact build requires Zig ${lock.zigVersion}, found ${zig}`); + throw new Error(`Ghostwright artifact build requires Zig ${lock.zigVersion}, found ${zig}`); const patch = `${root}/patches/0001-freestanding-kitty-direct-only.patch`; await $`git -C ${source} reset --hard ${lock.ghostty.commit}`; await $`git -C ${source} apply --check ${patch}`; await $`git -C ${source} apply ${patch}`; -const patchSha256 = createHash("sha256") - .update(await readFile(patch)) - .digest("hex"); +const patchSha256 = createHash('sha256') + .update(await readFile(patch)) + .digest('hex'); if (lock.graphics?.freestandingPatchSha256 !== patchSha256) - throw new Error("Ghostwright freestanding Kitty patch checksum mismatch"); + throw new Error('Ghostwright freestanding Kitty patch checksum mismatch'); await $`cd ${source} && zig build -Demit-lib-vt -Dtarget=wasm32-freestanding -Doptimize=ReleaseSmall`; await $`cp ${source}/zig-out/bin/ghostty-vt.wasm ${root}/artifacts/ghostty-vt.wasm`; diff --git a/experiments/ghostwright/scripts/build-host-c.ts b/experiments/ghostwright/scripts/build-host-c.ts index fa9fa17..dc64dd3 100644 --- a/experiments/ghostwright/scripts/build-host-c.ts +++ b/experiments/ghostwright/scripts/build-host-c.ts @@ -1,31 +1,31 @@ -import { $ } from "bun"; -import { mkdir } from "node:fs/promises"; +import { $ } from 'bun'; +import { mkdir } from 'node:fs/promises'; -const root = new URL("..", import.meta.url).pathname, - source = `${root}/native/pty-host-c`, - cache = `${root}/.cache/hosts`, - artifacts = `${root}/artifacts`, - sources = [`${source}/main.c`, `${source}/protocol.c`, `${source}/session.c`]; +const root = new URL('..', import.meta.url).pathname, + source = `${root}/native/pty-host-c`, + cache = `${root}/.cache/hosts`, + artifacts = `${root}/artifacts`, + sources = [`${source}/main.c`, `${source}/protocol.c`, `${source}/session.c`]; await mkdir(cache, { recursive: true }); await mkdir(artifacts, { recursive: true }); -if (process.platform === "darwin") { - for (const architecture of ["arm64", "x86_64"] as const) { - const target = architecture === "x86_64" ? "x64" : architecture, - output = `${artifacts}/pty-host-darwin-${target}`; - await $`xcrun clang -std=c17 -O2 -Wall -Wextra -Werror -arch ${architecture} ${sources} -o ${output}`; - await $`chmod +x ${output}`; - } - await $`cp ${artifacts}/pty-host-darwin-${process.arch} ${cache}/pty-host-c`; -} else if (process.platform === "linux") { - const compiler = process.env.CC ?? "musl-gcc", - target = `linux-${process.arch}`, - output = `${artifacts}/pty-host-${target}`; - await $`${compiler} -std=c17 -O2 -Wall -Wextra -Werror -static ${sources} -o ${output}`; - await $`chmod +x ${output}`; - await $`cp ${output} ${cache}/pty-host-c`; +if (process.platform === 'darwin') { + for (const architecture of ['arm64', 'x86_64'] as const) { + const target = architecture === 'x86_64' ? 'x64' : architecture, + output = `${artifacts}/pty-host-darwin-${target}`; + await $`xcrun clang -std=c17 -O2 -Wall -Wextra -Werror -arch ${architecture} ${sources} -o ${output}`; + await $`chmod +x ${output}`; + } + await $`cp ${artifacts}/pty-host-darwin-${process.arch} ${cache}/pty-host-c`; +} else if (process.platform === 'linux') { + const compiler = process.env.CC ?? 'musl-gcc', + target = `linux-${process.arch}`, + output = `${artifacts}/pty-host-${target}`; + await $`${compiler} -std=c17 -O2 -Wall -Wextra -Werror -static ${sources} -o ${output}`; + await $`chmod +x ${output}`; + await $`cp ${output} ${cache}/pty-host-c`; } else { - throw new Error(`unsupported C host build platform ${process.platform}-${process.arch}`); + throw new Error(`unsupported C host build platform ${process.platform}-${process.arch}`); } console.log(`${cache}/pty-host-c`); diff --git a/experiments/ghostwright/scripts/build-host-rust.ts b/experiments/ghostwright/scripts/build-host-rust.ts index 22aa318..6cf1298 100644 --- a/experiments/ghostwright/scripts/build-host-rust.ts +++ b/experiments/ghostwright/scripts/build-host-rust.ts @@ -1,18 +1,18 @@ -import { $ } from "bun"; -import { mkdir } from "node:fs/promises"; +import { $ } from 'bun'; +import { mkdir } from 'node:fs/promises'; -const root = new URL("..", import.meta.url).pathname, - crate = `${root}/native/pty-host-rust`, - cache = `${root}/.cache/hosts`, - target = process.env.GHOSTWRIGHT_RUST_TARGET; +const root = new URL('..', import.meta.url).pathname, + crate = `${root}/native/pty-host-rust`, + cache = `${root}/.cache/hosts`, + target = process.env.GHOSTWRIGHT_RUST_TARGET; await mkdir(cache, { recursive: true }); if (target) { - await $`cargo build --release --locked --target ${target}`.cwd(crate); - await $`cp ${crate}/target/${target}/release/ghostwright-pty-host ${cache}/pty-host-rust`; + await $`cargo build --release --locked --target ${target}`.cwd(crate); + await $`cp ${crate}/target/${target}/release/ghostwright-pty-host ${cache}/pty-host-rust`; } else { - await $`cargo build --release --locked`.cwd(crate); - await $`cp ${crate}/target/release/ghostwright-pty-host ${cache}/pty-host-rust`; + await $`cargo build --release --locked`.cwd(crate); + await $`cp ${crate}/target/release/ghostwright-pty-host ${cache}/pty-host-rust`; } await $`chmod +x ${cache}/pty-host-rust`; console.log(`${cache}/pty-host-rust`); diff --git a/experiments/ghostwright/scripts/compare-hosts.ts b/experiments/ghostwright/scripts/compare-hosts.ts index 9bb35ed..76d035b 100644 --- a/experiments/ghostwright/scripts/compare-hosts.ts +++ b/experiments/ghostwright/scripts/compare-hosts.ts @@ -1,128 +1,128 @@ -import { $ } from "bun"; -import { readdir, readFile, stat, writeFile } from "node:fs/promises"; -import { join } from "node:path"; -import { TerminalSession } from "../src/terminal/session.ts"; -import { usePtyHostForTesting } from "../src/profile.ts"; -import { SidecarClient } from "../src/pty/client.ts"; -import { runHostContract } from "../test/host-contract.ts"; - -const root = new URL("..", import.meta.url).pathname, - hosts = [ - { name: "Pure C", key: "c", path: `${root}/.cache/hosts/pty-host-c` }, - { name: "Rust", key: "rust", path: `${root}/.cache/hosts/pty-host-rust` }, - ]; +import { $ } from 'bun'; +import { readdir, readFile, stat, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { TerminalSession } from '../src/terminal/session.ts'; +import { usePtyHostForTesting } from '../src/profile.ts'; +import { SidecarClient } from '../src/pty/client.ts'; +import { runHostContract } from '../test/host-contract.ts'; + +const root = new URL('..', import.meta.url).pathname, + hosts = [ + { name: 'Pure C', key: 'c', path: `${root}/.cache/hosts/pty-host-c` }, + { name: 'Rust', key: 'rust', path: `${root}/.cache/hosts/pty-host-rust` }, + ]; async function timed(operation: () => Promise) { - const started = performance.now(); - await operation(); - return performance.now() - started; + const started = performance.now(); + await operation(); + return performance.now() - started; } const buildTimes = { - c: await timed(() => $`bun ${root}/scripts/build-host-c.ts`.quiet()), - rust: await timed(() => $`bun ${root}/scripts/build-host-rust.ts`.quiet()), + c: await timed(() => $`bun ${root}/scripts/build-host-c.ts`.quiet()), + rust: await timed(() => $`bun ${root}/scripts/build-host-rust.ts`.quiet()), }; for (const host of hosts) await runHostContract(host.path); async function launchSamples(hostPath: string) { - const restore = usePtyHostForTesting(hostPath), - samples: number[] = []; - try { - for (let index = 0; index < 12; index++) { - const started = performance.now(), - terminal = await TerminalSession.launch({ command: "/usr/bin/true", trace: "off" }); - await terminal.process.waitForExit(); - await terminal.close(); - samples.push(performance.now() - started); - } - } finally { - restore(); - } - samples.sort((a, b) => a - b); - return samples[Math.floor(samples.length / 2)]; + const restore = usePtyHostForTesting(hostPath), + samples: number[] = []; + try { + for (let index = 0; index < 12; index++) { + const started = performance.now(), + terminal = await TerminalSession.launch({ command: '/usr/bin/true', trace: 'off' }); + await terminal.process.waitForExit(); + await terminal.close(); + samples.push(performance.now() - started); + } + } finally { + restore(); + } + samples.sort((a, b) => a - b); + return samples[Math.floor(samples.length / 2)]; } async function transportThroughput(hostPath: string) { - const environment = Object.fromEntries( - Object.entries(process.env).filter( - (entry): entry is [string, string] => entry[1] !== undefined, - ), - ), - client = await SidecarClient.start(hostPath), - started = performance.now(); - let bytes = 0, - exited = false, - eof = false, - resolve!: () => void; - const completed = new Promise((done) => (resolve = done)), - check = () => { - if (exited && eof) resolve(); - }; - client.on("output", (chunk) => (bytes += chunk.length)); - client.on("exit", () => { - exited = true; - check(); - }); - client.on("eof", () => { - eof = true; - check(); - }); - await client.spawn({ - command: process.execPath, - args: ["-e", `process.stdout.write("x".repeat(1024 * 1024))`], - cwd: process.cwd(), - env: environment, - viewport: { columns: 80, rows: 24, widthPixels: 800, heightPixels: 480 }, - cleanup: { hangupGraceMs: 50, terminateGraceMs: 50, postExitDrainMs: 100 }, - }); - await completed; - const elapsed = performance.now() - started; - await client.close(); - return { bytes, elapsed, mibPerSecond: bytes / (1024 * 1024) / (elapsed / 1000) }; + const environment = Object.fromEntries( + Object.entries(process.env).filter( + (entry): entry is [string, string] => entry[1] !== undefined, + ), + ), + client = await SidecarClient.start(hostPath), + started = performance.now(); + let bytes = 0, + exited = false, + eof = false, + resolve!: () => void; + const completed = new Promise((done) => (resolve = done)), + check = () => { + if (exited && eof) resolve(); + }; + client.on('output', (chunk) => (bytes += chunk.length)); + client.on('exit', () => { + exited = true; + check(); + }); + client.on('eof', () => { + eof = true; + check(); + }); + await client.spawn({ + command: process.execPath, + args: ['-e', `process.stdout.write("x".repeat(1024 * 1024))`], + cwd: process.cwd(), + env: environment, + viewport: { columns: 80, rows: 24, widthPixels: 800, heightPixels: 480 }, + cleanup: { hangupGraceMs: 50, terminateGraceMs: 50, postExitDrainMs: 100 }, + }); + await completed; + const elapsed = performance.now() - started; + await client.close(); + return { bytes, elapsed, mibPerSecond: bytes / (1024 * 1024) / (elapsed / 1000) }; } async function sourceStats(directory: string) { - const names = (await readdir(directory)).filter((name) => /\.(c|h|rs)$/.test(name)), - sources = await Promise.all(names.map((name) => readFile(join(directory, name), "utf8"))); - return { - files: names.length, - lines: sources.reduce((total, source) => total + source.split("\n").length, 0), - nonblank: sources.reduce( - (total, source) => total + source.split("\n").filter((line) => line.trim()).length, - 0, - ), - unsafe: sources.reduce( - (total, source) => total + (source.match(/\bunsafe\b/g)?.length ?? 0), - 0, - ), - }; + const names = (await readdir(directory)).filter((name) => /\.(c|h|rs)$/.test(name)), + sources = await Promise.all(names.map((name) => readFile(join(directory, name), 'utf8'))); + return { + files: names.length, + lines: sources.reduce((total, source) => total + source.split('\n').length, 0), + nonblank: sources.reduce( + (total, source) => total + source.split('\n').filter((line) => line.trim()).length, + 0, + ), + unsafe: sources.reduce( + (total, source) => total + (source.match(/\bunsafe\b/g)?.length ?? 0), + 0, + ), + }; } const results = []; for (const host of hosts) { - const sourceDirectory = - host.key === "c" ? `${root}/native/pty-host-c` : `${root}/native/pty-host-rust/src`, - source = await sourceStats(sourceDirectory), - binary = await stat(host.path), - launchMedianMs = await launchSamples(host.path), - throughput = await transportThroughput(host.path); - results.push({ - ...host, - source, - binaryBytes: binary.size, - buildMs: buildTimes[host.key as keyof typeof buildTimes], - launchMedianMs, - throughput, - }); + const sourceDirectory = + host.key === 'c' ? `${root}/native/pty-host-c` : `${root}/native/pty-host-rust/src`, + source = await sourceStats(sourceDirectory), + binary = await stat(host.path), + launchMedianMs = await launchSamples(host.path), + throughput = await transportThroughput(host.path); + results.push({ + ...host, + source, + binaryBytes: binary.size, + buildMs: buildTimes[host.key as keyof typeof buildTimes], + launchMedianMs, + throughput, + }); } const table = results - .map( - (result) => - `| ${result.name} | ${result.source.files} | ${result.source.nonblank} | ${result.source.unsafe} | ${(result.binaryBytes / 1024).toFixed(1)} KiB | ${result.buildMs.toFixed(1)} ms | ${result.launchMedianMs.toFixed(1)} ms | ${result.throughput.mibPerSecond.toFixed(1)} MiB/s |`, - ) - .join("\n"); + .map( + (result) => + `| ${result.name} | ${result.source.files} | ${result.source.nonblank} | ${result.source.unsafe} | ${(result.binaryBytes / 1024).toFixed(1)} KiB | ${result.buildMs.toFixed(1)} ms | ${result.launchMedianMs.toFixed(1)} ms | ${result.throughput.mibPerSecond.toFixed(1)} MiB/s |`, + ) + .join('\n'); const document = `# PTY Host C vs. Rust Comparison Generated on ${new Date().toISOString()} by \`bun run compare:hosts\` on ${process.platform}-${process.arch}. diff --git a/experiments/ghostwright/scripts/fetch-ghostty.ts b/experiments/ghostwright/scripts/fetch-ghostty.ts index 3dde8a2..d2de82f 100644 --- a/experiments/ghostwright/scripts/fetch-ghostty.ts +++ b/experiments/ghostwright/scripts/fetch-ghostty.ts @@ -1,15 +1,15 @@ -import { $ } from "bun"; -import { existsSync } from "node:fs"; -import { mkdir, readFile } from "node:fs/promises"; +import { $ } from 'bun'; +import { existsSync } from 'node:fs'; +import { mkdir, readFile } from 'node:fs/promises'; -const root = new URL("..", import.meta.url).pathname, - cache = `${root}/.cache/ghostty`, - lock = JSON.parse(await readFile(`${root}/ghostty.lock.json`, "utf8")); +const root = new URL('..', import.meta.url).pathname, + cache = `${root}/.cache/ghostty`, + lock = JSON.parse(await readFile(`${root}/ghostty.lock.json`, 'utf8')); await mkdir(`${root}/.cache`, { recursive: true }); if (!existsSync(`${cache}/.git`)) - await $`git clone --filter=blob:none ${lock.ghostty.repository} ${cache}`; + await $`git clone --filter=blob:none ${lock.ghostty.repository} ${cache}`; await $`git -C ${cache} fetch --depth=1 origin ${lock.ghostty.commit}`; await $`git -C ${cache} checkout --detach ${lock.ghostty.commit}`; const actual = (await $`git -C ${cache} rev-parse HEAD`.text()).trim(); if (actual !== lock.ghostty.commit) - throw new Error(`Ghostty checkout mismatch: expected ${lock.ghostty.commit}, got ${actual}`); + throw new Error(`Ghostty checkout mismatch: expected ${lock.ghostty.commit}, got ${actual}`); diff --git a/experiments/ghostwright/scripts/fix-declarations.ts b/experiments/ghostwright/scripts/fix-declarations.ts index d26f2f8..5dc2298 100644 --- a/experiments/ghostwright/scripts/fix-declarations.ts +++ b/experiments/ghostwright/scripts/fix-declarations.ts @@ -1,15 +1,15 @@ -import { readdir, readFile, writeFile } from "node:fs/promises"; -import { join } from "node:path"; +import { readdir, readFile, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; async function rewrite(directory: string): Promise { - for (const entry of await readdir(directory, { withFileTypes: true })) { - const path = join(directory, entry.name); - if (entry.isDirectory()) await rewrite(path); - else if (entry.name.endsWith(".d.ts")) { - const source = await readFile(path, "utf8"); - await writeFile(path, source.replace(/(from\s+["'][.]{1,2}\/[^"']+)\.ts(["'])/g, "$1.js$2")); - } - } + for (const entry of await readdir(directory, { withFileTypes: true })) { + const path = join(directory, entry.name); + if (entry.isDirectory()) await rewrite(path); + else if (entry.name.endsWith('.d.ts')) { + const source = await readFile(path, 'utf8'); + await writeFile(path, source.replace(/(from\s+["'][.]{1,2}\/[^"']+)\.ts(["'])/g, '$1.js$2')); + } + } } -await rewrite(new URL("../dist/types", import.meta.url).pathname); +await rewrite(new URL('../dist/types', import.meta.url).pathname); diff --git a/experiments/ghostwright/scripts/update-manifest.ts b/experiments/ghostwright/scripts/update-manifest.ts index f5fbfd0..81798b6 100644 --- a/experiments/ghostwright/scripts/update-manifest.ts +++ b/experiments/ghostwright/scripts/update-manifest.ts @@ -1,116 +1,116 @@ -import { createHash } from "node:crypto"; -import { readdir, readFile, writeFile } from "node:fs/promises"; -const root = new URL("../artifacts/", import.meta.url), - files = [ - ...(await readdir(root)).filter((x) => x === "ghostty-vt.wasm" || x.startsWith("pty-host-")), - "terminfo/67/ghostty", - "terminfo/78/xterm-ghostty", - ]; +import { createHash } from 'node:crypto'; +import { readdir, readFile, writeFile } from 'node:fs/promises'; +const root = new URL('../artifacts/', import.meta.url), + files = [ + ...(await readdir(root)).filter((x) => x === 'ghostty-vt.wasm' || x.startsWith('pty-host-')), + 'terminfo/67/ghostty', + 'terminfo/78/xterm-ghostty', + ]; const artifacts: Record = {}; for (const name of files.sort()) { - artifacts[`artifacts/${name}`] = { - sha256: createHash("sha256") - .update(await readFile(new URL(name, root))) - .digest("hex"), - }; + artifacts[`artifacts/${name}`] = { + sha256: createHash('sha256') + .update(await readFile(new URL(name, root))) + .digest('hex'), + }; } const lock = { - schemaVersion: 1, - ghostty: { - repository: "https://github.com/ghostty-org/ghostty", - commit: "f8041e849b36efbbb9736b6ecf0ccfcb01d94e69", - }, - zigVersion: "0.15.2", - buildFlags: ["-Demit-lib-vt", "-Dtarget=wasm32-freestanding", "ReleaseSmall"], - ptyHostImplementation: "c", - ptyHostBuildFlags: ["clang-or-musl-gcc", "-std=c17", "-O2", "linux:-static"], - targets: ["darwin-arm64", "darwin-x64", "linux-arm64", "linux-x64"], - protocolVersion: 1, - bindingVersion: 1, - licenses: [ - { component: "ghostwright", license: "MIT", notice: "LICENSE" }, - { component: "libghostty-vt", license: "MIT", notice: "Ghostty source pin recorded above" }, - ], - requiredWasmExports: [ - "memory", - "__indirect_function_table", - "ghostty_type_json", - "ghostty_terminal_new", - "ghostty_terminal_free", - "ghostty_terminal_set", - "ghostty_terminal_get", - "ghostty_terminal_vt_write", - "ghostty_terminal_resize", - "ghostty_terminal_mode_get", - "ghostty_terminal_grid_ref", - "ghostty_grid_ref_cell", - "ghostty_grid_ref_row", - "ghostty_grid_ref_graphemes", - "ghostty_grid_ref_hyperlink_uri", - "ghostty_grid_ref_style", - "ghostty_cell_get", - "ghostty_cell_get_multi", - "ghostty_row_get", - "ghostty_formatter_terminal_new", - "ghostty_formatter_format_buf", - "ghostty_formatter_free", - "ghostty_render_state_new", - "ghostty_render_state_update", - "ghostty_render_state_get", - "ghostty_render_state_row_iterator_new", - "ghostty_render_state_row_iterator_next", - "ghostty_render_state_row_iterator_free", - "ghostty_render_state_row_get", - "ghostty_render_state_row_cells_new", - "ghostty_render_state_row_cells_next", - "ghostty_render_state_row_cells_get_multi", - "ghostty_render_state_row_cells_free", - "ghostty_render_state_free", - "ghostty_key_event_new", - "ghostty_key_event_free", - "ghostty_key_encoder_new", - "ghostty_key_encoder_free", - "ghostty_key_encoder_setopt_from_terminal", - "ghostty_key_encoder_encode", - "ghostty_mouse_event_new", - "ghostty_mouse_event_free", - "ghostty_mouse_encoder_new", - "ghostty_mouse_encoder_free", - "ghostty_mouse_encoder_setopt", - "ghostty_mouse_encoder_setopt_from_terminal", - "ghostty_mouse_encoder_encode", - "ghostty_paste_encode", - "ghostty_focus_encode", - ], - abi: { - structSizes: { - GhosttyTerminalOptions: 8, - GhosttyFormatterTerminalOptions: 40, - GhosttyPoint: 24, - GhosttyPointCoordinate: 8, - GhosttyGridRef: 12, - GhosttyStyle: 72, - GhosttyStyleColor: 16, - GhosttyMouseEncoderSize: 36, - GhosttyMousePosition: 8, - GhosttyString: 8, - GhosttySizeReportSize: 12, - GhosttyDeviceAttributes: 148, - GhosttyClipboardWrite: 16, - }, - enumValues: { - terminalOptionWritePty: 1, - terminalOptionClipboardWrite: 26, - terminalDataActiveScreen: 6, - terminalDataTitle: 12, - terminalDataPwd: 13, - cellDataWide: 3, - cellDataHasHyperlink: 7, - }, - }, - artifacts, + schemaVersion: 1, + ghostty: { + repository: 'https://github.com/ghostty-org/ghostty', + commit: 'f8041e849b36efbbb9736b6ecf0ccfcb01d94e69', + }, + zigVersion: '0.15.2', + buildFlags: ['-Demit-lib-vt', '-Dtarget=wasm32-freestanding', 'ReleaseSmall'], + ptyHostImplementation: 'c', + ptyHostBuildFlags: ['clang-or-musl-gcc', '-std=c17', '-O2', 'linux:-static'], + targets: ['darwin-arm64', 'darwin-x64', 'linux-arm64', 'linux-x64'], + protocolVersion: 1, + bindingVersion: 1, + licenses: [ + { component: 'ghostwright', license: 'MIT', notice: 'LICENSE' }, + { component: 'libghostty-vt', license: 'MIT', notice: 'Ghostty source pin recorded above' }, + ], + requiredWasmExports: [ + 'memory', + '__indirect_function_table', + 'ghostty_type_json', + 'ghostty_terminal_new', + 'ghostty_terminal_free', + 'ghostty_terminal_set', + 'ghostty_terminal_get', + 'ghostty_terminal_vt_write', + 'ghostty_terminal_resize', + 'ghostty_terminal_mode_get', + 'ghostty_terminal_grid_ref', + 'ghostty_grid_ref_cell', + 'ghostty_grid_ref_row', + 'ghostty_grid_ref_graphemes', + 'ghostty_grid_ref_hyperlink_uri', + 'ghostty_grid_ref_style', + 'ghostty_cell_get', + 'ghostty_cell_get_multi', + 'ghostty_row_get', + 'ghostty_formatter_terminal_new', + 'ghostty_formatter_format_buf', + 'ghostty_formatter_free', + 'ghostty_render_state_new', + 'ghostty_render_state_update', + 'ghostty_render_state_get', + 'ghostty_render_state_row_iterator_new', + 'ghostty_render_state_row_iterator_next', + 'ghostty_render_state_row_iterator_free', + 'ghostty_render_state_row_get', + 'ghostty_render_state_row_cells_new', + 'ghostty_render_state_row_cells_next', + 'ghostty_render_state_row_cells_get_multi', + 'ghostty_render_state_row_cells_free', + 'ghostty_render_state_free', + 'ghostty_key_event_new', + 'ghostty_key_event_free', + 'ghostty_key_encoder_new', + 'ghostty_key_encoder_free', + 'ghostty_key_encoder_setopt_from_terminal', + 'ghostty_key_encoder_encode', + 'ghostty_mouse_event_new', + 'ghostty_mouse_event_free', + 'ghostty_mouse_encoder_new', + 'ghostty_mouse_encoder_free', + 'ghostty_mouse_encoder_setopt', + 'ghostty_mouse_encoder_setopt_from_terminal', + 'ghostty_mouse_encoder_encode', + 'ghostty_paste_encode', + 'ghostty_focus_encode', + ], + abi: { + structSizes: { + GhosttyTerminalOptions: 8, + GhosttyFormatterTerminalOptions: 40, + GhosttyPoint: 24, + GhosttyPointCoordinate: 8, + GhosttyGridRef: 12, + GhosttyStyle: 72, + GhosttyStyleColor: 16, + GhosttyMouseEncoderSize: 36, + GhosttyMousePosition: 8, + GhosttyString: 8, + GhosttySizeReportSize: 12, + GhosttyDeviceAttributes: 148, + GhosttyClipboardWrite: 16, + }, + enumValues: { + terminalOptionWritePty: 1, + terminalOptionClipboardWrite: 26, + terminalDataActiveScreen: 6, + terminalDataTitle: 12, + terminalDataPwd: 13, + cellDataWide: 3, + cellDataHasHyperlink: 7, + }, + }, + artifacts, }; await writeFile( - new URL("../ghostty.lock.json", import.meta.url), - JSON.stringify(lock, null, 2) + "\n", + new URL('../ghostty.lock.json', import.meta.url), + JSON.stringify(lock, null, 2) + '\n', ); diff --git a/experiments/ghostwright/scripts/verify-artifacts.ts b/experiments/ghostwright/scripts/verify-artifacts.ts index 5050672..b5d4197 100644 --- a/experiments/ghostwright/scripts/verify-artifacts.ts +++ b/experiments/ghostwright/scripts/verify-artifacts.ts @@ -1,47 +1,47 @@ -import { createHash } from "node:crypto"; -import { readFile } from "node:fs/promises"; -const root = new URL("..", import.meta.url), - lock = JSON.parse(await readFile(new URL("ghostty.lock.json", root), "utf8")); +import { createHash } from 'node:crypto'; +import { readFile } from 'node:fs/promises'; +const root = new URL('..', import.meta.url), + lock = JSON.parse(await readFile(new URL('ghostty.lock.json', root), 'utf8')); if (lock.protocolVersion !== 1 || lock.bindingVersion !== 2) - throw new Error("protocol or binding version mismatch"); + throw new Error('protocol or binding version mismatch'); for (const [path, entry] of Object.entries(lock.artifacts) as [string, { sha256: string }][]) { - const actual = createHash("sha256") - .update(await readFile(new URL(path, root))) - .digest("hex"); - if (actual !== entry.sha256) - throw new Error(`${path}: checksum mismatch (expected ${entry.sha256}, got ${actual})`); + const actual = createHash('sha256') + .update(await readFile(new URL(path, root))) + .digest('hex'); + if (actual !== entry.sha256) + throw new Error(`${path}: checksum mismatch (expected ${entry.sha256}, got ${actual})`); } -for (const path of Object.keys(lock.artifacts).filter((path) => path.includes("pty-host-"))) { - const binary = await readFile(new URL(path, root)); - if (!binary.includes(Buffer.from(`GWPT_PROTOCOL_VERSION=${lock.protocolVersion}`))) - throw new Error(`${path}: protocol marker mismatch`); +for (const path of Object.keys(lock.artifacts).filter((path) => path.includes('pty-host-'))) { + const binary = await readFile(new URL(path, root)); + if (!binary.includes(Buffer.from(`GWPT_PROTOCOL_VERSION=${lock.protocolVersion}`))) + throw new Error(`${path}: protocol marker mismatch`); } -const wasmBytes = await readFile(new URL("artifacts/ghostty-vt.wasm", root)), - wasm = await WebAssembly.compile(wasmBytes), - exports = new Set(WebAssembly.Module.exports(wasm).map((x) => x.name)); +const wasmBytes = await readFile(new URL('artifacts/ghostty-vt.wasm', root)), + wasm = await WebAssembly.compile(wasmBytes), + exports = new Set(WebAssembly.Module.exports(wasm).map((x) => x.name)); for (const name of lock.requiredWasmExports) - if (!exports.has(name)) throw new Error(`ghostty-vt.wasm: missing export ${name}`); + if (!exports.has(name)) throw new Error(`ghostty-vt.wasm: missing export ${name}`); const instance = await WebAssembly.instantiate(wasm, { env: { log() {} } }), - wasmExports = instance.exports as Record, - pointer = wasmExports.ghostty_type_json(), - bytes = new Uint8Array((wasmExports.memory as WebAssembly.Memory).buffer); + wasmExports = instance.exports as Record, + pointer = wasmExports.ghostty_type_json(), + bytes = new Uint8Array((wasmExports.memory as WebAssembly.Memory).buffer); let end = pointer; while (bytes[end] !== 0) end++; const layouts = JSON.parse(new TextDecoder().decode(bytes.subarray(pointer, end))); for (const [name, size] of Object.entries(lock.abi.structSizes)) - if (layouts[name]?.size !== size) - throw new Error( - `ghostty-vt.wasm: ${name} size mismatch (expected ${size}, got ${layouts[name]?.size})`, - ); + if (layouts[name]?.size !== size) + throw new Error( + `ghostty-vt.wasm: ${name} size mismatch (expected ${size}, got ${layouts[name]?.size})`, + ); if (lock.graphics?.kittyGraphics) { - const out = wasmExports.ghostty_wasm_alloc_u8_array(1); - try { - if (wasmExports.ghostty_build_info(2, out) !== 0 || bytes[out] === 0) - throw new Error("ghostty-vt.wasm: Kitty graphics capability is absent"); - } finally { - wasmExports.ghostty_wasm_free_u8_array(out, 1); - } + const out = wasmExports.ghostty_wasm_alloc_u8_array(1); + try { + if (wasmExports.ghostty_build_info(2, out) !== 0 || bytes[out] === 0) + throw new Error('ghostty-vt.wasm: Kitty graphics capability is absent'); + } finally { + wasmExports.ghostty_wasm_free_u8_array(out, 1); + } } console.log( - `verified ${Object.keys(lock.artifacts).length} Ghostwright artifacts and ${Object.keys(lock.abi.structSizes).length} ABI layouts`, + `verified ${Object.keys(lock.artifacts).length} Ghostwright artifacts and ${Object.keys(lock.abi.structSizes).length} ABI layouts`, ); diff --git a/experiments/ghostwright/src/assertions/index.ts b/experiments/ghostwright/src/assertions/index.ts index c600eb3..d2afb1d 100644 --- a/experiments/ghostwright/src/assertions/index.ts +++ b/experiments/ghostwright/src/assertions/index.ts @@ -1,233 +1,233 @@ -import { StrictLocatorError, TerminalAssertionError } from "../errors.ts"; -import type { AsyncLocatorExpectation, AsyncTerminalExpectation } from "./types-internal.ts"; +import { StrictLocatorError, TerminalAssertionError } from '../errors.ts'; +import type { AsyncLocatorExpectation, AsyncTerminalExpectation } from './types-internal.ts'; import type { - AssertionOptions, - ScreenRevision, - ScreenSnapshot, - StableAssertionOptions, - TransientAssertionOptions, -} from "../types.ts"; -import { Locator, TerminalSession } from "../terminal/session.ts"; + AssertionOptions, + ScreenRevision, + ScreenSnapshot, + StableAssertionOptions, + TransientAssertionOptions, +} from '../types.ts'; +import { Locator, TerminalSession } from '../terminal/session.ts'; function diagnostic(session: TerminalSession, expected: string, timeout: number, settle?: number) { - const s = session.screen.current(), - tens = Array.from({ length: s.viewport.columns }, (_, column) => - column % 10 === 0 ? String(Math.floor(column / 10) % 10) : " ", - ).join(""), - ones = Array.from({ length: s.viewport.columns }, (_, column) => String(column % 10)).join(""), - rows = s.lines.map((line) => `${String(line.row).padStart(3)} |${line.text}|`).join("\n"), - recent = session.trace - .events() - .filter((event) => event.type === "action") - .slice(-5) - .map((event) => `#${event.sequence} action=${event.actionSequence} kind=${event.kind}`) - .join("; "); - return `Ghostwright assertion failed\nexpected: ${expected}\ntimeout: ${timeout} ms${settle === undefined ? "" : `\nsettle: ${settle} ms`}\nviewport: ${s.viewport.columns}x${s.viewport.rows}\ncursor: (${s.cursor.column},${s.cursor.row}) visible=${s.cursor.visible} shape=${s.cursor.shape} blinking=${s.cursor.blinking}\nactive buffer: ${s.activeBuffer}\nmodes: ${JSON.stringify(s.modes)}\nprocess: ${JSON.stringify(session.process.status())}\nchanged rows: ${session.revisionHistory.at(-1)?.changedRows.join(",") ?? "none"}\nhistory: earliest=${session.revisionHistory.at(0)?.sequence ?? s.sequence} latest=${session.revisionHistory.at(-1)?.sequence ?? s.sequence}\nrecent actions: ${recent || "none"}\nclosest candidates: ${ - s.lines - .map((line) => line.text.trimEnd()) - .filter(Boolean) - .slice(0, 5) - .map((line) => JSON.stringify(line)) - .join(", ") || "none" - }\n\n ${tens}\n ${ones}\n${rows}`; + const s = session.screen.current(), + tens = Array.from({ length: s.viewport.columns }, (_, column) => + column % 10 === 0 ? String(Math.floor(column / 10) % 10) : ' ', + ).join(''), + ones = Array.from({ length: s.viewport.columns }, (_, column) => String(column % 10)).join(''), + rows = s.lines.map((line) => `${String(line.row).padStart(3)} |${line.text}|`).join('\n'), + recent = session.trace + .events() + .filter((event) => event.type === 'action') + .slice(-5) + .map((event) => `#${event.sequence} action=${event.actionSequence} kind=${event.kind}`) + .join('; '); + return `Ghostwright assertion failed\nexpected: ${expected}\ntimeout: ${timeout} ms${settle === undefined ? '' : `\nsettle: ${settle} ms`}\nviewport: ${s.viewport.columns}x${s.viewport.rows}\ncursor: (${s.cursor.column},${s.cursor.row}) visible=${s.cursor.visible} shape=${s.cursor.shape} blinking=${s.cursor.blinking}\nactive buffer: ${s.activeBuffer}\nmodes: ${JSON.stringify(s.modes)}\nprocess: ${JSON.stringify(session.process.status())}\nchanged rows: ${session.revisionHistory.at(-1)?.changedRows.join(',') ?? 'none'}\nhistory: earliest=${session.revisionHistory.at(0)?.sequence ?? s.sequence} latest=${session.revisionHistory.at(-1)?.sequence ?? s.sequence}\nrecent actions: ${recent || 'none'}\nclosest candidates: ${ + s.lines + .map((line) => line.text.trimEnd()) + .filter(Boolean) + .slice(0, 5) + .map((line) => JSON.stringify(line)) + .join(', ') || 'none' + }\n\n ${tens}\n ${ones}\n${rows}`; } async function wait( - session: TerminalSession, - test: () => boolean, - timeout: number, - message: () => string, + session: TerminalSession, + test: () => boolean, + timeout: number, + message: () => string, ) { - try { - await session.waitForChange(test, timeout); - } catch (cause) { - if (cause instanceof StrictLocatorError) throw cause; - throw new TerminalAssertionError(message(), { cause }); - } + try { + await session.waitForChange(test, timeout); + } catch (cause) { + if (cause instanceof StrictLocatorError) throw cause; + throw new TerminalAssertionError(message(), { cause }); + } } class LocatorExpectation implements AsyncLocatorExpectation { - constructor(readonly locator: Locator) {} - async toBePresent(options: AssertionOptions = {}) { - const timeout = options.timeoutMs ?? this.locator.session.options.assertionTimeoutMs ?? 5000; - try { - return await this.locator.unique(timeout); - } catch (cause) { - if (cause instanceof StrictLocatorError) throw cause; - throw new TerminalAssertionError( - diagnostic( - this.locator.session, - `${JSON.stringify(this.locator.query)} to be present`, - timeout, - ), - { cause }, - ); - } - } - async toBeStable(options: StableAssertionOptions = {}) { - const timeout = options.timeoutMs ?? this.locator.session.options.assertionTimeoutMs ?? 5000, - settle = options.settleMs ?? this.locator.session.options.settleMs ?? 100, - start = performance.now(); - let match = await this.toBePresent({ timeoutMs: timeout }); - for (;;) { - const age = - this.locator.session.now() - this.locator.session.screen.current().lastVisualChangeAt, - remaining = Math.max(0, settle - age); - if (!remaining) return match; - if (performance.now() - start + remaining > timeout) - throw new TerminalAssertionError( - diagnostic( - this.locator.session, - `${JSON.stringify(this.locator.query)} to be visually stable`, - timeout, - settle, - ), - ); - await new Promise((resolve) => { - const off = this.locator.session.subscribe(() => { - off(); - clearTimeout(timer); - resolve(); - }), - timer = setTimeout(() => { - off(); - resolve(); - }, remaining); - }); - const m = this.locator.matches(); - if (m.length > 1) - throw new StrictLocatorError( - `Locator ${JSON.stringify(this.locator.query)} matched ${m.length} ranges`, - ); - if (m.length === 1) match = m[0]; - else - match = await this.toBePresent({ - timeoutMs: Math.max(1, timeout - (performance.now() - start)), - }); - } - } - async toBeAbsent(options: StableAssertionOptions = {}) { - const timeout = options.timeoutMs ?? this.locator.session.options.assertionTimeoutMs ?? 5000, - settle = options.settleMs ?? this.locator.session.options.settleMs ?? 100, - start = performance.now(); - for (;;) { - if (this.locator.matches().length === 0) { - const snapshot = this.locator.session.screen.current(), - age = this.locator.session.now() - snapshot.lastVisualChangeAt, - remaining = Math.max(0, settle - age); - if (remaining === 0) return; - await new Promise((resolve) => { - const off = this.locator.session.subscribe(() => { - off(); - clearTimeout(timer); - resolve(); - }), - timer = setTimeout(() => { - off(); - resolve(); - }, remaining); - }); - if ( - this.locator.matches().length === 0 && - this.locator.session.now() - this.locator.session.screen.current().lastVisualChangeAt >= - settle - ) - return; - } - if (performance.now() - start >= timeout) - throw new TerminalAssertionError( - diagnostic( - this.locator.session, - `${JSON.stringify(this.locator.query)} to be absent`, - timeout, - settle, - ), - ); - await wait( - this.locator.session, - () => this.locator.matches().length === 0, - Math.max(1, timeout - (performance.now() - start)), - () => - diagnostic( - this.locator.session, - `${JSON.stringify(this.locator.query)} to be absent`, - timeout, - settle, - ), - ); - } - } + constructor(readonly locator: Locator) {} + async toBePresent(options: AssertionOptions = {}) { + const timeout = options.timeoutMs ?? this.locator.session.options.assertionTimeoutMs ?? 5000; + try { + return await this.locator.unique(timeout); + } catch (cause) { + if (cause instanceof StrictLocatorError) throw cause; + throw new TerminalAssertionError( + diagnostic( + this.locator.session, + `${JSON.stringify(this.locator.query)} to be present`, + timeout, + ), + { cause }, + ); + } + } + async toBeStable(options: StableAssertionOptions = {}) { + const timeout = options.timeoutMs ?? this.locator.session.options.assertionTimeoutMs ?? 5000, + settle = options.settleMs ?? this.locator.session.options.settleMs ?? 100, + start = performance.now(); + let match = await this.toBePresent({ timeoutMs: timeout }); + for (;;) { + const age = + this.locator.session.now() - this.locator.session.screen.current().lastVisualChangeAt, + remaining = Math.max(0, settle - age); + if (!remaining) return match; + if (performance.now() - start + remaining > timeout) + throw new TerminalAssertionError( + diagnostic( + this.locator.session, + `${JSON.stringify(this.locator.query)} to be visually stable`, + timeout, + settle, + ), + ); + await new Promise((resolve) => { + const off = this.locator.session.subscribe(() => { + off(); + clearTimeout(timer); + resolve(); + }), + timer = setTimeout(() => { + off(); + resolve(); + }, remaining); + }); + const m = this.locator.matches(); + if (m.length > 1) + throw new StrictLocatorError( + `Locator ${JSON.stringify(this.locator.query)} matched ${m.length} ranges`, + ); + if (m.length === 1) match = m[0]; + else + match = await this.toBePresent({ + timeoutMs: Math.max(1, timeout - (performance.now() - start)), + }); + } + } + async toBeAbsent(options: StableAssertionOptions = {}) { + const timeout = options.timeoutMs ?? this.locator.session.options.assertionTimeoutMs ?? 5000, + settle = options.settleMs ?? this.locator.session.options.settleMs ?? 100, + start = performance.now(); + for (;;) { + if (this.locator.matches().length === 0) { + const snapshot = this.locator.session.screen.current(), + age = this.locator.session.now() - snapshot.lastVisualChangeAt, + remaining = Math.max(0, settle - age); + if (remaining === 0) return; + await new Promise((resolve) => { + const off = this.locator.session.subscribe(() => { + off(); + clearTimeout(timer); + resolve(); + }), + timer = setTimeout(() => { + off(); + resolve(); + }, remaining); + }); + if ( + this.locator.matches().length === 0 && + this.locator.session.now() - this.locator.session.screen.current().lastVisualChangeAt >= + settle + ) + return; + } + if (performance.now() - start >= timeout) + throw new TerminalAssertionError( + diagnostic( + this.locator.session, + `${JSON.stringify(this.locator.query)} to be absent`, + timeout, + settle, + ), + ); + await wait( + this.locator.session, + () => this.locator.matches().length === 0, + Math.max(1, timeout - (performance.now() - start)), + () => + diagnostic( + this.locator.session, + `${JSON.stringify(this.locator.query)} to be absent`, + timeout, + settle, + ), + ); + } + } } class TerminalExpectation implements AsyncTerminalExpectation { - constructor(readonly session: TerminalSession) {} - async toSatisfy( - predicate: (snapshot: ScreenSnapshot) => boolean, - options: StableAssertionOptions = {}, - ) { - const timeout = options.timeoutMs ?? this.session.options.assertionTimeoutMs ?? 5000, - settle = options.settleMs ?? this.session.options.settleMs ?? 100, - started = performance.now(); - for (;;) { - const snapshot = this.session.screen.current(); - if (predicate(snapshot)) { - const remaining = Math.max(0, settle - (this.session.now() - snapshot.lastVisualChangeAt)); - if (remaining === 0) return snapshot; - if (performance.now() - started + remaining > timeout) - throw new TerminalAssertionError( - diagnostic(this.session, "screen predicate to converge", timeout, settle), - ); - await new Promise((resolve) => { - const unsubscribe = this.session.subscribe(() => { - clearTimeout(timer); - unsubscribe(); - resolve(); - }), - timer = setTimeout(() => { - unsubscribe(); - resolve(); - }, remaining); - }); - continue; - } - await wait( - this.session, - () => predicate(this.session.screen.current()), - Math.max(1, timeout - (performance.now() - started)), - () => diagnostic(this.session, "screen predicate to converge", timeout, settle), - ); - } - } - async toHaveShown( - predicate: (snapshot: ScreenSnapshot) => boolean, - options: TransientAssertionOptions = {}, - ) { - const timeout = options.timeoutMs ?? this.session.options.assertionTimeoutMs ?? 5000, - baseline = - typeof options.since === "number" - ? options.since - : (options.since?.screenSequenceBefore ?? - this.session.lastAction?.screenSequenceBefore ?? - this.session.screen.current().sequence); - const find = () => - this.session.revisionsSince(baseline).find((revision) => predicate(revision.snapshot)); - let result = find(); - if (!result) - await wait( - this.session, - () => !!(result = find()), - timeout, - () => diagnostic(this.session, `screen predicate since revision ${baseline}`, timeout), - ); - return result as ScreenRevision; - } - toHaveShownText(text: string, options: TransientAssertionOptions = {}) { - return this.toHaveShown( - (snapshot) => snapshot.lines.some((line) => line.text.includes(text)), - options, - ); - } + constructor(readonly session: TerminalSession) {} + async toSatisfy( + predicate: (snapshot: ScreenSnapshot) => boolean, + options: StableAssertionOptions = {}, + ) { + const timeout = options.timeoutMs ?? this.session.options.assertionTimeoutMs ?? 5000, + settle = options.settleMs ?? this.session.options.settleMs ?? 100, + started = performance.now(); + for (;;) { + const snapshot = this.session.screen.current(); + if (predicate(snapshot)) { + const remaining = Math.max(0, settle - (this.session.now() - snapshot.lastVisualChangeAt)); + if (remaining === 0) return snapshot; + if (performance.now() - started + remaining > timeout) + throw new TerminalAssertionError( + diagnostic(this.session, 'screen predicate to converge', timeout, settle), + ); + await new Promise((resolve) => { + const unsubscribe = this.session.subscribe(() => { + clearTimeout(timer); + unsubscribe(); + resolve(); + }), + timer = setTimeout(() => { + unsubscribe(); + resolve(); + }, remaining); + }); + continue; + } + await wait( + this.session, + () => predicate(this.session.screen.current()), + Math.max(1, timeout - (performance.now() - started)), + () => diagnostic(this.session, 'screen predicate to converge', timeout, settle), + ); + } + } + async toHaveShown( + predicate: (snapshot: ScreenSnapshot) => boolean, + options: TransientAssertionOptions = {}, + ) { + const timeout = options.timeoutMs ?? this.session.options.assertionTimeoutMs ?? 5000, + baseline = + typeof options.since === 'number' + ? options.since + : (options.since?.screenSequenceBefore ?? + this.session.lastAction?.screenSequenceBefore ?? + this.session.screen.current().sequence); + const find = () => + this.session.revisionsSince(baseline).find((revision) => predicate(revision.snapshot)); + let result = find(); + if (!result) + await wait( + this.session, + () => !!(result = find()), + timeout, + () => diagnostic(this.session, `screen predicate since revision ${baseline}`, timeout), + ); + return result as ScreenRevision; + } + toHaveShownText(text: string, options: TransientAssertionOptions = {}) { + return this.toHaveShown( + (snapshot) => snapshot.lines.some((line) => line.text.includes(text)), + options, + ); + } } export function expectTerminal( - target: Locator | TerminalSession, + target: Locator | TerminalSession, ): LocatorExpectation | TerminalExpectation { - return target instanceof Locator - ? new LocatorExpectation(target) - : new TerminalExpectation(target); + return target instanceof Locator + ? new LocatorExpectation(target) + : new TerminalExpectation(target); } diff --git a/experiments/ghostwright/src/assertions/types-internal.ts b/experiments/ghostwright/src/assertions/types-internal.ts index d9051ad..67902b7 100644 --- a/experiments/ghostwright/src/assertions/types-internal.ts +++ b/experiments/ghostwright/src/assertions/types-internal.ts @@ -1,24 +1,24 @@ import type { - AssertionOptions, - LocatorMatch, - ScreenRevision, - ScreenSnapshot, - StableAssertionOptions, - TransientAssertionOptions, -} from "../types.ts"; + AssertionOptions, + LocatorMatch, + ScreenRevision, + ScreenSnapshot, + StableAssertionOptions, + TransientAssertionOptions, +} from '../types.ts'; export interface AsyncLocatorExpectation { - toBePresent(options?: AssertionOptions): Promise; - toBeAbsent(options?: StableAssertionOptions): Promise; - toBeStable(options?: StableAssertionOptions): Promise; + toBePresent(options?: AssertionOptions): Promise; + toBeAbsent(options?: StableAssertionOptions): Promise; + toBeStable(options?: StableAssertionOptions): Promise; } export interface AsyncTerminalExpectation { - toSatisfy( - predicate: (snapshot: ScreenSnapshot) => boolean, - options?: StableAssertionOptions, - ): Promise; - toHaveShown( - predicate: (snapshot: ScreenSnapshot) => boolean, - options?: TransientAssertionOptions, - ): Promise; - toHaveShownText(text: string, options?: TransientAssertionOptions): Promise; + toSatisfy( + predicate: (snapshot: ScreenSnapshot) => boolean, + options?: StableAssertionOptions, + ): Promise; + toHaveShown( + predicate: (snapshot: ScreenSnapshot) => boolean, + options?: TransientAssertionOptions, + ): Promise; + toHaveShownText(text: string, options?: TransientAssertionOptions): Promise; } diff --git a/experiments/ghostwright/src/async.ts b/experiments/ghostwright/src/async.ts index 02bed66..51f2f5b 100644 --- a/experiments/ghostwright/src/async.ts +++ b/experiments/ghostwright/src/async.ts @@ -1,39 +1,39 @@ -import { call, run } from "effection"; -import type { AsyncTerminal, TerminalLaunchOptions } from "./types.ts"; -import { TerminalSession } from "./terminal/session.ts"; +import { call, run } from 'effection'; +import type { AsyncTerminal, TerminalLaunchOptions } from './types.ts'; +import { TerminalSession } from './terminal/session.ts'; export async function withTerminalAsync( - options: TerminalLaunchOptions, - body: (terminal: AsyncTerminal) => Promise, + options: TerminalLaunchOptions, + body: (terminal: AsyncTerminal) => Promise, ): Promise { - return run(function* () { - const session: TerminalSession = yield* call(() => TerminalSession.launch(options)); - try { - const result: T = yield* call(() => body(session)); - if (session.trace.policy === "on") - yield* call(() => - session.trace.persist( - "Session completed successfully", - session.screen.current(), - session.process.status(), - ), - ); - return result; - } catch (error) { - try { - const path = yield* call(() => - session.trace.persist(error, session.screen.current(), session.process.status()), - ); - if (path && error instanceof Error) { - (error as Error & { tracePath?: string }).tracePath = path; - error.message += `\ntrace artifact: ${path}`; - } - } catch (traceError) { - if (error instanceof Error) - (error as Error & { suppressed?: unknown[] }).suppressed = [traceError]; - } - throw error; - } finally { - yield* call(() => session.close()); - } - }); + return run(function* () { + const session: TerminalSession = yield* call(() => TerminalSession.launch(options)); + try { + const result: T = yield* call(() => body(session)); + if (session.trace.policy === 'on') + yield* call(() => + session.trace.persist( + 'Session completed successfully', + session.screen.current(), + session.process.status(), + ), + ); + return result; + } catch (error) { + try { + const path = yield* call(() => + session.trace.persist(error, session.screen.current(), session.process.status()), + ); + if (path && error instanceof Error) { + (error as Error & { tracePath?: string }).tracePath = path; + error.message += `\ntrace artifact: ${path}`; + } + } catch (traceError) { + if (error instanceof Error) + (error as Error & { suppressed?: unknown[] }).suppressed = [traceError]; + } + throw error; + } finally { + yield* call(() => session.close()); + } + }); } diff --git a/experiments/ghostwright/src/effection/index.ts b/experiments/ghostwright/src/effection/index.ts index 464594f..afaa69b 100644 --- a/experiments/ghostwright/src/effection/index.ts +++ b/experiments/ghostwright/src/effection/index.ts @@ -1,152 +1,152 @@ -import { call, type Operation } from "effection"; +import { call, type Operation } from 'effection'; import type { - ActionReceipt, - AssertionOptions, - KeyName, - HistoryQuery, - HistorySearchOptions, - KeyPress, - MouseOptions, - RevisionCollectionOptions, - OperationLocator, - OperationRegion, - OperationTerminal, - Point, - Rect, - StableAssertionOptions, - ScreenSnapshot, - TerminalLaunchOptions, - TextLocatorOptions, - TraceableInputOptions, - TransientAssertionOptions, - WheelOptions, -} from "../types.ts"; -import { expectTerminal as expectAsync } from "../assertions/index.ts"; -import { Locator, TerminalSession } from "../terminal/session.ts"; + ActionReceipt, + AssertionOptions, + KeyName, + HistoryQuery, + HistorySearchOptions, + KeyPress, + MouseOptions, + RevisionCollectionOptions, + OperationLocator, + OperationRegion, + OperationTerminal, + Point, + Rect, + StableAssertionOptions, + ScreenSnapshot, + TerminalLaunchOptions, + TextLocatorOptions, + TraceableInputOptions, + TransientAssertionOptions, + WheelOptions, +} from '../types.ts'; +import { expectTerminal as expectAsync } from '../assertions/index.ts'; +import { Locator, TerminalSession } from '../terminal/session.ts'; const op = (fn: () => Promise): Operation => call(fn); export class EffectionLocator implements OperationLocator { - constructor(readonly inner: Locator) {} - nth(i: number) { - return new EffectionLocator(this.inner.nth(i)); - } - region(r: Rect) { - return new EffectionLocator(this.inner.region(r)); - } - matches() { - return this.inner.matches(); - } - click(o?: MouseOptions) { - return op(() => this.inner.click(o)); - } + constructor(readonly inner: Locator) {} + nth(i: number) { + return new EffectionLocator(this.inner.nth(i)); + } + region(r: Rect) { + return new EffectionLocator(this.inner.region(r)); + } + matches() { + return this.inner.matches(); + } + click(o?: MouseOptions) { + return op(() => this.inner.click(o)); + } } export class EffectionTerminal implements OperationTerminal { - constructor(readonly inner: TerminalSession) {} - keyboard = { - press: (k: KeyName | KeyPress) => op(() => this.inner.keyboard.press(k)), - type: (t: string, o?: TraceableInputOptions) => op(() => this.inner.keyboard.type(t, o)), - paste: (t: string, o?: TraceableInputOptions) => op(() => this.inner.keyboard.paste(t, o)), - focus: (s: "in" | "out") => op(() => this.inner.keyboard.focus(s)), - write: (d: Uint8Array) => op(() => this.inner.keyboard.write(d)), - }; - mouse = { - move: (p: Point, o?: MouseOptions) => op(() => this.inner.mouse.move(p, o)), - down: (p: Point, o?: MouseOptions) => op(() => this.inner.mouse.down(p, o)), - up: (p: Point, o?: MouseOptions) => op(() => this.inner.mouse.up(p, o)), - click: (p: Point, o?: MouseOptions) => op(() => this.inner.mouse.click(p, o)), - doubleClick: (p: Point, o?: MouseOptions) => op(() => this.inner.mouse.doubleClick(p, o)), - drag: (a: Point, b: Point, o?: MouseOptions) => op(() => this.inner.mouse.drag(a, b, o)), - wheel: (o: WheelOptions) => op(() => this.inner.mouse.wheel(o)), - }; - process = { - status: () => this.inner.process.status(), - signal: (s: string, t?: "child" | "process-group") => op(() => this.inner.process.signal(s, t)), - waitForExit: (o?: AssertionOptions) => op(() => this.inner.process.waitForExit(o)), - }; - get screen() { - return this.inner.screen; - } - revisions = { - collect: (options: RevisionCollectionOptions) => - op(() => this.inner.revisions.collect(options)), - }; - history = { - read: (query?: HistoryQuery) => op(() => this.inner.history.read(query)), - findText: (text: string, options?: HistorySearchOptions) => - op(() => this.inner.history.findText(text, options)), - }; - graphics = { - inspectImage: (id: number) => op(() => this.inner.graphics.inspectImage(id)), - copyImageData: (id: number) => op(() => this.inner.graphics.copyImageData(id)), - }; - getByText(t: string, o?: TextLocatorOptions) { - return new EffectionLocator(this.inner.getByText(t, o) as Locator); - } - region(r: Rect): OperationRegion { - const x = this.inner.region(r); - return { - getByText: (t, o) => new EffectionLocator(x.getByText(t, o) as Locator), - snapshot: () => x.snapshot(), - }; - } - resize(v: any) { - return op(() => this.inner.resize(v)); - } - close() { - return op(() => this.inner.close()); - } + constructor(readonly inner: TerminalSession) {} + keyboard = { + press: (k: KeyName | KeyPress) => op(() => this.inner.keyboard.press(k)), + type: (t: string, o?: TraceableInputOptions) => op(() => this.inner.keyboard.type(t, o)), + paste: (t: string, o?: TraceableInputOptions) => op(() => this.inner.keyboard.paste(t, o)), + focus: (s: 'in' | 'out') => op(() => this.inner.keyboard.focus(s)), + write: (d: Uint8Array) => op(() => this.inner.keyboard.write(d)), + }; + mouse = { + move: (p: Point, o?: MouseOptions) => op(() => this.inner.mouse.move(p, o)), + down: (p: Point, o?: MouseOptions) => op(() => this.inner.mouse.down(p, o)), + up: (p: Point, o?: MouseOptions) => op(() => this.inner.mouse.up(p, o)), + click: (p: Point, o?: MouseOptions) => op(() => this.inner.mouse.click(p, o)), + doubleClick: (p: Point, o?: MouseOptions) => op(() => this.inner.mouse.doubleClick(p, o)), + drag: (a: Point, b: Point, o?: MouseOptions) => op(() => this.inner.mouse.drag(a, b, o)), + wheel: (o: WheelOptions) => op(() => this.inner.mouse.wheel(o)), + }; + process = { + status: () => this.inner.process.status(), + signal: (s: string, t?: 'child' | 'process-group') => op(() => this.inner.process.signal(s, t)), + waitForExit: (o?: AssertionOptions) => op(() => this.inner.process.waitForExit(o)), + }; + get screen() { + return this.inner.screen; + } + revisions = { + collect: (options: RevisionCollectionOptions) => + op(() => this.inner.revisions.collect(options)), + }; + history = { + read: (query?: HistoryQuery) => op(() => this.inner.history.read(query)), + findText: (text: string, options?: HistorySearchOptions) => + op(() => this.inner.history.findText(text, options)), + }; + graphics = { + inspectImage: (id: number) => op(() => this.inner.graphics.inspectImage(id)), + copyImageData: (id: number) => op(() => this.inner.graphics.copyImageData(id)), + }; + getByText(t: string, o?: TextLocatorOptions) { + return new EffectionLocator(this.inner.getByText(t, o) as Locator); + } + region(r: Rect): OperationRegion { + const x = this.inner.region(r); + return { + getByText: (t, o) => new EffectionLocator(x.getByText(t, o) as Locator), + snapshot: () => x.snapshot(), + }; + } + resize(v: any) { + return op(() => this.inner.resize(v)); + } + close() { + return op(() => this.inner.close()); + } } export function* withTerminal( - options: TerminalLaunchOptions, - body: (terminal: OperationTerminal) => Operation, + options: TerminalLaunchOptions, + body: (terminal: OperationTerminal) => Operation, ): Operation { - const session: TerminalSession = yield* call(() => TerminalSession.launch(options)); - try { - const result: T = yield* body(new EffectionTerminal(session)); - if (session.trace.policy === "on") - yield* call(() => - session.trace.persist( - "Session completed successfully", - session.screen.current(), - session.process.status(), - ), - ); - return result; - } catch (error) { - try { - const path = yield* call(() => - session.trace.persist(error, session.screen.current(), session.process.status()), - ); - if (path && error instanceof Error) { - (error as Error & { tracePath?: string }).tracePath = path; - error.message += `\ntrace artifact: ${path}`; - } - } catch (traceError) { - if (error instanceof Error) - (error as Error & { suppressed?: unknown[] }).suppressed = [traceError]; - } - throw error; - } finally { - yield* call(() => session.close()); - } + const session: TerminalSession = yield* call(() => TerminalSession.launch(options)); + try { + const result: T = yield* body(new EffectionTerminal(session)); + if (session.trace.policy === 'on') + yield* call(() => + session.trace.persist( + 'Session completed successfully', + session.screen.current(), + session.process.status(), + ), + ); + return result; + } catch (error) { + try { + const path = yield* call(() => + session.trace.persist(error, session.screen.current(), session.process.status()), + ); + if (path && error instanceof Error) { + (error as Error & { tracePath?: string }).tracePath = path; + error.message += `\ntrace artifact: ${path}`; + } + } catch (traceError) { + if (error instanceof Error) + (error as Error & { suppressed?: unknown[] }).suppressed = [traceError]; + } + throw error; + } finally { + yield* call(() => session.close()); + } } export function expectOperation(target: EffectionLocator | EffectionTerminal) { - if (target instanceof EffectionLocator) { - const e = expectAsync(target.inner) as any; - return { - toBePresent: (o?: AssertionOptions) => op(() => e.toBePresent(o)), - toBeAbsent: (o?: StableAssertionOptions) => op(() => e.toBeAbsent(o)), - toBeStable: (o?: StableAssertionOptions) => op(() => e.toBeStable(o)), - }; - } - const e = expectAsync(target.inner) as any; - return { - toSatisfy: (predicate: (snapshot: ScreenSnapshot) => boolean, o?: StableAssertionOptions) => - op(() => e.toSatisfy(predicate, o)), - toHaveShown: ( - predicate: (snapshot: ScreenSnapshot) => boolean, - o?: TransientAssertionOptions, - ) => op(() => e.toHaveShown(predicate, o)), - toHaveShownText: (t: string, o?: TransientAssertionOptions) => - op(() => e.toHaveShownText(t, o)), - }; + if (target instanceof EffectionLocator) { + const e = expectAsync(target.inner) as any; + return { + toBePresent: (o?: AssertionOptions) => op(() => e.toBePresent(o)), + toBeAbsent: (o?: StableAssertionOptions) => op(() => e.toBeAbsent(o)), + toBeStable: (o?: StableAssertionOptions) => op(() => e.toBeStable(o)), + }; + } + const e = expectAsync(target.inner) as any; + return { + toSatisfy: (predicate: (snapshot: ScreenSnapshot) => boolean, o?: StableAssertionOptions) => + op(() => e.toSatisfy(predicate, o)), + toHaveShown: ( + predicate: (snapshot: ScreenSnapshot) => boolean, + o?: TransientAssertionOptions, + ) => op(() => e.toHaveShown(predicate, o)), + toHaveShownText: (t: string, o?: TransientAssertionOptions) => + op(() => e.toHaveShownText(t, o)), + }; } diff --git a/experiments/ghostwright/src/errors.ts b/experiments/ghostwright/src/errors.ts index 32034ae..fc85f33 100644 --- a/experiments/ghostwright/src/errors.ts +++ b/experiments/ghostwright/src/errors.ts @@ -1,49 +1,49 @@ export class GhostwrightError extends Error { - readonly code: string; - sessionName?: string; - tracePath?: string; - suppressed?: unknown[]; - constructor(code: string, message: string, options?: ErrorOptions & { sessionName?: string }) { - super(message, options); - this.name = new.target.name; - this.code = code; - this.sessionName = options?.sessionName; - } + readonly code: string; + sessionName?: string; + tracePath?: string; + suppressed?: unknown[]; + constructor(code: string, message: string, options?: ErrorOptions & { sessionName?: string }) { + super(message, options); + this.name = new.target.name; + this.code = code; + this.sessionName = options?.sessionName; + } } function errorType(name: T, code: string) { - return class extends GhostwrightError { - constructor(message: string, options?: ErrorOptions & { sessionName?: string }) { - super(code, message, options); - this.name = name; - } - }; + return class extends GhostwrightError { + constructor(message: string, options?: ErrorOptions & { sessionName?: string }) { + super(code, message, options); + this.name = name; + } + }; } export class UnsupportedPlatformError extends errorType( - "UnsupportedPlatformError", - "GW_UNSUPPORTED_PLATFORM", + 'UnsupportedPlatformError', + 'GW_UNSUPPORTED_PLATFORM', ) {} -export class AssetIntegrityError extends errorType("AssetIntegrityError", "GW_ASSET_INTEGRITY") {} -export class DenoPermissionError extends errorType("DenoPermissionError", "GW_DENO_PERMISSION") {} +export class AssetIntegrityError extends errorType('AssetIntegrityError', 'GW_ASSET_INTEGRITY') {} +export class DenoPermissionError extends errorType('DenoPermissionError', 'GW_DENO_PERMISSION') {} export class ReservedEnvironmentError extends errorType( - "ReservedEnvironmentError", - "GW_RESERVED_ENV", + 'ReservedEnvironmentError', + 'GW_RESERVED_ENV', ) {} -export class LaunchError extends errorType("LaunchError", "GW_LAUNCH") {} -export class ProtocolError extends errorType("ProtocolError", "GW_PROTOCOL") {} +export class LaunchError extends errorType('LaunchError', 'GW_LAUNCH') {} +export class ProtocolError extends errorType('ProtocolError', 'GW_PROTOCOL') {} export class HostCommandTimeoutError extends errorType( - "HostCommandTimeoutError", - "GW_HOST_TIMEOUT", + 'HostCommandTimeoutError', + 'GW_HOST_TIMEOUT', ) {} -export class SidecarExitedError extends errorType("SidecarExitedError", "GW_SIDECAR_EXITED") {} -export class ProcessExitedError extends errorType("ProcessExitedError", "GW_PROCESS_EXITED") {} -export class SessionClosedError extends errorType("SessionClosedError", "GW_SESSION_CLOSED") {} +export class SidecarExitedError extends errorType('SidecarExitedError', 'GW_SIDECAR_EXITED') {} +export class ProcessExitedError extends errorType('ProcessExitedError', 'GW_PROCESS_EXITED') {} +export class SessionClosedError extends errorType('SessionClosedError', 'GW_SESSION_CLOSED') {} export class CoordinateRangeError extends errorType( - "CoordinateRangeError", - "GW_COORDINATE_RANGE", + 'CoordinateRangeError', + 'GW_COORDINATE_RANGE', ) {} -export class StrictLocatorError extends errorType("StrictLocatorError", "GW_LOCATOR_STRICT") {} -export class TerminalAssertionError extends errorType("TerminalAssertionError", "GW_ASSERTION") {} -export class HistoryEvictedError extends errorType("HistoryEvictedError", "GW_HISTORY_EVICTED") {} -export class HistoryChangedError extends errorType("HistoryChangedError", "GW_HISTORY_CHANGED") {} -export class TraceWriteError extends errorType("TraceWriteError", "GW_TRACE_WRITE") {} -export class CleanupError extends errorType("CleanupError", "GW_CLEANUP") {} +export class StrictLocatorError extends errorType('StrictLocatorError', 'GW_LOCATOR_STRICT') {} +export class TerminalAssertionError extends errorType('TerminalAssertionError', 'GW_ASSERTION') {} +export class HistoryEvictedError extends errorType('HistoryEvictedError', 'GW_HISTORY_EVICTED') {} +export class HistoryChangedError extends errorType('HistoryChangedError', 'GW_HISTORY_CHANGED') {} +export class TraceWriteError extends errorType('TraceWriteError', 'GW_TRACE_WRITE') {} +export class CleanupError extends errorType('CleanupError', 'GW_CLEANUP') {} diff --git a/experiments/ghostwright/src/index.ts b/experiments/ghostwright/src/index.ts index cf32b6e..cade8ab 100644 --- a/experiments/ghostwright/src/index.ts +++ b/experiments/ghostwright/src/index.ts @@ -1,39 +1,39 @@ -export * from "./types.ts"; -export * from "./errors.ts"; -export { withTerminalAsync } from "./async.ts"; -export { withTerminal } from "./effection/index.ts"; -export { replayTrace, type ReplayResult } from "./tracing/replay.ts"; -import { expectTerminal as expectAsync } from "./assertions/index.ts"; -import { EffectionLocator, EffectionTerminal, expectOperation } from "./effection/index.ts"; +export * from './types.ts'; +export * from './errors.ts'; +export { withTerminalAsync } from './async.ts'; +export { withTerminal } from './effection/index.ts'; +export { replayTrace, type ReplayResult } from './tracing/replay.ts'; +import { expectTerminal as expectAsync } from './assertions/index.ts'; +import { EffectionLocator, EffectionTerminal, expectOperation } from './effection/index.ts'; import type { - AsyncLocator, - AsyncLocatorExpectation, - AsyncTerminal, - AsyncTerminalExpectation, - OperationLocator, - OperationLocatorExpectation, - OperationTerminal, - OperationTerminalExpectation, -} from "./types.ts"; + AsyncLocator, + AsyncLocatorExpectation, + AsyncTerminal, + AsyncTerminalExpectation, + OperationLocator, + OperationLocatorExpectation, + OperationTerminal, + OperationTerminalExpectation, +} from './types.ts'; export function expectTerminal(target: OperationLocator): OperationLocatorExpectation; export function expectTerminal(target: AsyncLocator): AsyncLocatorExpectation; export function expectTerminal(target: OperationTerminal): OperationTerminalExpectation; export function expectTerminal(target: AsyncTerminal): AsyncTerminalExpectation; export function expectTerminal( - target: OperationLocator | OperationTerminal | AsyncLocator | AsyncTerminal, + target: OperationLocator | OperationTerminal | AsyncLocator | AsyncTerminal, ): - | OperationLocatorExpectation - | AsyncLocatorExpectation - | OperationTerminalExpectation - | AsyncTerminalExpectation { - return ( - target instanceof EffectionLocator || target instanceof EffectionTerminal - ? expectOperation(target) - : expectAsync(target as any) - ) as - | OperationLocatorExpectation - | AsyncLocatorExpectation - | OperationTerminalExpectation - | AsyncTerminalExpectation; + | OperationLocatorExpectation + | AsyncLocatorExpectation + | OperationTerminalExpectation + | AsyncTerminalExpectation { + return ( + target instanceof EffectionLocator || target instanceof EffectionTerminal + ? expectOperation(target) + : expectAsync(target as any) + ) as + | OperationLocatorExpectation + | AsyncLocatorExpectation + | OperationTerminalExpectation + | AsyncTerminalExpectation; } diff --git a/experiments/ghostwright/src/profile.ts b/experiments/ghostwright/src/profile.ts index 32398d3..df66d0d 100644 --- a/experiments/ghostwright/src/profile.ts +++ b/experiments/ghostwright/src/profile.ts @@ -1,140 +1,140 @@ -import { createHash } from "node:crypto"; -import { readFile, realpath } from "node:fs/promises"; -import { dirname, resolve, sep } from "node:path"; -import { fileURLToPath } from "node:url"; +import { createHash } from 'node:crypto'; +import { readFile, realpath } from 'node:fs/promises'; +import { dirname, resolve, sep } from 'node:path'; +import { fileURLToPath } from 'node:url'; import { - ReservedEnvironmentError, - UnsupportedPlatformError, - AssetIntegrityError, - DenoPermissionError, - LaunchError, -} from "./errors.ts"; -import type { TerminalLaunchOptions, Viewport } from "./types.ts"; + ReservedEnvironmentError, + UnsupportedPlatformError, + AssetIntegrityError, + DenoPermissionError, + LaunchError, +} from './errors.ts'; +import type { TerminalLaunchOptions, Viewport } from './types.ts'; export const RESERVED_ENVIRONMENT = [ - "TERM", - "TERMINFO", - "COLORTERM", - "TERM_PROGRAM", - "TERM_PROGRAM_VERSION", + 'TERM', + 'TERMINFO', + 'COLORTERM', + 'TERM_PROGRAM', + 'TERM_PROGRAM_VERSION', ] as const; -export const PACKAGE_VERSION = "0.1.0"; +export const PACKAGE_VERSION = '0.1.0'; let testPtyHostPath: string | undefined; /** Internal contract-test seam; not exported from the package root. */ export function usePtyHostForTesting(path: string | undefined) { - const previous = testPtyHostPath; - testPtyHostPath = path; - return () => { - testPtyHostPath = previous; - }; + const previous = testPtyHostPath; + testPtyHostPath = path; + return () => { + testPtyHostPath = previous; + }; } function versionAtLeast(actual: string, required: readonly [number, number]) { - const [major = 0, minor = 0] = actual.replace(/^v/, "").split(".").map(Number); - return major > required[0] || (major === required[0] && minor >= required[1]); + const [major = 0, minor = 0] = actual.replace(/^v/, '').split('.').map(Number); + return major > required[0] || (major === required[0] && minor >= required[1]); } export function assertSupportedRuntime() { - const deno = (globalThis as unknown as { Deno?: { version: { deno: string } } }).Deno, - bun = (globalThis as unknown as { Bun?: { version: string } }).Bun; - if (deno && !versionAtLeast(deno.version.deno, [2, 2])) - throw new LaunchError(`Ghostwright requires Deno 2.2 or newer; found ${deno.version.deno}`); - if (bun && !versionAtLeast(bun.version, [1, 2])) - throw new LaunchError(`Ghostwright requires Bun 1.2 or newer; found ${bun.version}`); - if (!deno && !bun && !versionAtLeast(process.versions.node, [22, 0])) - throw new LaunchError(`Ghostwright requires Node 22 or newer; found ${process.versions.node}`); + const deno = (globalThis as unknown as { Deno?: { version: { deno: string } } }).Deno, + bun = (globalThis as unknown as { Bun?: { version: string } }).Bun; + if (deno && !versionAtLeast(deno.version.deno, [2, 2])) + throw new LaunchError(`Ghostwright requires Deno 2.2 or newer; found ${deno.version.deno}`); + if (bun && !versionAtLeast(bun.version, [1, 2])) + throw new LaunchError(`Ghostwright requires Bun 1.2 or newer; found ${bun.version}`); + if (!deno && !bun && !versionAtLeast(process.versions.node, [22, 0])) + throw new LaunchError(`Ghostwright requires Node 22 or newer; found ${process.versions.node}`); } export function normalizeViewport(input?: Viewport): Required { - const columns = input?.columns ?? 80, - rows = input?.rows ?? 24; - if ( - !Number.isInteger(columns) || - !Number.isInteger(rows) || - columns <= 0 || - rows <= 0 || - columns > 65535 || - rows > 65535 - ) - throw new RangeError(`Invalid viewport ${columns}x${rows}`); - const widthPixels = input?.widthPixels ?? columns * 10, - heightPixels = input?.heightPixels ?? rows * 20; - if ( - !Number.isInteger(widthPixels) || - !Number.isInteger(heightPixels) || - widthPixels <= 0 || - heightPixels <= 0 || - widthPixels > 65535 || - heightPixels > 65535 - ) - throw new RangeError(`Invalid pixel viewport ${widthPixels}x${heightPixels}`); - return { columns, rows, widthPixels, heightPixels }; + const columns = input?.columns ?? 80, + rows = input?.rows ?? 24; + if ( + !Number.isInteger(columns) || + !Number.isInteger(rows) || + columns <= 0 || + rows <= 0 || + columns > 65535 || + rows > 65535 + ) + throw new RangeError(`Invalid viewport ${columns}x${rows}`); + const widthPixels = input?.widthPixels ?? columns * 10, + heightPixels = input?.heightPixels ?? rows * 20; + if ( + !Number.isInteger(widthPixels) || + !Number.isInteger(heightPixels) || + widthPixels <= 0 || + heightPixels <= 0 || + widthPixels > 65535 || + heightPixels > 65535 + ) + throw new RangeError(`Invalid pixel viewport ${widthPixels}x${heightPixels}`); + return { columns, rows, widthPixels, heightPixels }; } export function profileEnvironment( - explicit: Readonly> | undefined, - terminfo: string, + explicit: Readonly> | undefined, + terminfo: string, ) { - const bad = RESERVED_ENVIRONMENT.filter((k) => Object.hasOwn(explicit ?? {}, k)); - if (bad.length) - throw new ReservedEnvironmentError( - `Terminal profile variables cannot be overridden: ${bad.join(", ")}`, - ); - return { - ...process.env, - ...explicit, - TERM: "xterm-ghostty", - TERMINFO: terminfo, - COLORTERM: "truecolor", - TERM_PROGRAM: "ghostwright", - TERM_PROGRAM_VERSION: PACKAGE_VERSION, - } as Record; + const bad = RESERVED_ENVIRONMENT.filter((k) => Object.hasOwn(explicit ?? {}, k)); + if (bad.length) + throw new ReservedEnvironmentError( + `Terminal profile variables cannot be overridden: ${bad.join(', ')}`, + ); + return { + ...process.env, + ...explicit, + TERM: 'xterm-ghostty', + TERMINFO: terminfo, + COLORTERM: 'truecolor', + TERM_PROGRAM: 'ghostwright', + TERM_PROGRAM_VERSION: PACKAGE_VERSION, + } as Record; } export function target(os = process.platform, arch = process.arch) { - const key = `${os}-${arch}`; - const supported = ["darwin-arm64", "darwin-x64", "linux-arm64", "linux-x64"]; - if (!supported.includes(key)) - throw new UnsupportedPlatformError( - `Unsupported platform ${key}; supported platforms: ${supported.join(", ")}`, - ); - return key; + const key = `${os}-${arch}`; + const supported = ['darwin-arm64', 'darwin-x64', 'linux-arm64', 'linux-x64']; + if (!supported.includes(key)) + throw new UnsupportedPlatformError( + `Unsupported platform ${key}; supported platforms: ${supported.join(', ')}`, + ); + return key; } export async function resolveAssets(options: TerminalLaunchOptions) { - const root = resolve(dirname(fileURLToPath(import.meta.url)), "../artifacts"), - bundledHost = resolve(root, `pty-host-${target()}`), - host = testPtyHostPath ?? bundledHost, - terminfo = resolve(root, "terminfo"); - try { - const rr = await realpath(root), - rh = await realpath(host), - wasm = await realpath(resolve(root, "ghostty-vt.wasm")), - terminfoEntry = await realpath(resolve(terminfo, "78/xterm-ghostty")); - for (const path of [wasm, terminfoEntry, ...(testPtyHostPath ? [] : [rh])]) - if (path !== rr && !path.startsWith(rr + sep)) - throw new AssetIntegrityError(`Runtime asset resolves outside artifact directory: ${path}`); - const lock = JSON.parse(await readFile(resolve(root, "../ghostty.lock.json"), "utf8")); - for (const { path, key } of [ - ...(testPtyHostPath ? [] : [{ path: rh, key: `artifacts/${rh.split(sep).at(-1)}` }]), - { path: wasm, key: "artifacts/ghostty-vt.wasm" }, - { - path: terminfoEntry, - key: "artifacts/terminfo/78/xterm-ghostty", - }, - ]) { - const expected = lock.artifacts[key]?.sha256; - const actual = createHash("sha256") - .update(await readFile(path)) - .digest("hex"); - if (!expected || actual !== expected) { - throw new AssetIntegrityError( - `${path}: checksum mismatch (expected ${expected ?? "manifest entry"}, got ${actual})`, - ); - } - } - return { root: rr, host: rh, terminfo, wasm }; - } catch (cause) { - if ("Deno" in globalThis && !(cause instanceof AssetIntegrityError)) - throw new DenoPermissionError( - `Deno cannot read Ghostwright artifacts. Retry with --allow-read=${root} --allow-run=${host}`, - { cause }, - ); - throw cause; - } + const root = resolve(dirname(fileURLToPath(import.meta.url)), '../artifacts'), + bundledHost = resolve(root, `pty-host-${target()}`), + host = testPtyHostPath ?? bundledHost, + terminfo = resolve(root, 'terminfo'); + try { + const rr = await realpath(root), + rh = await realpath(host), + wasm = await realpath(resolve(root, 'ghostty-vt.wasm')), + terminfoEntry = await realpath(resolve(terminfo, '78/xterm-ghostty')); + for (const path of [wasm, terminfoEntry, ...(testPtyHostPath ? [] : [rh])]) + if (path !== rr && !path.startsWith(rr + sep)) + throw new AssetIntegrityError(`Runtime asset resolves outside artifact directory: ${path}`); + const lock = JSON.parse(await readFile(resolve(root, '../ghostty.lock.json'), 'utf8')); + for (const { path, key } of [ + ...(testPtyHostPath ? [] : [{ path: rh, key: `artifacts/${rh.split(sep).at(-1)}` }]), + { path: wasm, key: 'artifacts/ghostty-vt.wasm' }, + { + path: terminfoEntry, + key: 'artifacts/terminfo/78/xterm-ghostty', + }, + ]) { + const expected = lock.artifacts[key]?.sha256; + const actual = createHash('sha256') + .update(await readFile(path)) + .digest('hex'); + if (!expected || actual !== expected) { + throw new AssetIntegrityError( + `${path}: checksum mismatch (expected ${expected ?? 'manifest entry'}, got ${actual})`, + ); + } + } + return { root: rr, host: rh, terminfo, wasm }; + } catch (cause) { + if ('Deno' in globalThis && !(cause instanceof AssetIntegrityError)) + throw new DenoPermissionError( + `Deno cannot read Ghostwright artifacts. Retry with --allow-read=${root} --allow-run=${host}`, + { cause }, + ); + throw cause; + } } diff --git a/experiments/ghostwright/src/pty/client.ts b/experiments/ghostwright/src/pty/client.ts index 11167c5..5b0f8f8 100644 --- a/experiments/ghostwright/src/pty/client.ts +++ b/experiments/ghostwright/src/pty/client.ts @@ -1,219 +1,219 @@ -import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; -import { dirname } from "node:path"; +import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process'; +import { dirname } from 'node:path'; import { - DenoPermissionError, - HostCommandTimeoutError, - ProtocolError, - SidecarExitedError, -} from "../errors.ts"; + DenoPermissionError, + HostCommandTimeoutError, + ProtocolError, + SidecarExitedError, +} from '../errors.ts'; import { - decodeCbor, - encodeCbor, - encodeFrame, - FrameDecoder, - FrameKind, - type Frame, -} from "./protocol"; + decodeCbor, + encodeCbor, + encodeFrame, + FrameDecoder, + FrameKind, + type Frame, +} from './protocol'; export interface SidecarEvents { - output: (data: Uint8Array, sequence: number) => void; - exit: (value: { exitCode: number | null; signal: string | null }) => void; - eof: () => void; - fatal: (error: Error) => void; + output: (data: Uint8Array, sequence: number) => void; + exit: (value: { exitCode: number | null; signal: string | null }) => void; + eof: () => void; + fatal: (error: Error) => void; } type Key = keyof SidecarEvents; export class SidecarClient { - #child: ChildProcessWithoutNullStreams; - #sequence = 1; - #pending = new Map< - number, - { - kind: FrameKind; - resolve: (v: any) => void; - reject: (e: unknown) => void; - timer: ReturnType; - interim?: Record; - } - >(); - #listeners: { [K in Key]: Set } = { - output: new Set(), - exit: new Set(), - eof: new Set(), - fatal: new Set(), - }; - #decoder = new FrameDecoder(); - #stderr = ""; - #closed = false; - #applicationPid?: number; - #applicationPgid?: number; - private constructor( - child: ChildProcessWithoutNullStreams, - readonly commandTimeoutMs: number, - ) { - this.#child = child; - child.stdout.on("data", (b: Buffer) => { - try { - for (const f of this.#decoder.push(b)) this.#frame(f); - } catch (e) { - this.#fail(e as Error); - } - }); - child.stderr.on("data", (b: Buffer) => { - this.#stderr = (this.#stderr + b.toString()).slice(-8192); - }); - child.on("exit", (code, signal) => { - if (!this.#closed) - this.#fail(new SidecarExitedError(`PTY host exited (${code ?? signal}); ${this.#stderr}`)); - }); - child.on("error", (error) => - this.#fail( - "Deno" in globalThis && /permission|denied|EACCES|requires run access/i.test(error.message) - ? new DenoPermissionError( - `Deno cannot execute Ghostwright's PTY host. Retry with --allow-read=${dirname(child.spawnfile)} --allow-run=${child.spawnfile}`, - { cause: error }, - ) - : new SidecarExitedError(`PTY host failed: ${error.message}`, { cause: error }), - ), - ); - } - static async start(path: string, timeoutMs = 5000) { - const child = spawn(path, [], { stdio: ["pipe", "pipe", "pipe"] }); - const c = new SidecarClient(child, timeoutMs); - await c.request(FrameKind.HELLO, { minVersion: 1, maxVersion: 1, clientVersion: "0.1.0" }); - return c; - } - on(key: K, listener: SidecarEvents[K]) { - this.#listeners[key].add(listener); - return () => this.#listeners[key].delete(listener); - } - #emit(key: K, ...args: Parameters) { - for (const f of this.#listeners[key]) - try { - (f as (...a: any[]) => void)(...args); - } catch (e) { - this.#fail(e as Error); - } - } - #frame(f: Frame) { - if (f.kind === FrameKind.OUTPUT) { - this.#emit("output", f.payload, f.sequence); - return; - } - if (f.kind === FrameKind.PROCESS_EXIT) { - this.#emit("exit", decodeCbor(f.payload) as any); - return; - } - if (f.kind === FrameKind.PTY_EOF) { - this.#emit("eof"); - return; - } - if (f.kind === FrameKind.ERROR && f.correlation === 0) { - const d = decodeCbor(f.payload) as any; - this.#fail(new ProtocolError(`${d.code}: ${d.message}`)); - return; - } - const p = this.#pending.get(f.correlation); - if (!p) throw new ProtocolError(`Uncorrelated sidecar response ${f.correlation}`); - if (f.kind === FrameKind.SPAWNED && p.kind === FrameKind.SPAWN) { - const spawned = decodeCbor(f.payload) as Record; - p.interim = spawned; - this.#applicationPid = spawned.pid as number; - this.#applicationPgid = spawned.processGroupId as number; - return; - } - clearTimeout(p.timer); - this.#pending.delete(f.correlation); - if (f.kind === FrameKind.ERROR) { - const d = decodeCbor(f.payload) as any; - p.reject(new ProtocolError(`${d.code}: ${d.message}`)); - } else { - const response = f.payload.length ? decodeCbor(f.payload) : {}; - p.resolve( - p.interim ? { ...p.interim, ...(response as object), execPending: false } : response, - ); - } - } - #fail(error: Error) { - if (this.#closed) return; - this.#closed = true; - for (const p of this.#pending.values()) { - clearTimeout(p.timer); - p.reject(error); - } - this.#pending.clear(); - this.#emit("fatal", error); - this.#child.kill("SIGKILL"); - } - request(kind: FrameKind, value?: unknown, raw = false, timeout = this.commandTimeoutMs) { - if (this.#closed) return Promise.reject(new SidecarExitedError("PTY host is closed")); - if (this.#sequence === 0xffffffff) - return Promise.reject(new ProtocolError("Client sequence exhausted")); - const sequence = this.#sequence++, - payload = raw - ? (value as Uint8Array) - : value === undefined - ? new Uint8Array() - : encodeCbor(value); - const data = encodeFrame({ kind, sequence, correlation: 0, payload }); - return new Promise((resolve, reject) => { - const timer = setTimeout(() => { - this.#pending.delete(sequence); - let fallback = "not attempted: no trusted application process group"; - if ( - this.#applicationPgid && - this.#applicationPid && - this.#applicationPgid === this.#applicationPid && - this.#applicationPgid > 1 - ) { - try { - process.kill(-this.#applicationPgid, "SIGKILL"); - fallback = `sent SIGKILL to process group ${this.#applicationPgid}`; - } catch (cause) { - fallback = `process-group fallback failed: ${cause instanceof Error ? cause.message : String(cause)}`; - } - } - const error = new HostCommandTimeoutError( - `PTY host did not acknowledge command ${kind} sequence ${sequence} within ${timeout} ms; ${fallback}`, - ); - reject(error); - this.#fail(error); - }, timeout); - this.#pending.set(sequence, { kind, resolve, reject, timer }); - this.#child.stdin.write(data, (e) => { - if (e) { - clearTimeout(timer); - this.#pending.delete(sequence); - reject(e); - } - }); - }); - } - async spawn(value: unknown) { - const result = await this.request(FrameKind.SPAWN, value); - this.#applicationPid = result.pid; - this.#applicationPgid = result.processGroupId; - return result; - } - write(data: Uint8Array) { - return this.request(FrameKind.WRITE, data, true); - } - resize(value: unknown) { - return this.request(FrameKind.RESIZE, value); - } - signal(value: unknown) { - return this.request(FrameKind.SIGNAL, value); - } - async close(timeout?: number) { - if (this.#closed) return; - try { - await this.request(FrameKind.CLOSE, undefined, false, timeout); - } finally { - this.#closed = true; - this.#child.stdin.end(); - } - } - forceKill() { - this.#closed = true; - this.#child.kill("SIGKILL"); - } + #child: ChildProcessWithoutNullStreams; + #sequence = 1; + #pending = new Map< + number, + { + kind: FrameKind; + resolve: (v: any) => void; + reject: (e: unknown) => void; + timer: ReturnType; + interim?: Record; + } + >(); + #listeners: { [K in Key]: Set } = { + output: new Set(), + exit: new Set(), + eof: new Set(), + fatal: new Set(), + }; + #decoder = new FrameDecoder(); + #stderr = ''; + #closed = false; + #applicationPid?: number; + #applicationPgid?: number; + private constructor( + child: ChildProcessWithoutNullStreams, + readonly commandTimeoutMs: number, + ) { + this.#child = child; + child.stdout.on('data', (b: Buffer) => { + try { + for (const f of this.#decoder.push(b)) this.#frame(f); + } catch (e) { + this.#fail(e as Error); + } + }); + child.stderr.on('data', (b: Buffer) => { + this.#stderr = (this.#stderr + b.toString()).slice(-8192); + }); + child.on('exit', (code, signal) => { + if (!this.#closed) + this.#fail(new SidecarExitedError(`PTY host exited (${code ?? signal}); ${this.#stderr}`)); + }); + child.on('error', (error) => + this.#fail( + 'Deno' in globalThis && /permission|denied|EACCES|requires run access/i.test(error.message) + ? new DenoPermissionError( + `Deno cannot execute Ghostwright's PTY host. Retry with --allow-read=${dirname(child.spawnfile)} --allow-run=${child.spawnfile}`, + { cause: error }, + ) + : new SidecarExitedError(`PTY host failed: ${error.message}`, { cause: error }), + ), + ); + } + static async start(path: string, timeoutMs = 5000) { + const child = spawn(path, [], { stdio: ['pipe', 'pipe', 'pipe'] }); + const c = new SidecarClient(child, timeoutMs); + await c.request(FrameKind.HELLO, { minVersion: 1, maxVersion: 1, clientVersion: '0.1.0' }); + return c; + } + on(key: K, listener: SidecarEvents[K]) { + this.#listeners[key].add(listener); + return () => this.#listeners[key].delete(listener); + } + #emit(key: K, ...args: Parameters) { + for (const f of this.#listeners[key]) + try { + (f as (...a: any[]) => void)(...args); + } catch (e) { + this.#fail(e as Error); + } + } + #frame(f: Frame) { + if (f.kind === FrameKind.OUTPUT) { + this.#emit('output', f.payload, f.sequence); + return; + } + if (f.kind === FrameKind.PROCESS_EXIT) { + this.#emit('exit', decodeCbor(f.payload) as any); + return; + } + if (f.kind === FrameKind.PTY_EOF) { + this.#emit('eof'); + return; + } + if (f.kind === FrameKind.ERROR && f.correlation === 0) { + const d = decodeCbor(f.payload) as any; + this.#fail(new ProtocolError(`${d.code}: ${d.message}`)); + return; + } + const p = this.#pending.get(f.correlation); + if (!p) throw new ProtocolError(`Uncorrelated sidecar response ${f.correlation}`); + if (f.kind === FrameKind.SPAWNED && p.kind === FrameKind.SPAWN) { + const spawned = decodeCbor(f.payload) as Record; + p.interim = spawned; + this.#applicationPid = spawned.pid as number; + this.#applicationPgid = spawned.processGroupId as number; + return; + } + clearTimeout(p.timer); + this.#pending.delete(f.correlation); + if (f.kind === FrameKind.ERROR) { + const d = decodeCbor(f.payload) as any; + p.reject(new ProtocolError(`${d.code}: ${d.message}`)); + } else { + const response = f.payload.length ? decodeCbor(f.payload) : {}; + p.resolve( + p.interim ? { ...p.interim, ...(response as object), execPending: false } : response, + ); + } + } + #fail(error: Error) { + if (this.#closed) return; + this.#closed = true; + for (const p of this.#pending.values()) { + clearTimeout(p.timer); + p.reject(error); + } + this.#pending.clear(); + this.#emit('fatal', error); + this.#child.kill('SIGKILL'); + } + request(kind: FrameKind, value?: unknown, raw = false, timeout = this.commandTimeoutMs) { + if (this.#closed) return Promise.reject(new SidecarExitedError('PTY host is closed')); + if (this.#sequence === 0xffffffff) + return Promise.reject(new ProtocolError('Client sequence exhausted')); + const sequence = this.#sequence++, + payload = raw + ? (value as Uint8Array) + : value === undefined + ? new Uint8Array() + : encodeCbor(value); + const data = encodeFrame({ kind, sequence, correlation: 0, payload }); + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + this.#pending.delete(sequence); + let fallback = 'not attempted: no trusted application process group'; + if ( + this.#applicationPgid && + this.#applicationPid && + this.#applicationPgid === this.#applicationPid && + this.#applicationPgid > 1 + ) { + try { + process.kill(-this.#applicationPgid, 'SIGKILL'); + fallback = `sent SIGKILL to process group ${this.#applicationPgid}`; + } catch (cause) { + fallback = `process-group fallback failed: ${cause instanceof Error ? cause.message : String(cause)}`; + } + } + const error = new HostCommandTimeoutError( + `PTY host did not acknowledge command ${kind} sequence ${sequence} within ${timeout} ms; ${fallback}`, + ); + reject(error); + this.#fail(error); + }, timeout); + this.#pending.set(sequence, { kind, resolve, reject, timer }); + this.#child.stdin.write(data, (e) => { + if (e) { + clearTimeout(timer); + this.#pending.delete(sequence); + reject(e); + } + }); + }); + } + async spawn(value: unknown) { + const result = await this.request(FrameKind.SPAWN, value); + this.#applicationPid = result.pid; + this.#applicationPgid = result.processGroupId; + return result; + } + write(data: Uint8Array) { + return this.request(FrameKind.WRITE, data, true); + } + resize(value: unknown) { + return this.request(FrameKind.RESIZE, value); + } + signal(value: unknown) { + return this.request(FrameKind.SIGNAL, value); + } + async close(timeout?: number) { + if (this.#closed) return; + try { + await this.request(FrameKind.CLOSE, undefined, false, timeout); + } finally { + this.#closed = true; + this.#child.stdin.end(); + } + } + forceKill() { + this.#closed = true; + this.#child.kill('SIGKILL'); + } } diff --git a/experiments/ghostwright/src/pty/protocol.ts b/experiments/ghostwright/src/pty/protocol.ts index 8546b71..4f53944 100644 --- a/experiments/ghostwright/src/pty/protocol.ts +++ b/experiments/ghostwright/src/pty/protocol.ts @@ -1,209 +1,209 @@ -import { ProtocolError } from "../errors.ts"; +import { ProtocolError } from '../errors.ts'; export const PROTOCOL_VERSION = 1; export const HEADER_SIZE = 20; export const MAGIC = new Uint8Array([0x47, 0x57, 0x50, 0x54]); export const enum FrameKind { - HELLO = 0x0001, - SPAWN = 0x0002, - WRITE = 0x0003, - RESIZE = 0x0004, - SIGNAL = 0x0005, - CLOSE = 0x0006, - READY = 0x8001, - SPAWNED = 0x8002, - ACK = 0x8003, - ERROR = 0x80ff, - OUTPUT = 0x8100, - PROCESS_EXIT = 0x8101, - PTY_EOF = 0x8102, + HELLO = 0x0001, + SPAWN = 0x0002, + WRITE = 0x0003, + RESIZE = 0x0004, + SIGNAL = 0x0005, + CLOSE = 0x0006, + READY = 0x8001, + SPAWNED = 0x8002, + ACK = 0x8003, + ERROR = 0x80ff, + OUTPUT = 0x8100, + PROCESS_EXIT = 0x8101, + PTY_EOF = 0x8102, } export interface Frame { - kind: FrameKind; - sequence: number; - correlation: number; - payload: Uint8Array; + kind: FrameKind; + sequence: number; + correlation: number; + payload: Uint8Array; } const concat = (parts: readonly Uint8Array[]) => { - const n = parts.reduce((s, p) => s + p.length, 0), - out = new Uint8Array(n); - let o = 0; - for (const p of parts) { - out.set(p, o); - o += p.length; - } - return out; + const n = parts.reduce((s, p) => s + p.length, 0), + out = new Uint8Array(n); + let o = 0; + for (const p of parts) { + out.set(p, o); + o += p.length; + } + return out; }; function head(major: number, n: number): Uint8Array { - if (!Number.isSafeInteger(n) || n < 0) throw new ProtocolError(`Invalid CBOR length ${n}`); - if (n < 24) return Uint8Array.of((major << 5) | n); - if (n <= 0xff) return Uint8Array.of((major << 5) | 24, n); - if (n <= 0xffff) return Uint8Array.of((major << 5) | 25, n >> 8, n); - const b = new Uint8Array(5); - b[0] = (major << 5) | 26; - new DataView(b.buffer).setUint32(1, n); - return b; + if (!Number.isSafeInteger(n) || n < 0) throw new ProtocolError(`Invalid CBOR length ${n}`); + if (n < 24) return Uint8Array.of((major << 5) | n); + if (n <= 0xff) return Uint8Array.of((major << 5) | 24, n); + if (n <= 0xffff) return Uint8Array.of((major << 5) | 25, n >> 8, n); + const b = new Uint8Array(5); + b[0] = (major << 5) | 26; + new DataView(b.buffer).setUint32(1, n); + return b; } export function encodeCbor(value: unknown): Uint8Array { - if (value === null) return Uint8Array.of(0xf6); - if (value === false) return Uint8Array.of(0xf4); - if (value === true) return Uint8Array.of(0xf5); - if (typeof value === "number") { - if (!Number.isSafeInteger(value)) throw new ProtocolError("CBOR supports only safe integers"); - return value >= 0 ? head(0, value) : head(1, -1 - value); - } - if (typeof value === "string") { - const b = new TextEncoder().encode(value); - return concat([head(3, b.length), b]); - } - if (value instanceof Uint8Array) return concat([head(2, value.length), value]); - if (Array.isArray(value)) return concat([head(4, value.length), ...value.map(encodeCbor)]); - if (typeof value === "object") { - const entries = Object.entries(value as Record) - .filter(([, v]) => v !== undefined) - .map(([k, v]) => [encodeCbor(k), encodeCbor(v)] as const) - .sort((a, b) => a[0].length - b[0].length || compare(a[0], b[0])); - return concat([head(5, entries.length), ...entries.flat()]); - } - throw new ProtocolError(`Unsupported CBOR value: ${typeof value}`); + if (value === null) return Uint8Array.of(0xf6); + if (value === false) return Uint8Array.of(0xf4); + if (value === true) return Uint8Array.of(0xf5); + if (typeof value === 'number') { + if (!Number.isSafeInteger(value)) throw new ProtocolError('CBOR supports only safe integers'); + return value >= 0 ? head(0, value) : head(1, -1 - value); + } + if (typeof value === 'string') { + const b = new TextEncoder().encode(value); + return concat([head(3, b.length), b]); + } + if (value instanceof Uint8Array) return concat([head(2, value.length), value]); + if (Array.isArray(value)) return concat([head(4, value.length), ...value.map(encodeCbor)]); + if (typeof value === 'object') { + const entries = Object.entries(value as Record) + .filter(([, v]) => v !== undefined) + .map(([k, v]) => [encodeCbor(k), encodeCbor(v)] as const) + .sort((a, b) => a[0].length - b[0].length || compare(a[0], b[0])); + return concat([head(5, entries.length), ...entries.flat()]); + } + throw new ProtocolError(`Unsupported CBOR value: ${typeof value}`); } function compare(a: Uint8Array, b: Uint8Array) { - for (let i = 0; i < Math.min(a.length, b.length); i++) if (a[i] !== b[i]) return a[i] - b[i]; - return a.length - b.length; + for (let i = 0; i < Math.min(a.length, b.length); i++) if (a[i] !== b[i]) return a[i] - b[i]; + return a.length - b.length; } export function decodeCbor(bytes: Uint8Array): unknown { - let p = 0; - const readLen = (ai: number) => { - if (ai < 24) return ai; - if (ai === 24) { - if (p + 1 > bytes.length) throw bad(); - const n = bytes[p++]; - if (n < 24) throw new ProtocolError("Non-deterministic CBOR integer/length encoding"); - return n; - } - if (ai === 25) { - if (p + 2 > bytes.length) throw bad(); - const n = (bytes[p] << 8) | bytes[p + 1]; - p += 2; - if (n <= 0xff) throw new ProtocolError("Non-deterministic CBOR integer/length encoding"); - return n; - } - if (ai === 26) { - if (p + 4 > bytes.length) throw bad(); - const n = new DataView(bytes.buffer, bytes.byteOffset + p, 4).getUint32(0); - p += 4; - if (n <= 0xffff) throw new ProtocolError("Non-deterministic CBOR integer/length encoding"); - return n; - } - throw new ProtocolError("Unsupported or indefinite CBOR length"); - }; - const bad = () => new ProtocolError("Truncated CBOR payload"); - const one = (depth = 0): unknown => { - if (depth > 64) throw new ProtocolError("CBOR nesting limit exceeded"); - if (p >= bytes.length) throw bad(); - const h = bytes[p++], - m = h >> 5, - ai = h & 31; - if (m === 7) { - if (ai === 20) return false; - if (ai === 21) return true; - if (ai === 22) return null; - throw new ProtocolError("Unsupported CBOR simple value"); - } - const n = readLen(ai); - if (m === 0) return n; - if (m === 1) return -1 - n; - if (m === 2) { - if (p + n > bytes.length) throw bad(); - return bytes.slice(p, (p += n)); - } - if (m === 3) { - if (p + n > bytes.length) throw bad(); - const s = new TextDecoder("utf-8", { fatal: true }).decode(bytes.subarray(p, p + n)); - p += n; - return s; - } - if (m === 4) { - const a = []; - for (let i = 0; i < n; i++) a.push(one(depth + 1)); - return a; - } - if (m === 5) { - const o: Record = {}; - let previousKey: Uint8Array | undefined; - for (let i = 0; i < n; i++) { - const keyStart = p, - k = one(depth + 1), - keyBytes = bytes.slice(keyStart, p); - if (typeof k !== "string" || Object.hasOwn(o, k)) - throw new ProtocolError("Invalid or duplicate CBOR map key"); - if ( - previousKey && - (previousKey.length > keyBytes.length || - (previousKey.length === keyBytes.length && compare(previousKey, keyBytes) >= 0)) - ) - throw new ProtocolError("Non-deterministic CBOR map key ordering"); - previousKey = keyBytes; - o[k] = one(depth + 1); - } - return o; - } - throw new ProtocolError("Unsupported CBOR major type"); - }; - const result = one(); - if (p !== bytes.length) throw new ProtocolError("Trailing CBOR bytes"); - return result; + let p = 0; + const readLen = (ai: number) => { + if (ai < 24) return ai; + if (ai === 24) { + if (p + 1 > bytes.length) throw bad(); + const n = bytes[p++]; + if (n < 24) throw new ProtocolError('Non-deterministic CBOR integer/length encoding'); + return n; + } + if (ai === 25) { + if (p + 2 > bytes.length) throw bad(); + const n = (bytes[p] << 8) | bytes[p + 1]; + p += 2; + if (n <= 0xff) throw new ProtocolError('Non-deterministic CBOR integer/length encoding'); + return n; + } + if (ai === 26) { + if (p + 4 > bytes.length) throw bad(); + const n = new DataView(bytes.buffer, bytes.byteOffset + p, 4).getUint32(0); + p += 4; + if (n <= 0xffff) throw new ProtocolError('Non-deterministic CBOR integer/length encoding'); + return n; + } + throw new ProtocolError('Unsupported or indefinite CBOR length'); + }; + const bad = () => new ProtocolError('Truncated CBOR payload'); + const one = (depth = 0): unknown => { + if (depth > 64) throw new ProtocolError('CBOR nesting limit exceeded'); + if (p >= bytes.length) throw bad(); + const h = bytes[p++], + m = h >> 5, + ai = h & 31; + if (m === 7) { + if (ai === 20) return false; + if (ai === 21) return true; + if (ai === 22) return null; + throw new ProtocolError('Unsupported CBOR simple value'); + } + const n = readLen(ai); + if (m === 0) return n; + if (m === 1) return -1 - n; + if (m === 2) { + if (p + n > bytes.length) throw bad(); + return bytes.slice(p, (p += n)); + } + if (m === 3) { + if (p + n > bytes.length) throw bad(); + const s = new TextDecoder('utf-8', { fatal: true }).decode(bytes.subarray(p, p + n)); + p += n; + return s; + } + if (m === 4) { + const a = []; + for (let i = 0; i < n; i++) a.push(one(depth + 1)); + return a; + } + if (m === 5) { + const o: Record = {}; + let previousKey: Uint8Array | undefined; + for (let i = 0; i < n; i++) { + const keyStart = p, + k = one(depth + 1), + keyBytes = bytes.slice(keyStart, p); + if (typeof k !== 'string' || Object.hasOwn(o, k)) + throw new ProtocolError('Invalid or duplicate CBOR map key'); + if ( + previousKey && + (previousKey.length > keyBytes.length || + (previousKey.length === keyBytes.length && compare(previousKey, keyBytes) >= 0)) + ) + throw new ProtocolError('Non-deterministic CBOR map key ordering'); + previousKey = keyBytes; + o[k] = one(depth + 1); + } + return o; + } + throw new ProtocolError('Unsupported CBOR major type'); + }; + const result = one(); + if (p !== bytes.length) throw new ProtocolError('Trailing CBOR bytes'); + return result; } export function encodeFrame(frame: Frame): Uint8Array { - const raw = frame.kind === FrameKind.WRITE || frame.kind === FrameKind.OUTPUT; - const max = raw ? 65536 : 1024 * 1024; - if (frame.payload.length > max) throw new ProtocolError(`Frame payload exceeds ${max} bytes`); - if (frame.sequence <= 0 || frame.sequence > 0xffffffff) - throw new ProtocolError("Frame sequence must be a nonzero uint32"); - const out = new Uint8Array(HEADER_SIZE + frame.payload.length); - out.set(MAGIC); - const d = new DataView(out.buffer); - d.setUint16(4, PROTOCOL_VERSION, true); - d.setUint16(6, frame.kind, true); - d.setUint32(8, frame.sequence, true); - d.setUint32(12, frame.correlation, true); - d.setUint32(16, frame.payload.length, true); - out.set(frame.payload, HEADER_SIZE); - return out; + const raw = frame.kind === FrameKind.WRITE || frame.kind === FrameKind.OUTPUT; + const max = raw ? 65536 : 1024 * 1024; + if (frame.payload.length > max) throw new ProtocolError(`Frame payload exceeds ${max} bytes`); + if (frame.sequence <= 0 || frame.sequence > 0xffffffff) + throw new ProtocolError('Frame sequence must be a nonzero uint32'); + const out = new Uint8Array(HEADER_SIZE + frame.payload.length); + out.set(MAGIC); + const d = new DataView(out.buffer); + d.setUint16(4, PROTOCOL_VERSION, true); + d.setUint16(6, frame.kind, true); + d.setUint32(8, frame.sequence, true); + d.setUint32(12, frame.correlation, true); + d.setUint32(16, frame.payload.length, true); + out.set(frame.payload, HEADER_SIZE); + return out; } export class FrameDecoder { - #buffer = new Uint8Array(); - #last = 0; - push(chunk: Uint8Array): Frame[] { - this.#buffer = concat([this.#buffer, chunk]); - const frames: Frame[] = []; - while (this.#buffer.length >= HEADER_SIZE) { - if (!MAGIC.every((v, i) => this.#buffer[i] === v)) - throw new ProtocolError("Invalid GWPT frame magic"); - const d = new DataView(this.#buffer.buffer, this.#buffer.byteOffset); - const version = d.getUint16(4, true), - kind = d.getUint16(6, true) as FrameKind, - sequence = d.getUint32(8, true), - correlation = d.getUint32(12, true), - length = d.getUint32(16, true); - if (version !== PROTOCOL_VERSION) - throw new ProtocolError(`Unsupported protocol version ${version}`); - const raw = kind === FrameKind.WRITE || kind === FrameKind.OUTPUT, - max = raw ? 65536 : 1024 * 1024; - if (length > max) throw new ProtocolError(`Frame payload exceeds ${max} bytes`); - if (this.#buffer.length < HEADER_SIZE + length) break; - if (sequence <= this.#last) - throw new ProtocolError(`Duplicate or regressing frame sequence ${sequence}`); - this.#last = sequence; - frames.push({ - kind, - sequence, - correlation, - payload: this.#buffer.slice(HEADER_SIZE, HEADER_SIZE + length), - }); - this.#buffer = this.#buffer.slice(HEADER_SIZE + length); - } - return frames; - } + #buffer = new Uint8Array(); + #last = 0; + push(chunk: Uint8Array): Frame[] { + this.#buffer = concat([this.#buffer, chunk]); + const frames: Frame[] = []; + while (this.#buffer.length >= HEADER_SIZE) { + if (!MAGIC.every((v, i) => this.#buffer[i] === v)) + throw new ProtocolError('Invalid GWPT frame magic'); + const d = new DataView(this.#buffer.buffer, this.#buffer.byteOffset); + const version = d.getUint16(4, true), + kind = d.getUint16(6, true) as FrameKind, + sequence = d.getUint32(8, true), + correlation = d.getUint32(12, true), + length = d.getUint32(16, true); + if (version !== PROTOCOL_VERSION) + throw new ProtocolError(`Unsupported protocol version ${version}`); + const raw = kind === FrameKind.WRITE || kind === FrameKind.OUTPUT, + max = raw ? 65536 : 1024 * 1024; + if (length > max) throw new ProtocolError(`Frame payload exceeds ${max} bytes`); + if (this.#buffer.length < HEADER_SIZE + length) break; + if (sequence <= this.#last) + throw new ProtocolError(`Duplicate or regressing frame sequence ${sequence}`); + this.#last = sequence; + frames.push({ + kind, + sequence, + correlation, + payload: this.#buffer.slice(HEADER_SIZE, HEADER_SIZE + length), + }); + this.#buffer = this.#buffer.slice(HEADER_SIZE + length); + } + return frames; + } } diff --git a/experiments/ghostwright/src/terminal/session.ts b/experiments/ghostwright/src/terminal/session.ts index 024dd1f..72a9e4b 100644 --- a/experiments/ghostwright/src/terminal/session.ts +++ b/experiments/ghostwright/src/terminal/session.ts @@ -1,964 +1,964 @@ -import { resolve } from "node:path"; +import { resolve } from 'node:path'; import { - CoordinateRangeError, - DenoPermissionError, - HistoryChangedError, - HistoryEvictedError, - LaunchError, - ProcessExitedError, - ReservedEnvironmentError, - SessionClosedError, - StrictLocatorError, - TerminalAssertionError, -} from "../errors.ts"; + CoordinateRangeError, + DenoPermissionError, + HistoryChangedError, + HistoryEvictedError, + LaunchError, + ProcessExitedError, + ReservedEnvironmentError, + SessionClosedError, + StrictLocatorError, + TerminalAssertionError, +} from '../errors.ts'; import { - assertSupportedRuntime, - normalizeViewport, - profileEnvironment, - resolveAssets, -} from "../profile.ts"; -import { FrameKind } from "../pty/protocol.ts"; -import { SidecarClient } from "../pty/client.ts"; -import { SessionTrace } from "../tracing/trace.ts"; + assertSupportedRuntime, + normalizeViewport, + profileEnvironment, + resolveAssets, +} from '../profile.ts'; +import { FrameKind } from '../pty/protocol.ts'; +import { SidecarClient } from '../pty/client.ts'; +import { SessionTrace } from '../tracing/trace.ts'; import type { - ActionReceipt, - AsyncLocator, - AsyncRegion, - AsyncTerminal, - CellChange, - KeyName, - HistoryLine, - HistoryMatch, - HistoryQuery, - HistoryRange, - HistorySearchOptions, - KeyPress, - KittyImageSnapshot, - LocatorMatch, - MouseOptions, - Point, - ProcessStatus, - Rect, - RevisionCollection, - RevisionCollectionOptions, - RevisionRangeQuery, - ScreenCell, - ScreenReader, - ScreenRevision, - ScreenSnapshot, - TerminalLaunchOptions, - TextLocatorOptions, - TraceableInputOptions, - Viewport, - WheelOptions, -} from "../types.ts"; -import { GhosttyWasmTerminal } from "./wasm.ts"; + ActionReceipt, + AsyncLocator, + AsyncRegion, + AsyncTerminal, + CellChange, + KeyName, + HistoryLine, + HistoryMatch, + HistoryQuery, + HistoryRange, + HistorySearchOptions, + KeyPress, + KittyImageSnapshot, + LocatorMatch, + MouseOptions, + Point, + ProcessStatus, + Rect, + RevisionCollection, + RevisionCollectionOptions, + RevisionRangeQuery, + ScreenCell, + ScreenReader, + ScreenRevision, + ScreenSnapshot, + TerminalLaunchOptions, + TextLocatorOptions, + TraceableInputOptions, + Viewport, + WheelOptions, +} from '../types.ts'; +import { GhosttyWasmTerminal } from './wasm.ts'; function concatBytes(parts: readonly Uint8Array[]) { - const result = new Uint8Array(parts.reduce((total, part) => total + part.length, 0)); - let offset = 0; - for (const part of parts) { - result.set(part, offset); - offset += part.length; - } - return result; + const result = new Uint8Array(parts.reduce((total, part) => total + part.length, 0)); + let offset = 0; + for (const part of parts) { + result.set(part, offset); + offset += part.length; + } + return result; } function visualKey(s: ScreenSnapshot) { - return JSON.stringify([ - s.lines.map((l) => l.cells.map((c) => [c.text, c.style])), - s.cursor, - s.activeBuffer, - s.viewport, - // Only renderer-visible graphics participate in visual settlement. - s.graphics.placements.filter((placement) => placement.viewport.visible), - ]); + return JSON.stringify([ + s.lines.map((l) => l.cells.map((c) => [c.text, c.style])), + s.cursor, + s.activeBuffer, + s.viewport, + // Only renderer-visible graphics participate in visual settlement. + s.graphics.placements.filter((placement) => placement.viewport.visible), + ]); } function observableKey(s: ScreenSnapshot) { - return JSON.stringify([visualKey(s), s.graphics, s.modes, s.title, s.workingDirectory]); + return JSON.stringify([visualKey(s), s.graphics, s.modes, s.title, s.workingDirectory]); } export class TerminalSession implements AsyncTerminal { - #host!: SidecarClient; - #engine!: GhosttyWasmTerminal; - #snapshot!: ScreenSnapshot; - #status: ProcessStatus = { state: "starting", ptyEof: false }; - #closed = false; - #action = 0; - #revision = 0; - #history: ScreenRevision[] = []; - #historyDecodedBytes = 0; - #historySizes: number[] = []; - // This is a session-owned generation because the Ghostty ABI does not expose one. - #terminalHistoryGeneration = 0; - #raw: Uint8Array[] = []; - #listeners = new Set<() => void>(); - #outputPump: Promise = Promise.resolve(); - #commands: Promise = Promise.resolve(); - #lastAction?: ActionReceipt; - #mouseDown = false; - #trace: SessionTrace; - #viewport; - #fatalError?: Error; - #exitResolve!: (s: ProcessStatus) => void; - #exitPromise: Promise; - private constructor(readonly options: TerminalLaunchOptions) { - this.#viewport = normalizeViewport(options.viewport); - const t = - typeof options.trace === "string" - ? options.trace - : (options.trace?.policy ?? "retain-on-failure"), - dir = - typeof options.trace === "object" - ? (options.trace.directory ?? ".ghostwright") - : ".ghostwright"; - this.#trace = new SessionTrace(options, t, dir); - this.#exitPromise = new Promise((r) => (this.#exitResolve = r)); - } - static async launch(options: TerminalLaunchOptions) { - assertSupportedRuntime(); - if (!options.command || options.command.includes("\0")) - throw new TypeError("command must be a nonempty NUL-free string"); - for (const [label, value] of [ - ...(options.args ?? []).map((value, index) => [`argument ${index}`, value] as const), - ...Object.entries(options.env ?? {}).flatMap(([key, value]) => [ - [`environment key ${key}`, key] as const, - [`environment value ${key}`, value] as const, - ]), - ...(options.cwd ? [["cwd", options.cwd] as const] : []), - ]) - if (value.includes("\0")) throw new TypeError(`${label} must not contain NUL`); - for (const [label, value] of [ - ["commandTimeoutMs", options.commandTimeoutMs], - ["assertionTimeoutMs", options.assertionTimeoutMs], - ["settleMs", options.settleMs], - ["hangupGraceMs", options.cleanup?.hangupGraceMs], - ["terminateGraceMs", options.cleanup?.terminateGraceMs], - ["postExitDrainMs", options.cleanup?.postExitDrainMs], - ["maxRevisions", options.history?.maxRevisions], - ["maxRawBytes", options.history?.maxRawBytes], - ["maxDecodedBytes", options.history?.maxDecodedBytes], - ["graphics.storageLimitBytes", options.graphics?.storageLimitBytes], - ] as const) - if (value !== undefined && (!Number.isSafeInteger(value) || value < 0)) - throw new RangeError(`${label} must be a nonnegative safe integer`); - const self = new TerminalSession(options), - assets = await resolveAssets(options), - cwd = resolve(options.cwd ?? process.cwd()); - let env: Record; - try { - env = profileEnvironment(options.env, assets.terminfo); - } catch (cause) { - if ("Deno" in globalThis && !(cause instanceof ReservedEnvironmentError)) - throw new DenoPermissionError( - `Deno cannot inherit the launch environment. Add --allow-env together with --allow-read=${assets.root} --allow-run=${assets.host}`, - { cause }, - ); - throw cause; - } - self.#engine = await GhosttyWasmTerminal.create( - self.#viewport, - options.graphics?.storageLimitBytes ?? 64 * 1024 * 1024, - ); - self.#snapshot = self.#engine.snapshot(); - self.#trace.add("kitty-capability", { - supported: self.#snapshot.graphics.supported, - storageLimitBytes: self.#snapshot.graphics.storageLimitBytes, - profile: self.#snapshot.graphics.supported ? "direct-raw-only" : "disabled", - }); - self.#host = await SidecarClient.start(assets.host, options.commandTimeoutMs ?? 5000); - self.#host.on("output", (b, seq) => { - self.#outputPump = self.#outputPump - .then(() => self.#output(b, seq)) - .catch((error) => { - self.#status = { ...self.#status, state: "failed" }; - self.#trace.add("output-pump-error", { - message: error instanceof Error ? error.message : String(error), - }); - self.#notify(); - self.#exitResolve(self.#status); - }); - }); - self.#host.on("exit", (x) => { - self.#status = { ...self.#status, state: "exited", ...x }; - self.#trace.add("process-exit", x); - self.#notify(); - if (self.#status.ptyEof) self.#exitResolve(self.#status); - }); - self.#host.on("eof", () => { - void self.#outputPump.then(() => { - self.#status = { ...self.#status, ptyEof: true }; - self.#trace.add("pty-eof"); - self.#notify(); - if (self.#status.state === "exited") self.#exitResolve(self.#status); - }); - }); - self.#host.on("fatal", (e) => { - self.#fatalError = e; - self.#status = { ...self.#status, state: "failed" }; - self.#trace.add("sidecar-error", { message: e.message }); - self.#notify(); - self.#exitResolve(self.#status); - }); - let spawned: any; - try { - spawned = await self.#host.spawn({ - command: options.command, - args: options.args ?? [], - cwd, - env, - viewport: self.#viewport, - cleanup: { - hangupGraceMs: options.cleanup?.hangupGraceMs ?? 500, - terminateGraceMs: options.cleanup?.terminateGraceMs ?? 500, - postExitDrainMs: options.cleanup?.postExitDrainMs ?? 1_000, - }, - }); - } catch (cause) { - self.#host.forceKill(); - self.#engine.free(); - throw new LaunchError( - `Unable to launch ${JSON.stringify(options.command)} in ${JSON.stringify(cwd)}`, - { cause }, - ); - } - if (self.#fatalError) { - self.#engine.free(); - throw new LaunchError( - `Unable to launch ${JSON.stringify(options.command)} in ${JSON.stringify(cwd)}: ${self.#fatalError.message}`, - { cause: self.#fatalError }, - ); - } - self.#status = { - state: "running", - pid: spawned.pid, - processGroupId: spawned.processGroupId, - ptyEof: false, - }; - self.#trace.add("spawned", { pid: spawned.pid, processGroupId: spawned.processGroupId }); - return self; - } - get trace() { - return this.#trace; - } - get revisionHistory() { - return this.#history; - } - get lastAction() { - return this.#lastAction; - } - now() { - return this.#engine.now(); - } - #notify() { - for (const f of [...this.#listeners]) f(); - } - subscribe(f: () => void) { - this.#listeners.add(f); - return () => this.#listeners.delete(f); - } - async #output(bytes: Uint8Array, sourceFrameSequence: number) { - if (this.#closed) return; - this.#trace.output(bytes, sourceFrameSequence); - this.#raw.push(bytes.slice()); - const max = this.options.history?.maxRawBytes ?? 4 * 1024 * 1024; - while (this.#raw.reduce((n, b) => n + b.length, 0) > max) this.#raw.shift(); - this.#engine.write(bytes); - // Any output can append, prune, reflow, reset, or switch Ghostty's active page list. - // Incrementing conservatively prevents a caller from mixing pagination layouts. - this.#terminalHistoryGeneration++; - this.#publish("pty-output", sourceFrameSequence); - for (const effect of this.#engine.takeEffects()) { - this.#trace.add("terminal-effect", { - effect: effect.type, - ...(effect.type === "write-pty" ? { bytes: effect.data.length } : {}), - }); - if (effect.type === "write-pty") { - this.#trace.input(effect.data, 0, false); - await this.#command(() => this.#host.write(effect.data)); - } - } - } - #command(operation: () => Promise): Promise { - const result = this.#commands.then(operation); - this.#commands = result; - return result; - } - #publish(cause: "pty-output" | "resize" | "reset", sourceFrameSequence?: number) { - const decoded = this.#engine.snapshot(cause), - lines = decoded.lines.map((line, index) => - JSON.stringify(line) === JSON.stringify(this.#snapshot.lines[index]) - ? this.#snapshot.lines[index] - : line, - ), - next = Object.freeze({ ...decoded, lines: Object.freeze(lines) }), - changed = observableKey(next) !== observableKey(this.#snapshot); - if (!changed) return; - const visual = visualKey(next) !== visualKey(this.#snapshot), - changedRows: number[] = []; - for (let i = 0; i < next.lines.length; i++) - if (next.lines[i] !== this.#snapshot.lines[i]) changedRows.push(i); - this.#revision++; - this.#snapshot = Object.freeze({ - ...next, - sequence: this.#revision, - lastVisualChangeAt: visual ? next.timestamp : this.#snapshot.lastVisualChangeAt, - }); - const rev = Object.freeze({ - sequence: this.#revision, - timestamp: next.timestamp, - cause, - sourceFrameSequence, - changedRows: Object.freeze(changedRows), - visualChange: visual, - snapshot: this.#snapshot, - }); - this.#history.push(rev); - const decodedSize = changedRows.reduce( - (total, row) => total + JSON.stringify(this.#snapshot.lines[row]).length * 2, - 512, - ); - this.#historySizes.push(decodedSize); - this.#historyDecodedBytes += decodedSize; - const max = this.options.history?.maxRevisions ?? 1000, - maxDecoded = this.options.history?.maxDecodedBytes ?? 64 * 1024 * 1024; - while (this.#history.length > max || this.#historyDecodedBytes > maxDecoded) { - this.#history.shift(); - this.#historyDecodedBytes -= this.#historySizes.shift() ?? 0; - } - this.#trace.add("revision", { - revision: this.#revision, - cause, - changedRows, - visualChange: visual, - graphics: { - generation: this.#snapshot.graphics.generation, - placements: this.#snapshot.graphics.placements.map((placement) => ({ - imageId: placement.imageId, - placementId: placement.placementId, - imageGeneration: placement.imageGeneration, - sha256: placement.image?.sha256, - visible: placement.viewport.visible, - z: placement.z, - })), - }, - }); - this.#notify(); - } - #ensure(op: string) { - if (this.#closed) throw new SessionClosedError(`Cannot ${op}: terminal session is closed`); - } - async #send(kind: FrameKind, value: unknown, raw = false, delivered = true) { - this.#ensure("perform action"); - const before = this.#revision, - sequence = ++this.#action; - let ack: any; - if (kind === FrameKind.WRITE) - ack = await this.#command(() => this.#host.write(value as Uint8Array)); - else if (kind === FrameKind.RESIZE) ack = await this.#command(() => this.#host.resize(value)); - else if (kind === FrameKind.SIGNAL) ack = await this.#command(() => this.#host.signal(value)); - else throw new Error("Unsupported action"); - const receipt: Object = Object.freeze({ - actionSequence: sequence, - screenSequenceBefore: before, - acknowledgedAt: this.#engine.now(), - deliveredToChild: delivered, - bytesWritten: ack.bytesWritten ?? 0, - }); - this.#lastAction = receipt as ActionReceipt; - this.#trace.add("action", { - actionSequence: sequence, - kind, - bytesWritten: ack.bytesWritten ?? 0, - ...(kind === FrameKind.RESIZE ? { viewport: value } : {}), - }); - return receipt as ActionReceipt; - } - async #write( - data: Uint8Array, - delivered = data.length > 0, - traceMode: "record" | "redact" = "record", - ) { - this.#ensure("write input"); - const before = this.#revision, - sequence = ++this.#action; - let total = 0; - this.#trace.input(data, sequence, traceMode === "redact"); - for (let offset = 0; offset < data.length; offset += 65_536) { - const chunk = data.slice(offset, offset + 65_536); - const ack = await this.#command(() => this.#host.write(chunk)); - total += ack.bytesWritten ?? 0; - } - const receipt = Object.freeze({ - actionSequence: sequence, - screenSequenceBefore: before, - acknowledgedAt: this.#engine.now(), - deliveredToChild: delivered, - bytesWritten: total, - }); - this.#lastAction = receipt; - this.#trace.add("action", { - actionSequence: sequence, - kind: FrameKind.WRITE, - bytesWritten: total, - }); - return receipt; - } - keyboard = { - press: async (key: KeyName | KeyPress) => this.#write(this.#engine.encodeKey(key)), - type: async (text: string, options?: TraceableInputOptions) => - this.#write( - concatBytes(Array.from(text, (key) => this.#engine.encodeKey(key))), - true, - options?.trace ?? "record", - ), - paste: async (text: string, options?: TraceableInputOptions) => - this.#write(this.#engine.encodePaste(text), true, options?.trace ?? "record"), - focus: async (state: "in" | "out") => { - const b = this.#engine.encodeFocus(state); - return this.#write(b); - }, - write: async (data: Uint8Array) => this.#write(data), - }; - #point(p: Point) { - if ( - !Number.isInteger(p.column) || - !Number.isInteger(p.row) || - p.column < 0 || - p.row < 0 || - p.column >= this.#viewport.columns || - p.row >= this.#viewport.rows - ) - throw new CoordinateRangeError( - `Coordinate (${p.column},${p.row}) is outside ${this.#viewport.columns}x${this.#viewport.rows}`, - ); - } - #mouse(action: "move" | "down" | "up", p: Point, o: MouseOptions = {}) { - this.#point(p); - const wasDown = this.#mouseDown; - if (action === "down") this.#mouseDown = true; - if (action === "up") this.#mouseDown = false; - const bytes = this.#engine.encodeMouse( - action, - p, - o, - action === "up" ? wasDown : this.#mouseDown, - ); - return this.#write(bytes, bytes.length > 0); - } - mouse = { - move: (p: Point, o?: MouseOptions) => this.#mouse("move", p, o), - down: (p: Point, o?: MouseOptions) => this.#mouse("down", p, o), - up: (p: Point, o?: MouseOptions) => this.#mouse("up", p, o), - click: async (p: Point, o?: MouseOptions) => { - await this.#mouse("down", p, o); - return this.#mouse("up", p, o); - }, - doubleClick: async (p: Point, o?: MouseOptions) => { - await this.mouse.click(p, o); - return this.mouse.click(p, o); - }, - drag: async (a: Point, b: Point, o?: MouseOptions) => { - await this.#mouse("down", a, o); - await this.#mouse("move", b, o); - return this.#mouse("up", b, o); - }, - wheel: (o: WheelOptions) => { - this.#point(o); - if (!Number.isInteger(o.deltaRows) || !Number.isInteger(o.deltaColumns ?? 0)) - throw new RangeError("Wheel deltas must be integers"); - const parts: Uint8Array[] = []; - for (let index = 0; index < Math.abs(o.deltaRows); index++) - parts.push(this.#engine.encodeMouse("down", o, { button: o.deltaRows < 0 ? 4 : 5 }, false)); - for (let index = 0; index < Math.abs(o.deltaColumns ?? 0); index++) - parts.push( - this.#engine.encodeMouse("down", o, { button: (o.deltaColumns ?? 0) < 0 ? 6 : 7 }, false), - ); - const bytes = concatBytes(parts); - return this.#write(bytes, bytes.length > 0); - }, - }; - process = { - status: () => ({ ...this.#status }), - signal: (signal: string, target: "child" | "process-group" = "process-group") => - this.#send(FrameKind.SIGNAL, { signal, target }), - waitForExit: async (options?: { timeoutMs?: number }) => - this.#timeout( - this.#exitPromise, - options?.timeoutMs ?? this.options.assertionTimeoutMs ?? 5000, - () => new ProcessExitedError("Timed out waiting for process exit"), - ), - }; - revisions = { - collect: (options: RevisionCollectionOptions) => this.collectRevisions(options), - }; - history = { - read: (query?: HistoryQuery) => this.readHistory(query), - findText: (text: string, options?: HistorySearchOptions) => this.findHistoryText(text, options), - }; - graphics = { - inspectImage: async (id: number): Promise => { - await this.#outputPump; - return this.#engine.inspectImage(id); - }, - copyImageData: async (id: number): Promise => { - await this.#outputPump; - return this.#engine.copyImageData(id); - }, - }; - getByText(text: string, options?: TextLocatorOptions) { - return new Locator(this, text, options); - } - region(rect: Rect): AsyncRegion { - this.#rect(rect); - return { - getByText: (text, options) => new Locator(this, text, options, undefined, rect), - snapshot: () => this.#snapshot, - }; - } - validateRegion(r: Rect) { - this.#rect(r); - } - #rect(r: Rect) { - if (!Number.isInteger(r.width) || !Number.isInteger(r.height) || r.width <= 0 || r.height <= 0) - this.#point({ column: -1, row: -1 }); - this.#point(r); - this.#point({ column: r.column + r.width - 1, row: r.row + r.height - 1 }); - } - async resize(v: Viewport) { - const viewport = normalizeViewport(v); - const receipt = await this.#send(FrameKind.RESIZE, viewport); - this.#viewport = viewport; - this.#engine.resize(viewport); - this.#terminalHistoryGeneration++; - this.#publish("resize"); - return receipt; - } - async close() { - if (this.#closed) - return Object.freeze({ - actionSequence: ++this.#action, - screenSequenceBefore: this.#revision, - acknowledgedAt: this.#engine.now(), - deliveredToChild: false, - bytesWritten: 0, - }); - const before = this.#revision, - sequence = ++this.#action; - try { - const c = this.options.cleanup ?? {}, - timeout = Math.max( - this.options.commandTimeoutMs ?? 5000, - (c.hangupGraceMs ?? 500) + - (c.terminateGraceMs ?? 500) + - (c.postExitDrainMs ?? 1000) + - 1000, - ); - await this.#outputPump; - await this.#commands; - await this.#host.close(timeout); - const receipt = Object.freeze({ - actionSequence: sequence, - screenSequenceBefore: before, - acknowledgedAt: this.#engine.now(), - deliveredToChild: true, - bytesWritten: 0, - }); - this.#lastAction = receipt; - this.#trace.add("action", { actionSequence: sequence, kind: FrameKind.CLOSE }); - return receipt; - } finally { - this.#closed = true; - this.#status = { ...this.#status, state: "closed" }; - this.#engine.free(); - this.#trace.add("cleanup"); - this.#notify(); - } - } - async waitForChange(test: () => boolean, timeout: number) { - if (test()) return; - await new Promise((resolve, reject) => { - const off = this.subscribe(() => { - if (test()) { - clearTimeout(timer); - off(); - resolve(); - } else if (this.#status.state === "closed" || this.#status.state === "failed") { - clearTimeout(timer); - off(); - reject(new SessionClosedError("Session closed before condition matched")); - } else if (this.#status.ptyEof) { - clearTimeout(timer); - off(); - reject(new ProcessExitedError("Process exited before condition matched")); - } - }), - timer = setTimeout(() => { - off(); - reject(new Error("timeout")); - }, timeout); - }); - } - async #timeout(p: Promise, ms: number, error: () => Error) { - return new Promise((r, j) => { - const t = setTimeout(() => j(error()), ms); - p.then( - (x) => { - clearTimeout(t); - r(x); - }, - (e) => { - clearTimeout(t); - j(e); - }, - ); - }); - } - #baselineSequence(value: ActionReceipt | ScreenRevision | number) { - if (typeof value === "number") { - if (!Number.isSafeInteger(value) || value < 0) - throw new RangeError("revision sequence must be a nonnegative safe integer"); - return value; - } - return "screenSequenceBefore" in value ? value.screenSequenceBefore : value.sequence; - } - revisionsSince(sequence: number) { - const earliest = this.#history[0]?.sequence ?? this.#revision, - latest = this.#history.at(-1)?.sequence ?? this.#revision; - if (sequence < earliest - 1) - throw new HistoryEvictedError( - `Revision baseline ${sequence} was evicted; retained range is ${earliest} through ${latest}`, - ); - return this.#history.filter((revision) => revision.sequence > sequence); - } - revisionRange(query: RevisionRangeQuery) { - const since = this.#baselineSequence(query.since), - until = query.until === undefined ? undefined : this.#baselineSequence(query.until), - max = this.options.history?.maxRevisions ?? 1000, - limit = query.limit ?? max; - if (!Number.isSafeInteger(limit) || limit <= 0) - throw new RangeError("revision limit must be positive"); - if (limit > max) - throw new RangeError( - `revision limit ${limit} exceeds the configured retained maximum ${max}`, - ); - if (until !== undefined && until < since) - throw new RangeError("revision until sequence must not precede its exclusive baseline"); - const revisions = this.revisionsSince(since); - if (until !== undefined) { - const latest = this.#history.at(-1)?.sequence ?? this.#revision; - if (until < (this.#history[0]?.sequence ?? this.#revision) || until > latest) - throw new HistoryEvictedError( - `Revision endpoint ${until} cannot be proven from retained range ${this.#history[0]?.sequence ?? this.#revision} through ${latest}`, - ); - } - return Object.freeze( - revisions - .filter((revision) => until === undefined || revision.sequence <= until) - .slice(0, limit), - ); - } - async collectRevisions(options: RevisionCollectionOptions): Promise { - const baseline = this.#baselineSequence(options.since), - max = options.maxRevisions ?? 1000, - configuredMax = this.options.history?.maxRevisions ?? 1000, - timeout = options.timeoutMs ?? this.options.assertionTimeoutMs ?? 5000, - startedAt = this.now(); - if (!Number.isSafeInteger(max) || max <= 0 || max > configuredMax) - throw new RangeError(`maxRevisions must be positive and no greater than ${configuredMax}`); - if (!Number.isSafeInteger(timeout) || timeout < 0) - throw new RangeError("timeoutMs must be nonnegative"); - const samples = [...this.revisionsSince(baseline)].slice(0, max); - const complete = () => samples.some((revision) => options.until(revision.snapshot, revision)); - if (!complete() && samples.length === max) - throw new HistoryEvictedError( - `Revision collection reached its ${max} sample limit before its predicate matched`, - ); - if (!complete()) - await new Promise((resolve, reject) => { - let timer: ReturnType | undefined; - const finish = (error?: Error) => { - if (timer) clearTimeout(timer); - off(); - error ? reject(error) : resolve(); - }; - const off = this.subscribe(() => { - try { - const latest = this.revisionsSince(baseline); - for (const revision of latest) - if (!samples.some((sample) => sample.sequence === revision.sequence)) - samples.push(revision); - if (samples.length > max) - return finish( - new HistoryEvictedError( - `Revision collection exceeded its ${max} sample limit before its predicate matched`, - ), - ); - if (complete()) return finish(); - if (this.#status.state === "closed" || this.#status.state === "failed") - return finish(new SessionClosedError("Session closed during revision collection")); - if (this.#status.ptyEof) - return finish(new ProcessExitedError("Process exited during revision collection")); - } catch (error) { - finish(error instanceof Error ? error : new Error(String(error))); - } - }); - timer = setTimeout( - () => - finish( - new TerminalAssertionError(`Timed out collecting revisions after ${timeout} ms`), - ), - timeout, - ); - }); - return Object.freeze({ - baselineSequence: baseline, - startedAt, - completedAt: this.now(), - revisions: Object.freeze(samples), - }); - } - async readHistory(query: HistoryQuery = {}): Promise { - const direction = query.direction ?? "oldest-first", - count = query.count ?? 200, - start = query.start ?? 0; - if (!Number.isSafeInteger(start) || start < 0) - throw new RangeError("history start must be nonnegative"); - if (!Number.isSafeInteger(count) || count <= 0 || count > 1000) - throw new RangeError("history count must be positive and no greater than 1000"); - await this.#outputPump; - const generation = String(this.#terminalHistoryGeneration); - if (query.expectedGeneration !== undefined && query.expectedGeneration !== generation) - throw new HistoryChangedError( - `Terminal history changed (expected generation ${query.expectedGeneration}, current ${generation})`, - ); - const predecessor = start > 0 ? this.#engine.history(start - 1, 1)[0] : undefined; - const rows = this.#engine.history(start, count); - const lines = rows.map((entry, offset) => { - const line = entry.line; - return Object.freeze({ - index: start + offset, - text: line.text, - wrapped: line.wrapped, - wrapContinuation: offset > 0 ? rows[offset - 1].line.wrapped : !!predecessor?.line.wrapped, - kittyVirtualPlaceholder: entry.kittyVirtualPlaceholder, - cells: line.cells, - } satisfies HistoryLine); - }); - if (direction === "newest-first") lines.reverse(); - return Object.freeze({ - generation, - totalRows: this.#engine.scrollbackRows(), - start, - direction, - lines: Object.freeze(lines), - }); - } - async findHistoryText( - text: string, - options: HistorySearchOptions = {}, - ): Promise { - if (!text.length) throw new RangeError("history search text must be nonempty"); - const maxRows = options.maxRows ?? 1000, - limit = options.limit ?? 20; - if (!Number.isSafeInteger(maxRows) || maxRows <= 0 || maxRows > 10_000) - throw new RangeError("history maxRows must be positive and no greater than 10000"); - if (!Number.isSafeInteger(limit) || limit <= 0 || limit > 1000) - throw new RangeError("history result limit must be positive and no greater than 1000"); - const start = options.start ?? 0; - if (!Number.isSafeInteger(start) || start < 0) - throw new RangeError("history start must be nonnegative"); - const lines: HistoryLine[] = []; - let generation = options.expectedGeneration; - for (let offset = start; offset < start + maxRows; offset += 1000) { - const page = await this.readHistory({ - direction: "oldest-first", - start: offset, - count: Math.min(1000, start + maxRows - offset), - expectedGeneration: generation, - }); - generation ??= page.generation; - lines.push(...page.lines); - if (page.lines.length < 1000) break; - } - if (options.direction === "newest-first") lines.reverse(); - const results: HistoryMatch[] = []; - for (const line of lines) { - let textOffset = 0; - const segments = line.cells - .filter((cell) => !cell.continuation) - .map((cell) => { - const start = textOffset; - textOffset += (cell.style.invisible ? " " : cell.text || " ").length; - return { cell, start, end: textOffset }; - }); - let matchAt = 0; - while (results.length < limit && (matchAt = line.text.indexOf(text, matchAt)) >= 0) { - const first = segments.find((segment) => matchAt < segment.end) ?? segments.at(-1); - const last = - [...segments].reverse().find((segment) => matchAt + text.length > segment.start) ?? first; - const column = first?.cell.column ?? 0, - endColumn = last ? last.cell.column + Math.max(1, last.cell.width) : column + 1; - results.push( - Object.freeze({ - lineIndex: line.index, - text, - range: { - column, - row: line.index, - width: Math.max(1, endColumn - column), - height: 1, - }, - line, - }), - ); - matchAt += Math.max(1, text.length); - } - if (results.length === limit) break; - } - return Object.freeze(results); - } - screen: ScreenReader = { - current: () => this.#snapshot, - getCell: (p: Point) => { - this.#point(p); - return this.#snapshot.lines[p.row].cells[p.column]; - }, - getText: (r?: Rect) => { - if (!r) return this.#snapshot.lines.map((l) => l.text).join("\n"); - this.#rect(r); - return this.#snapshot.lines - .slice(r.row, r.row + r.height) - .map((l) => - l.cells - .slice(r.column, r.column + r.width) - .map((c) => (c.continuation ? "" : c.style.invisible ? " " : c.text || " ")) - .join(""), - ) - .join("\n"); - }, - changedCells: (since: ScreenSnapshot | number) => { - const seq = typeof since === "number" ? since : since.sequence, - old = - typeof since === "number" - ? this.#history.find((r) => r.sequence === seq)?.snapshot - : since; - if (!old) throw new HistoryEvictedError(`Snapshot ${seq} is no longer retained`); - const out: CellChange[] = []; - for (let r = 0; r < this.#snapshot.lines.length; r++) - for (let c = 0; c < this.#snapshot.lines[r].cells.length; c++) { - const a = old.lines[r]?.cells[c], - b = this.#snapshot.lines[r].cells[c]; - if (a && JSON.stringify(a) !== JSON.stringify(b)) - out.push({ point: { column: c, row: r }, before: a, after: b }); - } - return out; - }, - revisions: (query) => this.revisionRange(query), - getKittyImage: (id: number): KittyImageSnapshot | undefined => this.#engine.cachedImage(id), - rawOutput: () => { - const n = this.#raw.reduce((s, b) => s + b.length, 0), - out = new Uint8Array(n); - let p = 0; - for (const b of this.#raw) { - out.set(b, p); - p += b.length; - } - return out; - }, - scrollback: () => [], - clipboard: () => this.#engine.clipboard(), - }; + #host!: SidecarClient; + #engine!: GhosttyWasmTerminal; + #snapshot!: ScreenSnapshot; + #status: ProcessStatus = { state: 'starting', ptyEof: false }; + #closed = false; + #action = 0; + #revision = 0; + #history: ScreenRevision[] = []; + #historyDecodedBytes = 0; + #historySizes: number[] = []; + // This is a session-owned generation because the Ghostty ABI does not expose one. + #terminalHistoryGeneration = 0; + #raw: Uint8Array[] = []; + #listeners = new Set<() => void>(); + #outputPump: Promise = Promise.resolve(); + #commands: Promise = Promise.resolve(); + #lastAction?: ActionReceipt; + #mouseDown = false; + #trace: SessionTrace; + #viewport; + #fatalError?: Error; + #exitResolve!: (s: ProcessStatus) => void; + #exitPromise: Promise; + private constructor(readonly options: TerminalLaunchOptions) { + this.#viewport = normalizeViewport(options.viewport); + const t = + typeof options.trace === 'string' + ? options.trace + : (options.trace?.policy ?? 'retain-on-failure'), + dir = + typeof options.trace === 'object' + ? (options.trace.directory ?? '.ghostwright') + : '.ghostwright'; + this.#trace = new SessionTrace(options, t, dir); + this.#exitPromise = new Promise((r) => (this.#exitResolve = r)); + } + static async launch(options: TerminalLaunchOptions) { + assertSupportedRuntime(); + if (!options.command || options.command.includes('\0')) + throw new TypeError('command must be a nonempty NUL-free string'); + for (const [label, value] of [ + ...(options.args ?? []).map((value, index) => [`argument ${index}`, value] as const), + ...Object.entries(options.env ?? {}).flatMap(([key, value]) => [ + [`environment key ${key}`, key] as const, + [`environment value ${key}`, value] as const, + ]), + ...(options.cwd ? [['cwd', options.cwd] as const] : []), + ]) + if (value.includes('\0')) throw new TypeError(`${label} must not contain NUL`); + for (const [label, value] of [ + ['commandTimeoutMs', options.commandTimeoutMs], + ['assertionTimeoutMs', options.assertionTimeoutMs], + ['settleMs', options.settleMs], + ['hangupGraceMs', options.cleanup?.hangupGraceMs], + ['terminateGraceMs', options.cleanup?.terminateGraceMs], + ['postExitDrainMs', options.cleanup?.postExitDrainMs], + ['maxRevisions', options.history?.maxRevisions], + ['maxRawBytes', options.history?.maxRawBytes], + ['maxDecodedBytes', options.history?.maxDecodedBytes], + ['graphics.storageLimitBytes', options.graphics?.storageLimitBytes], + ] as const) + if (value !== undefined && (!Number.isSafeInteger(value) || value < 0)) + throw new RangeError(`${label} must be a nonnegative safe integer`); + const self = new TerminalSession(options), + assets = await resolveAssets(options), + cwd = resolve(options.cwd ?? process.cwd()); + let env: Record; + try { + env = profileEnvironment(options.env, assets.terminfo); + } catch (cause) { + if ('Deno' in globalThis && !(cause instanceof ReservedEnvironmentError)) + throw new DenoPermissionError( + `Deno cannot inherit the launch environment. Add --allow-env together with --allow-read=${assets.root} --allow-run=${assets.host}`, + { cause }, + ); + throw cause; + } + self.#engine = await GhosttyWasmTerminal.create( + self.#viewport, + options.graphics?.storageLimitBytes ?? 64 * 1024 * 1024, + ); + self.#snapshot = self.#engine.snapshot(); + self.#trace.add('kitty-capability', { + supported: self.#snapshot.graphics.supported, + storageLimitBytes: self.#snapshot.graphics.storageLimitBytes, + profile: self.#snapshot.graphics.supported ? 'direct-raw-only' : 'disabled', + }); + self.#host = await SidecarClient.start(assets.host, options.commandTimeoutMs ?? 5000); + self.#host.on('output', (b, seq) => { + self.#outputPump = self.#outputPump + .then(() => self.#output(b, seq)) + .catch((error) => { + self.#status = { ...self.#status, state: 'failed' }; + self.#trace.add('output-pump-error', { + message: error instanceof Error ? error.message : String(error), + }); + self.#notify(); + self.#exitResolve(self.#status); + }); + }); + self.#host.on('exit', (x) => { + self.#status = { ...self.#status, state: 'exited', ...x }; + self.#trace.add('process-exit', x); + self.#notify(); + if (self.#status.ptyEof) self.#exitResolve(self.#status); + }); + self.#host.on('eof', () => { + void self.#outputPump.then(() => { + self.#status = { ...self.#status, ptyEof: true }; + self.#trace.add('pty-eof'); + self.#notify(); + if (self.#status.state === 'exited') self.#exitResolve(self.#status); + }); + }); + self.#host.on('fatal', (e) => { + self.#fatalError = e; + self.#status = { ...self.#status, state: 'failed' }; + self.#trace.add('sidecar-error', { message: e.message }); + self.#notify(); + self.#exitResolve(self.#status); + }); + let spawned: any; + try { + spawned = await self.#host.spawn({ + command: options.command, + args: options.args ?? [], + cwd, + env, + viewport: self.#viewport, + cleanup: { + hangupGraceMs: options.cleanup?.hangupGraceMs ?? 500, + terminateGraceMs: options.cleanup?.terminateGraceMs ?? 500, + postExitDrainMs: options.cleanup?.postExitDrainMs ?? 1_000, + }, + }); + } catch (cause) { + self.#host.forceKill(); + self.#engine.free(); + throw new LaunchError( + `Unable to launch ${JSON.stringify(options.command)} in ${JSON.stringify(cwd)}`, + { cause }, + ); + } + if (self.#fatalError) { + self.#engine.free(); + throw new LaunchError( + `Unable to launch ${JSON.stringify(options.command)} in ${JSON.stringify(cwd)}: ${self.#fatalError.message}`, + { cause: self.#fatalError }, + ); + } + self.#status = { + state: 'running', + pid: spawned.pid, + processGroupId: spawned.processGroupId, + ptyEof: false, + }; + self.#trace.add('spawned', { pid: spawned.pid, processGroupId: spawned.processGroupId }); + return self; + } + get trace() { + return this.#trace; + } + get revisionHistory() { + return this.#history; + } + get lastAction() { + return this.#lastAction; + } + now() { + return this.#engine.now(); + } + #notify() { + for (const f of [...this.#listeners]) f(); + } + subscribe(f: () => void) { + this.#listeners.add(f); + return () => this.#listeners.delete(f); + } + async #output(bytes: Uint8Array, sourceFrameSequence: number) { + if (this.#closed) return; + this.#trace.output(bytes, sourceFrameSequence); + this.#raw.push(bytes.slice()); + const max = this.options.history?.maxRawBytes ?? 4 * 1024 * 1024; + while (this.#raw.reduce((n, b) => n + b.length, 0) > max) this.#raw.shift(); + this.#engine.write(bytes); + // Any output can append, prune, reflow, reset, or switch Ghostty's active page list. + // Incrementing conservatively prevents a caller from mixing pagination layouts. + this.#terminalHistoryGeneration++; + this.#publish('pty-output', sourceFrameSequence); + for (const effect of this.#engine.takeEffects()) { + this.#trace.add('terminal-effect', { + effect: effect.type, + ...(effect.type === 'write-pty' ? { bytes: effect.data.length } : {}), + }); + if (effect.type === 'write-pty') { + this.#trace.input(effect.data, 0, false); + await this.#command(() => this.#host.write(effect.data)); + } + } + } + #command(operation: () => Promise): Promise { + const result = this.#commands.then(operation); + this.#commands = result; + return result; + } + #publish(cause: 'pty-output' | 'resize' | 'reset', sourceFrameSequence?: number) { + const decoded = this.#engine.snapshot(cause), + lines = decoded.lines.map((line, index) => + JSON.stringify(line) === JSON.stringify(this.#snapshot.lines[index]) + ? this.#snapshot.lines[index] + : line, + ), + next = Object.freeze({ ...decoded, lines: Object.freeze(lines) }), + changed = observableKey(next) !== observableKey(this.#snapshot); + if (!changed) return; + const visual = visualKey(next) !== visualKey(this.#snapshot), + changedRows: number[] = []; + for (let i = 0; i < next.lines.length; i++) + if (next.lines[i] !== this.#snapshot.lines[i]) changedRows.push(i); + this.#revision++; + this.#snapshot = Object.freeze({ + ...next, + sequence: this.#revision, + lastVisualChangeAt: visual ? next.timestamp : this.#snapshot.lastVisualChangeAt, + }); + const rev = Object.freeze({ + sequence: this.#revision, + timestamp: next.timestamp, + cause, + sourceFrameSequence, + changedRows: Object.freeze(changedRows), + visualChange: visual, + snapshot: this.#snapshot, + }); + this.#history.push(rev); + const decodedSize = changedRows.reduce( + (total, row) => total + JSON.stringify(this.#snapshot.lines[row]).length * 2, + 512, + ); + this.#historySizes.push(decodedSize); + this.#historyDecodedBytes += decodedSize; + const max = this.options.history?.maxRevisions ?? 1000, + maxDecoded = this.options.history?.maxDecodedBytes ?? 64 * 1024 * 1024; + while (this.#history.length > max || this.#historyDecodedBytes > maxDecoded) { + this.#history.shift(); + this.#historyDecodedBytes -= this.#historySizes.shift() ?? 0; + } + this.#trace.add('revision', { + revision: this.#revision, + cause, + changedRows, + visualChange: visual, + graphics: { + generation: this.#snapshot.graphics.generation, + placements: this.#snapshot.graphics.placements.map((placement) => ({ + imageId: placement.imageId, + placementId: placement.placementId, + imageGeneration: placement.imageGeneration, + sha256: placement.image?.sha256, + visible: placement.viewport.visible, + z: placement.z, + })), + }, + }); + this.#notify(); + } + #ensure(op: string) { + if (this.#closed) throw new SessionClosedError(`Cannot ${op}: terminal session is closed`); + } + async #send(kind: FrameKind, value: unknown, raw = false, delivered = true) { + this.#ensure('perform action'); + const before = this.#revision, + sequence = ++this.#action; + let ack: any; + if (kind === FrameKind.WRITE) + ack = await this.#command(() => this.#host.write(value as Uint8Array)); + else if (kind === FrameKind.RESIZE) ack = await this.#command(() => this.#host.resize(value)); + else if (kind === FrameKind.SIGNAL) ack = await this.#command(() => this.#host.signal(value)); + else throw new Error('Unsupported action'); + const receipt: Object = Object.freeze({ + actionSequence: sequence, + screenSequenceBefore: before, + acknowledgedAt: this.#engine.now(), + deliveredToChild: delivered, + bytesWritten: ack.bytesWritten ?? 0, + }); + this.#lastAction = receipt as ActionReceipt; + this.#trace.add('action', { + actionSequence: sequence, + kind, + bytesWritten: ack.bytesWritten ?? 0, + ...(kind === FrameKind.RESIZE ? { viewport: value } : {}), + }); + return receipt as ActionReceipt; + } + async #write( + data: Uint8Array, + delivered = data.length > 0, + traceMode: 'record' | 'redact' = 'record', + ) { + this.#ensure('write input'); + const before = this.#revision, + sequence = ++this.#action; + let total = 0; + this.#trace.input(data, sequence, traceMode === 'redact'); + for (let offset = 0; offset < data.length; offset += 65_536) { + const chunk = data.slice(offset, offset + 65_536); + const ack = await this.#command(() => this.#host.write(chunk)); + total += ack.bytesWritten ?? 0; + } + const receipt = Object.freeze({ + actionSequence: sequence, + screenSequenceBefore: before, + acknowledgedAt: this.#engine.now(), + deliveredToChild: delivered, + bytesWritten: total, + }); + this.#lastAction = receipt; + this.#trace.add('action', { + actionSequence: sequence, + kind: FrameKind.WRITE, + bytesWritten: total, + }); + return receipt; + } + keyboard = { + press: async (key: KeyName | KeyPress) => this.#write(this.#engine.encodeKey(key)), + type: async (text: string, options?: TraceableInputOptions) => + this.#write( + concatBytes(Array.from(text, (key) => this.#engine.encodeKey(key))), + true, + options?.trace ?? 'record', + ), + paste: async (text: string, options?: TraceableInputOptions) => + this.#write(this.#engine.encodePaste(text), true, options?.trace ?? 'record'), + focus: async (state: 'in' | 'out') => { + const b = this.#engine.encodeFocus(state); + return this.#write(b); + }, + write: async (data: Uint8Array) => this.#write(data), + }; + #point(p: Point) { + if ( + !Number.isInteger(p.column) || + !Number.isInteger(p.row) || + p.column < 0 || + p.row < 0 || + p.column >= this.#viewport.columns || + p.row >= this.#viewport.rows + ) + throw new CoordinateRangeError( + `Coordinate (${p.column},${p.row}) is outside ${this.#viewport.columns}x${this.#viewport.rows}`, + ); + } + #mouse(action: 'move' | 'down' | 'up', p: Point, o: MouseOptions = {}) { + this.#point(p); + const wasDown = this.#mouseDown; + if (action === 'down') this.#mouseDown = true; + if (action === 'up') this.#mouseDown = false; + const bytes = this.#engine.encodeMouse( + action, + p, + o, + action === 'up' ? wasDown : this.#mouseDown, + ); + return this.#write(bytes, bytes.length > 0); + } + mouse = { + move: (p: Point, o?: MouseOptions) => this.#mouse('move', p, o), + down: (p: Point, o?: MouseOptions) => this.#mouse('down', p, o), + up: (p: Point, o?: MouseOptions) => this.#mouse('up', p, o), + click: async (p: Point, o?: MouseOptions) => { + await this.#mouse('down', p, o); + return this.#mouse('up', p, o); + }, + doubleClick: async (p: Point, o?: MouseOptions) => { + await this.mouse.click(p, o); + return this.mouse.click(p, o); + }, + drag: async (a: Point, b: Point, o?: MouseOptions) => { + await this.#mouse('down', a, o); + await this.#mouse('move', b, o); + return this.#mouse('up', b, o); + }, + wheel: (o: WheelOptions) => { + this.#point(o); + if (!Number.isInteger(o.deltaRows) || !Number.isInteger(o.deltaColumns ?? 0)) + throw new RangeError('Wheel deltas must be integers'); + const parts: Uint8Array[] = []; + for (let index = 0; index < Math.abs(o.deltaRows); index++) + parts.push(this.#engine.encodeMouse('down', o, { button: o.deltaRows < 0 ? 4 : 5 }, false)); + for (let index = 0; index < Math.abs(o.deltaColumns ?? 0); index++) + parts.push( + this.#engine.encodeMouse('down', o, { button: (o.deltaColumns ?? 0) < 0 ? 6 : 7 }, false), + ); + const bytes = concatBytes(parts); + return this.#write(bytes, bytes.length > 0); + }, + }; + process = { + status: () => ({ ...this.#status }), + signal: (signal: string, target: 'child' | 'process-group' = 'process-group') => + this.#send(FrameKind.SIGNAL, { signal, target }), + waitForExit: async (options?: { timeoutMs?: number }) => + this.#timeout( + this.#exitPromise, + options?.timeoutMs ?? this.options.assertionTimeoutMs ?? 5000, + () => new ProcessExitedError('Timed out waiting for process exit'), + ), + }; + revisions = { + collect: (options: RevisionCollectionOptions) => this.collectRevisions(options), + }; + history = { + read: (query?: HistoryQuery) => this.readHistory(query), + findText: (text: string, options?: HistorySearchOptions) => this.findHistoryText(text, options), + }; + graphics = { + inspectImage: async (id: number): Promise => { + await this.#outputPump; + return this.#engine.inspectImage(id); + }, + copyImageData: async (id: number): Promise => { + await this.#outputPump; + return this.#engine.copyImageData(id); + }, + }; + getByText(text: string, options?: TextLocatorOptions) { + return new Locator(this, text, options); + } + region(rect: Rect): AsyncRegion { + this.#rect(rect); + return { + getByText: (text, options) => new Locator(this, text, options, undefined, rect), + snapshot: () => this.#snapshot, + }; + } + validateRegion(r: Rect) { + this.#rect(r); + } + #rect(r: Rect) { + if (!Number.isInteger(r.width) || !Number.isInteger(r.height) || r.width <= 0 || r.height <= 0) + this.#point({ column: -1, row: -1 }); + this.#point(r); + this.#point({ column: r.column + r.width - 1, row: r.row + r.height - 1 }); + } + async resize(v: Viewport) { + const viewport = normalizeViewport(v); + const receipt = await this.#send(FrameKind.RESIZE, viewport); + this.#viewport = viewport; + this.#engine.resize(viewport); + this.#terminalHistoryGeneration++; + this.#publish('resize'); + return receipt; + } + async close() { + if (this.#closed) + return Object.freeze({ + actionSequence: ++this.#action, + screenSequenceBefore: this.#revision, + acknowledgedAt: this.#engine.now(), + deliveredToChild: false, + bytesWritten: 0, + }); + const before = this.#revision, + sequence = ++this.#action; + try { + const c = this.options.cleanup ?? {}, + timeout = Math.max( + this.options.commandTimeoutMs ?? 5000, + (c.hangupGraceMs ?? 500) + + (c.terminateGraceMs ?? 500) + + (c.postExitDrainMs ?? 1000) + + 1000, + ); + await this.#outputPump; + await this.#commands; + await this.#host.close(timeout); + const receipt = Object.freeze({ + actionSequence: sequence, + screenSequenceBefore: before, + acknowledgedAt: this.#engine.now(), + deliveredToChild: true, + bytesWritten: 0, + }); + this.#lastAction = receipt; + this.#trace.add('action', { actionSequence: sequence, kind: FrameKind.CLOSE }); + return receipt; + } finally { + this.#closed = true; + this.#status = { ...this.#status, state: 'closed' }; + this.#engine.free(); + this.#trace.add('cleanup'); + this.#notify(); + } + } + async waitForChange(test: () => boolean, timeout: number) { + if (test()) return; + await new Promise((resolve, reject) => { + const off = this.subscribe(() => { + if (test()) { + clearTimeout(timer); + off(); + resolve(); + } else if (this.#status.state === 'closed' || this.#status.state === 'failed') { + clearTimeout(timer); + off(); + reject(new SessionClosedError('Session closed before condition matched')); + } else if (this.#status.ptyEof) { + clearTimeout(timer); + off(); + reject(new ProcessExitedError('Process exited before condition matched')); + } + }), + timer = setTimeout(() => { + off(); + reject(new Error('timeout')); + }, timeout); + }); + } + async #timeout(p: Promise, ms: number, error: () => Error) { + return new Promise((r, j) => { + const t = setTimeout(() => j(error()), ms); + p.then( + (x) => { + clearTimeout(t); + r(x); + }, + (e) => { + clearTimeout(t); + j(e); + }, + ); + }); + } + #baselineSequence(value: ActionReceipt | ScreenRevision | number) { + if (typeof value === 'number') { + if (!Number.isSafeInteger(value) || value < 0) + throw new RangeError('revision sequence must be a nonnegative safe integer'); + return value; + } + return 'screenSequenceBefore' in value ? value.screenSequenceBefore : value.sequence; + } + revisionsSince(sequence: number) { + const earliest = this.#history[0]?.sequence ?? this.#revision, + latest = this.#history.at(-1)?.sequence ?? this.#revision; + if (sequence < earliest - 1) + throw new HistoryEvictedError( + `Revision baseline ${sequence} was evicted; retained range is ${earliest} through ${latest}`, + ); + return this.#history.filter((revision) => revision.sequence > sequence); + } + revisionRange(query: RevisionRangeQuery) { + const since = this.#baselineSequence(query.since), + until = query.until === undefined ? undefined : this.#baselineSequence(query.until), + max = this.options.history?.maxRevisions ?? 1000, + limit = query.limit ?? max; + if (!Number.isSafeInteger(limit) || limit <= 0) + throw new RangeError('revision limit must be positive'); + if (limit > max) + throw new RangeError( + `revision limit ${limit} exceeds the configured retained maximum ${max}`, + ); + if (until !== undefined && until < since) + throw new RangeError('revision until sequence must not precede its exclusive baseline'); + const revisions = this.revisionsSince(since); + if (until !== undefined) { + const latest = this.#history.at(-1)?.sequence ?? this.#revision; + if (until < (this.#history[0]?.sequence ?? this.#revision) || until > latest) + throw new HistoryEvictedError( + `Revision endpoint ${until} cannot be proven from retained range ${this.#history[0]?.sequence ?? this.#revision} through ${latest}`, + ); + } + return Object.freeze( + revisions + .filter((revision) => until === undefined || revision.sequence <= until) + .slice(0, limit), + ); + } + async collectRevisions(options: RevisionCollectionOptions): Promise { + const baseline = this.#baselineSequence(options.since), + max = options.maxRevisions ?? 1000, + configuredMax = this.options.history?.maxRevisions ?? 1000, + timeout = options.timeoutMs ?? this.options.assertionTimeoutMs ?? 5000, + startedAt = this.now(); + if (!Number.isSafeInteger(max) || max <= 0 || max > configuredMax) + throw new RangeError(`maxRevisions must be positive and no greater than ${configuredMax}`); + if (!Number.isSafeInteger(timeout) || timeout < 0) + throw new RangeError('timeoutMs must be nonnegative'); + const samples = [...this.revisionsSince(baseline)].slice(0, max); + const complete = () => samples.some((revision) => options.until(revision.snapshot, revision)); + if (!complete() && samples.length === max) + throw new HistoryEvictedError( + `Revision collection reached its ${max} sample limit before its predicate matched`, + ); + if (!complete()) + await new Promise((resolve, reject) => { + let timer: ReturnType | undefined; + const finish = (error?: Error) => { + if (timer) clearTimeout(timer); + off(); + error ? reject(error) : resolve(); + }; + const off = this.subscribe(() => { + try { + const latest = this.revisionsSince(baseline); + for (const revision of latest) + if (!samples.some((sample) => sample.sequence === revision.sequence)) + samples.push(revision); + if (samples.length > max) + return finish( + new HistoryEvictedError( + `Revision collection exceeded its ${max} sample limit before its predicate matched`, + ), + ); + if (complete()) return finish(); + if (this.#status.state === 'closed' || this.#status.state === 'failed') + return finish(new SessionClosedError('Session closed during revision collection')); + if (this.#status.ptyEof) + return finish(new ProcessExitedError('Process exited during revision collection')); + } catch (error) { + finish(error instanceof Error ? error : new Error(String(error))); + } + }); + timer = setTimeout( + () => + finish( + new TerminalAssertionError(`Timed out collecting revisions after ${timeout} ms`), + ), + timeout, + ); + }); + return Object.freeze({ + baselineSequence: baseline, + startedAt, + completedAt: this.now(), + revisions: Object.freeze(samples), + }); + } + async readHistory(query: HistoryQuery = {}): Promise { + const direction = query.direction ?? 'oldest-first', + count = query.count ?? 200, + start = query.start ?? 0; + if (!Number.isSafeInteger(start) || start < 0) + throw new RangeError('history start must be nonnegative'); + if (!Number.isSafeInteger(count) || count <= 0 || count > 1000) + throw new RangeError('history count must be positive and no greater than 1000'); + await this.#outputPump; + const generation = String(this.#terminalHistoryGeneration); + if (query.expectedGeneration !== undefined && query.expectedGeneration !== generation) + throw new HistoryChangedError( + `Terminal history changed (expected generation ${query.expectedGeneration}, current ${generation})`, + ); + const predecessor = start > 0 ? this.#engine.history(start - 1, 1)[0] : undefined; + const rows = this.#engine.history(start, count); + const lines = rows.map((entry, offset) => { + const line = entry.line; + return Object.freeze({ + index: start + offset, + text: line.text, + wrapped: line.wrapped, + wrapContinuation: offset > 0 ? rows[offset - 1].line.wrapped : !!predecessor?.line.wrapped, + kittyVirtualPlaceholder: entry.kittyVirtualPlaceholder, + cells: line.cells, + } satisfies HistoryLine); + }); + if (direction === 'newest-first') lines.reverse(); + return Object.freeze({ + generation, + totalRows: this.#engine.scrollbackRows(), + start, + direction, + lines: Object.freeze(lines), + }); + } + async findHistoryText( + text: string, + options: HistorySearchOptions = {}, + ): Promise { + if (!text.length) throw new RangeError('history search text must be nonempty'); + const maxRows = options.maxRows ?? 1000, + limit = options.limit ?? 20; + if (!Number.isSafeInteger(maxRows) || maxRows <= 0 || maxRows > 10_000) + throw new RangeError('history maxRows must be positive and no greater than 10000'); + if (!Number.isSafeInteger(limit) || limit <= 0 || limit > 1000) + throw new RangeError('history result limit must be positive and no greater than 1000'); + const start = options.start ?? 0; + if (!Number.isSafeInteger(start) || start < 0) + throw new RangeError('history start must be nonnegative'); + const lines: HistoryLine[] = []; + let generation = options.expectedGeneration; + for (let offset = start; offset < start + maxRows; offset += 1000) { + const page = await this.readHistory({ + direction: 'oldest-first', + start: offset, + count: Math.min(1000, start + maxRows - offset), + expectedGeneration: generation, + }); + generation ??= page.generation; + lines.push(...page.lines); + if (page.lines.length < 1000) break; + } + if (options.direction === 'newest-first') lines.reverse(); + const results: HistoryMatch[] = []; + for (const line of lines) { + let textOffset = 0; + const segments = line.cells + .filter((cell) => !cell.continuation) + .map((cell) => { + const start = textOffset; + textOffset += (cell.style.invisible ? ' ' : cell.text || ' ').length; + return { cell, start, end: textOffset }; + }); + let matchAt = 0; + while (results.length < limit && (matchAt = line.text.indexOf(text, matchAt)) >= 0) { + const first = segments.find((segment) => matchAt < segment.end) ?? segments.at(-1); + const last = + [...segments].reverse().find((segment) => matchAt + text.length > segment.start) ?? first; + const column = first?.cell.column ?? 0, + endColumn = last ? last.cell.column + Math.max(1, last.cell.width) : column + 1; + results.push( + Object.freeze({ + lineIndex: line.index, + text, + range: { + column, + row: line.index, + width: Math.max(1, endColumn - column), + height: 1, + }, + line, + }), + ); + matchAt += Math.max(1, text.length); + } + if (results.length === limit) break; + } + return Object.freeze(results); + } + screen: ScreenReader = { + current: () => this.#snapshot, + getCell: (p: Point) => { + this.#point(p); + return this.#snapshot.lines[p.row].cells[p.column]; + }, + getText: (r?: Rect) => { + if (!r) return this.#snapshot.lines.map((l) => l.text).join('\n'); + this.#rect(r); + return this.#snapshot.lines + .slice(r.row, r.row + r.height) + .map((l) => + l.cells + .slice(r.column, r.column + r.width) + .map((c) => (c.continuation ? '' : c.style.invisible ? ' ' : c.text || ' ')) + .join(''), + ) + .join('\n'); + }, + changedCells: (since: ScreenSnapshot | number) => { + const seq = typeof since === 'number' ? since : since.sequence, + old = + typeof since === 'number' + ? this.#history.find((r) => r.sequence === seq)?.snapshot + : since; + if (!old) throw new HistoryEvictedError(`Snapshot ${seq} is no longer retained`); + const out: CellChange[] = []; + for (let r = 0; r < this.#snapshot.lines.length; r++) + for (let c = 0; c < this.#snapshot.lines[r].cells.length; c++) { + const a = old.lines[r]?.cells[c], + b = this.#snapshot.lines[r].cells[c]; + if (a && JSON.stringify(a) !== JSON.stringify(b)) + out.push({ point: { column: c, row: r }, before: a, after: b }); + } + return out; + }, + revisions: (query) => this.revisionRange(query), + getKittyImage: (id: number): KittyImageSnapshot | undefined => this.#engine.cachedImage(id), + rawOutput: () => { + const n = this.#raw.reduce((s, b) => s + b.length, 0), + out = new Uint8Array(n); + let p = 0; + for (const b of this.#raw) { + out.set(b, p); + p += b.length; + } + return out; + }, + scrollback: () => [], + clipboard: () => this.#engine.clipboard(), + }; } export class Locator implements AsyncLocator { - constructor( - readonly session: TerminalSession, - readonly query: string, - readonly options: TextLocatorOptions = {}, - readonly index?: number, - readonly bounds?: Rect, - ) {} - nth(index: number) { - if (!Number.isInteger(index) || index < 0) - throw new RangeError("nth index must be nonnegative"); - return new Locator(this.session, this.query, this.options, index, this.bounds); - } - region(rect: Rect) { - this.session.validateRegion(rect); - return new Locator(this.session, this.query, this.options, this.index, rect); - } - matches() { - const s = this.session.screen.current(), - out: LocatorMatch[] = []; - for (const line of s.lines) { - if ( - this.bounds && - (line.row < this.bounds.row || line.row >= this.bounds.row + this.bounds.height) - ) - continue; - const start = this.bounds?.column ?? 0, - end = this.bounds ? this.bounds.column + this.bounds.width : s.viewport.columns, - segments: Array<{ start: number; end: number; cell: ScreenCell }> = []; - let row = ""; - for (const cell of line.cells.slice(start, end)) { - if (cell.continuation) continue; - const text = cell.style.invisible ? " " : cell.text || " ", - offset = row.length; - row += text; - segments.push({ start: offset, end: row.length, cell }); - } - const rangeFor = (from: number, to: number): Rect => { - const first = segments.find((segment) => from < segment.end) ?? segments.at(-1), - last = [...segments].reverse().find((segment) => to > segment.start) ?? first, - column = first?.cell.column ?? start, - lastEnd = last ? last.cell.column + Math.max(1, last.cell.width) : column + 1; - return { column, row: line.row, width: Math.max(1, lastEnd - column), height: 1 }; - }; - if (this.options.exact) { - const trimmed = row.replace(/ +$/g, ""); - if (trimmed === this.query) - out.push({ - text: trimmed, - rowText: row, - range: rangeFor(0, trimmed.length), - }); - } else { - let at = 0; - while (this.query.length && (at = row.indexOf(this.query, at)) >= 0) { - out.push({ - text: this.query, - rowText: row, - range: rangeFor(at, at + this.query.length), - }); - at += Math.max(1, this.query.length); - } - } - } - const chosen = this.index === undefined ? out : out[this.index] ? [out[this.index]] : []; - return Object.freeze(chosen); - } - async unique(timeout = this.session.options.assertionTimeoutMs ?? 5000) { - const get = () => this.matches(); - let m = get(); - if (m.length > 1) - throw new StrictLocatorError( - `Locator ${JSON.stringify(this.query)} matched ${m.length} ranges: ${m.map((x) => JSON.stringify(x.range)).join(", ")}`, - ); - if (!m.length) { - await this.session.waitForChange(() => { - m = get(); - if (m.length > 1) - throw new StrictLocatorError( - `Locator ${JSON.stringify(this.query)} matched multiple ranges`, - ); - return m.length === 1; - }, timeout); - } - return m[0]; - } - async click(options?: MouseOptions) { - const m = await this.unique(), - p = { - column: Math.floor((m.range.column + m.range.column + m.range.width - 1) / 2), - row: Math.floor((m.range.row + m.range.row + m.range.height - 1) / 2), - }; - return this.session.mouse.click(p, options); - } + constructor( + readonly session: TerminalSession, + readonly query: string, + readonly options: TextLocatorOptions = {}, + readonly index?: number, + readonly bounds?: Rect, + ) {} + nth(index: number) { + if (!Number.isInteger(index) || index < 0) + throw new RangeError('nth index must be nonnegative'); + return new Locator(this.session, this.query, this.options, index, this.bounds); + } + region(rect: Rect) { + this.session.validateRegion(rect); + return new Locator(this.session, this.query, this.options, this.index, rect); + } + matches() { + const s = this.session.screen.current(), + out: LocatorMatch[] = []; + for (const line of s.lines) { + if ( + this.bounds && + (line.row < this.bounds.row || line.row >= this.bounds.row + this.bounds.height) + ) + continue; + const start = this.bounds?.column ?? 0, + end = this.bounds ? this.bounds.column + this.bounds.width : s.viewport.columns, + segments: Array<{ start: number; end: number; cell: ScreenCell }> = []; + let row = ''; + for (const cell of line.cells.slice(start, end)) { + if (cell.continuation) continue; + const text = cell.style.invisible ? ' ' : cell.text || ' ', + offset = row.length; + row += text; + segments.push({ start: offset, end: row.length, cell }); + } + const rangeFor = (from: number, to: number): Rect => { + const first = segments.find((segment) => from < segment.end) ?? segments.at(-1), + last = [...segments].reverse().find((segment) => to > segment.start) ?? first, + column = first?.cell.column ?? start, + lastEnd = last ? last.cell.column + Math.max(1, last.cell.width) : column + 1; + return { column, row: line.row, width: Math.max(1, lastEnd - column), height: 1 }; + }; + if (this.options.exact) { + const trimmed = row.replace(/ +$/g, ''); + if (trimmed === this.query) + out.push({ + text: trimmed, + rowText: row, + range: rangeFor(0, trimmed.length), + }); + } else { + let at = 0; + while (this.query.length && (at = row.indexOf(this.query, at)) >= 0) { + out.push({ + text: this.query, + rowText: row, + range: rangeFor(at, at + this.query.length), + }); + at += Math.max(1, this.query.length); + } + } + } + const chosen = this.index === undefined ? out : out[this.index] ? [out[this.index]] : []; + return Object.freeze(chosen); + } + async unique(timeout = this.session.options.assertionTimeoutMs ?? 5000) { + const get = () => this.matches(); + let m = get(); + if (m.length > 1) + throw new StrictLocatorError( + `Locator ${JSON.stringify(this.query)} matched ${m.length} ranges: ${m.map((x) => JSON.stringify(x.range)).join(', ')}`, + ); + if (!m.length) { + await this.session.waitForChange(() => { + m = get(); + if (m.length > 1) + throw new StrictLocatorError( + `Locator ${JSON.stringify(this.query)} matched multiple ranges`, + ); + return m.length === 1; + }, timeout); + } + return m[0]; + } + async click(options?: MouseOptions) { + const m = await this.unique(), + p = { + column: Math.floor((m.range.column + m.range.column + m.range.width - 1) / 2), + row: Math.floor((m.range.row + m.range.row + m.range.height - 1) / 2), + }; + return this.session.mouse.click(p, options); + } } diff --git a/experiments/ghostwright/src/terminal/wasm.ts b/experiments/ghostwright/src/terminal/wasm.ts index 6ca06f5..5301000 100644 --- a/experiments/ghostwright/src/terminal/wasm.ts +++ b/experiments/ghostwright/src/terminal/wasm.ts @@ -1,1371 +1,1371 @@ -import { createHash } from "node:crypto"; -import { readFile } from "node:fs/promises"; +import { createHash } from 'node:crypto'; +import { readFile } from 'node:fs/promises'; import type { - CellStyle, - KittyGraphicsSnapshot, - KittyImageSnapshot, - KittyPlacementSnapshot, - ScreenCell, - ScreenLine, - ScreenSnapshot, - TerminalModes, - Viewport, - KeyName, - KeyPress, - MouseOptions, - Point, -} from "../types.ts"; -import { AssetIntegrityError } from "../errors.ts"; + CellStyle, + KittyGraphicsSnapshot, + KittyImageSnapshot, + KittyPlacementSnapshot, + ScreenCell, + ScreenLine, + ScreenSnapshot, + TerminalModes, + Viewport, + KeyName, + KeyPress, + MouseOptions, + Point, +} from '../types.ts'; +import { AssetIntegrityError } from '../errors.ts'; type Fn = (...args: any[]) => number; type Exports = Record & { - memory: WebAssembly.Memory; - __indirect_function_table: WebAssembly.Table; + memory: WebAssembly.Memory; + __indirect_function_table: WebAssembly.Table; }; type LayoutMap = Record< - string, - { - size: number; - fields: Record; - } + string, + { + size: number; + fields: Record; + } >; const encoder = new TextEncoder(), - decoder = new TextDecoder(); + decoder = new TextDecoder(); function unsignedLeb(value: number): number[] { - const bytes: number[] = []; - do { - let byte = value & 0x7f; - value >>>= 7; - if (value) byte |= 0x80; - bytes.push(byte); - } while (value); - return bytes; + const bytes: number[] = []; + do { + let byte = value & 0x7f; + value >>>= 7; + if (value) byte |= 0x80; + bytes.push(byte); + } while (value); + return bytes; } function callbackModule(parameterCount: number, returnsInt = false): WebAssembly.Module { - const section = (id: number, payload: number[]) => [ - id, - ...unsignedLeb(payload.length), - ...payload, - ], - name = (value: string) => { - const bytes = [...encoder.encode(value)]; - return [...unsignedLeb(bytes.length), ...bytes]; - }, - type = [ - 1, - 0x60, - ...unsignedLeb(parameterCount), - ...Array.from({ length: parameterCount }, () => 0x7f), - returnsInt ? 1 : 0, - ...(returnsInt ? [0x7f] : []), - ], - imported = [1, ...name("env"), ...name("callback"), 0, 0], - exported = [1, ...name("callback"), 0, 0]; - return new WebAssembly.Module( - Uint8Array.from([ - 0x00, - 0x61, - 0x73, - 0x6d, - 0x01, - 0x00, - 0x00, - 0x00, - ...section(1, type), - ...section(2, imported), - ...section(7, exported), - ]), - ); + const section = (id: number, payload: number[]) => [ + id, + ...unsignedLeb(payload.length), + ...payload, + ], + name = (value: string) => { + const bytes = [...encoder.encode(value)]; + return [...unsignedLeb(bytes.length), ...bytes]; + }, + type = [ + 1, + 0x60, + ...unsignedLeb(parameterCount), + ...Array.from({ length: parameterCount }, () => 0x7f), + returnsInt ? 1 : 0, + ...(returnsInt ? [0x7f] : []), + ], + imported = [1, ...name('env'), ...name('callback'), 0, 0], + exported = [1, ...name('callback'), 0, 0]; + return new WebAssembly.Module( + Uint8Array.from([ + 0x00, + 0x61, + 0x73, + 0x6d, + 0x01, + 0x00, + 0x00, + 0x00, + ...section(1, type), + ...section(2, imported), + ...section(7, exported), + ]), + ); } export type TerminalEffect = - | { type: "write-pty"; data: Uint8Array } - | { type: "bell" } - | { type: "title" } - | { type: "working-directory" } - | { type: "clipboard-write"; data: string }; + | { type: 'write-pty'; data: Uint8Array } + | { type: 'bell' } + | { type: 'title' } + | { type: 'working-directory' } + | { type: 'clipboard-write'; data: string }; interface HistoryRow { - line: ScreenLine; - kittyVirtualPlaceholder: boolean; + line: ScreenLine; + kittyVirtualPlaceholder: boolean; } const defaultStyle: CellStyle = Object.freeze({ - bold: false, - italic: false, - faint: false, - blink: false, - inverse: false, - invisible: false, - strikethrough: false, - overline: false, - underline: 0, - foreground: Object.freeze({ kind: "default" as const }), - background: Object.freeze({ kind: "default" as const }), + bold: false, + italic: false, + faint: false, + blink: false, + inverse: false, + invisible: false, + strikethrough: false, + overline: false, + underline: 0, + foreground: Object.freeze({ kind: 'default' as const }), + background: Object.freeze({ kind: 'default' as const }), }); let compiled: Promise | undefined; async function moduleFor(url: URL) { - return (compiled ??= WebAssembly.compile(await readFile(url))); + return (compiled ??= WebAssembly.compile(await readFile(url))); } function freeze(value: T): T { - // Typed arrays are mutable buffers by design and cannot be frozen when nonempty. - if (ArrayBuffer.isView(value)) return value; - if (value && typeof value === "object" && !Object.isFrozen(value)) { - Object.freeze(value); - for (const v of Object.values(value)) freeze(v); - } - return value; + // Typed arrays are mutable buffers by design and cannot be frozen when nonempty. + if (ArrayBuffer.isView(value)) return value; + if (value && typeof value === 'object' && !Object.isFrozen(value)) { + Object.freeze(value); + for (const v of Object.values(value)) freeze(v); + } + return value; } export class GhosttyWasmTerminal { - #e!: Exports; - #terminal = 0; - #formatter = 0; - #renderState = 0; - #rowIterator = 0; - #rowIteratorHolder = 0; - #rowCells = 0; - #rowCellsHolder = 0; - #keyEncoder = 0; - #keyEvent = 0; - #mouseEncoder = 0; - #mouseEvent = 0; - #viewport: Required; - #layouts!: LayoutMap; - #allocations: Array<{ pointer: number; size: number }> = []; - #opaqueAllocations: number[] = []; - #callbackInstances: WebAssembly.Instance[] = []; - #effects: TerminalEffect[] = []; - #clipboard = ""; - #sequence = 0; - #started = performance.now(); - #lastVisual = 0; - #previous = ""; - #kittySupported = false; - #kittyStorageLimit = 0; - // Metadata is immutable and may be structurally shared by retained revisions. - // Decoded pixel copies deliberately never enter this cache. - #images = new Map(); - #currentImageKeys = new Set(); - #inspectedImageIds = new Set(); - private constructor(viewport: Required) { - this.#viewport = viewport; - } - static async create(viewport: Required, storageLimitBytes = 64 * 1024 * 1024) { - const self = new GhosttyWasmTerminal(viewport); - const url = new URL( - import.meta.url.includes("/dist/") - ? "../artifacts/ghostty-vt.wasm" - : "../../artifacts/ghostty-vt.wasm", - import.meta.url, - ); - let mod: WebAssembly.Module; - try { - mod = await moduleFor(url); - } catch (cause) { - throw new AssetIntegrityError(`Unable to load ${url.pathname}`, { cause }); - } - const instance = await WebAssembly.instantiate(mod, { env: { log() {} } }); - self.#e = instance.exports as unknown as Exports; - self.#initialize(storageLimitBytes); - return self; - } - #view() { - return new DataView(this.#e.memory.buffer); - } - #bytes() { - return new Uint8Array(this.#e.memory.buffer); - } - #opaque() { - const p = this.#e.ghostty_wasm_alloc_opaque(); - if (!p) throw new AssetIntegrityError("WASM allocation failed"); - this.#opaqueAllocations.push(p); - return p; - } - #alloc(n: number) { - const p = this.#e.ghostty_wasm_alloc_u8_array(n); - if (!p) throw new AssetIntegrityError("WASM allocation failed"); - this.#allocations.push({ pointer: p, size: n }); - return p; - } - #release(pointer: number, size: number) { - this.#e.ghostty_wasm_free_u8_array(pointer, size); - const index = this.#allocations.findIndex((item) => item.pointer === pointer); - if (index >= 0) this.#allocations.splice(index, 1); - } - #readTypeLayouts() { - const pointer = this.#e.ghostty_type_json(); - const bytes = this.#bytes(); - let end = pointer; - while (end < bytes.length && bytes[end] !== 0) end++; - try { - return JSON.parse(decoder.decode(bytes.subarray(pointer, end))) as LayoutMap; - } catch (cause) { - throw new AssetIntegrityError("Unable to decode libghostty-vt ABI metadata", { cause }); - } - } - #initialize(storageLimitBytes: number) { - this.#layouts = this.#readTypeLayouts(); - const terminalLayout = this.#layouts.GhosttyTerminalOptions; - if (!terminalLayout || terminalLayout.size !== 8) - throw new AssetIntegrityError("Unexpected GhosttyTerminalOptions ABI layout"); - const out = this.#opaque(), - opts = this.#alloc(terminalLayout.size), - d = this.#view(); - d.setUint16(opts, this.#viewport.columns, true); - d.setUint16(opts + 2, this.#viewport.rows, true); - d.setUint32(opts + 4, 10000, true); - if (this.#e.ghostty_terminal_new(0, out, opts) !== 0) - throw new AssetIntegrityError("ghostty_terminal_new failed"); - this.#terminal = this.#view().getUint32(out, true); - const capability = this.#alloc(1); - try { - this.#kittySupported = - this.#e.ghostty_build_info(2, capability) === 0 && this.#view().getUint8(capability) !== 0; - } finally { - this.#release(capability, 1); - } - if (!this.#kittySupported) - throw new AssetIntegrityError( - "ghostty-vt.wasm was built without the required Kitty graphics capability", - ); - { - if (!Number.isSafeInteger(storageLimitBytes) || storageLimitBytes < 0) - throw new RangeError("graphics.storageLimitBytes must be a nonnegative safe integer"); - const limit = this.#alloc(8); - try { - this.#view().setBigUint64(limit, BigInt(storageLimitBytes), true); - if (this.#e.ghostty_terminal_set(this.#terminal, 15, limit) !== 0) - throw new AssetIntegrityError("Unable to configure Kitty image storage limit"); - this.#kittyStorageLimit = storageLimitBytes; - } finally { - this.#release(limit, 8); - } - } - if ( - this.#e.ghostty_terminal_resize( - this.#terminal, - this.#viewport.columns, - this.#viewport.rows, - 10, - 20, - ) !== 0 - ) - throw new AssetIntegrityError("Unable to initialize Ghostty cell pixel geometry"); - const fo = this.#opaque(), - o = this.#alloc(40), - v = this.#view(); - v.setUint32(o, 40, true); - v.setUint32(o + 4, 0, true); - v.setUint8(o + 8, 0); - v.setUint8(o + 9, 0); - v.setUint32(o + 12, 24, true); - v.setUint32(o + 24, 12, true); - if (this.#e.ghostty_formatter_terminal_new(0, fo, this.#terminal, o) !== 0) - throw new AssetIntegrityError("ghostty_formatter_terminal_new failed"); - this.#formatter = this.#view().getUint32(fo, true); - const renderStateOut = this.#opaque(); - if (this.#e.ghostty_render_state_new(0, renderStateOut) !== 0) - throw new AssetIntegrityError("ghostty_render_state_new failed"); - this.#renderState = this.#view().getUint32(renderStateOut, true); - this.#rowIteratorHolder = this.#opaque(); - if (this.#e.ghostty_render_state_row_iterator_new(0, this.#rowIteratorHolder) !== 0) - throw new AssetIntegrityError("ghostty_render_state_row_iterator_new failed"); - this.#rowIterator = this.#view().getUint32(this.#rowIteratorHolder, true); - this.#rowCellsHolder = this.#opaque(); - if (this.#e.ghostty_render_state_row_cells_new(0, this.#rowCellsHolder) !== 0) - throw new AssetIntegrityError("ghostty_render_state_row_cells_new failed"); - this.#rowCells = this.#view().getUint32(this.#rowCellsHolder, true); + #e!: Exports; + #terminal = 0; + #formatter = 0; + #renderState = 0; + #rowIterator = 0; + #rowIteratorHolder = 0; + #rowCells = 0; + #rowCellsHolder = 0; + #keyEncoder = 0; + #keyEvent = 0; + #mouseEncoder = 0; + #mouseEvent = 0; + #viewport: Required; + #layouts!: LayoutMap; + #allocations: Array<{ pointer: number; size: number }> = []; + #opaqueAllocations: number[] = []; + #callbackInstances: WebAssembly.Instance[] = []; + #effects: TerminalEffect[] = []; + #clipboard = ''; + #sequence = 0; + #started = performance.now(); + #lastVisual = 0; + #previous = ''; + #kittySupported = false; + #kittyStorageLimit = 0; + // Metadata is immutable and may be structurally shared by retained revisions. + // Decoded pixel copies deliberately never enter this cache. + #images = new Map(); + #currentImageKeys = new Set(); + #inspectedImageIds = new Set(); + private constructor(viewport: Required) { + this.#viewport = viewport; + } + static async create(viewport: Required, storageLimitBytes = 64 * 1024 * 1024) { + const self = new GhosttyWasmTerminal(viewport); + const url = new URL( + import.meta.url.includes('/dist/') + ? '../artifacts/ghostty-vt.wasm' + : '../../artifacts/ghostty-vt.wasm', + import.meta.url, + ); + let mod: WebAssembly.Module; + try { + mod = await moduleFor(url); + } catch (cause) { + throw new AssetIntegrityError(`Unable to load ${url.pathname}`, { cause }); + } + const instance = await WebAssembly.instantiate(mod, { env: { log() {} } }); + self.#e = instance.exports as unknown as Exports; + self.#initialize(storageLimitBytes); + return self; + } + #view() { + return new DataView(this.#e.memory.buffer); + } + #bytes() { + return new Uint8Array(this.#e.memory.buffer); + } + #opaque() { + const p = this.#e.ghostty_wasm_alloc_opaque(); + if (!p) throw new AssetIntegrityError('WASM allocation failed'); + this.#opaqueAllocations.push(p); + return p; + } + #alloc(n: number) { + const p = this.#e.ghostty_wasm_alloc_u8_array(n); + if (!p) throw new AssetIntegrityError('WASM allocation failed'); + this.#allocations.push({ pointer: p, size: n }); + return p; + } + #release(pointer: number, size: number) { + this.#e.ghostty_wasm_free_u8_array(pointer, size); + const index = this.#allocations.findIndex((item) => item.pointer === pointer); + if (index >= 0) this.#allocations.splice(index, 1); + } + #readTypeLayouts() { + const pointer = this.#e.ghostty_type_json(); + const bytes = this.#bytes(); + let end = pointer; + while (end < bytes.length && bytes[end] !== 0) end++; + try { + return JSON.parse(decoder.decode(bytes.subarray(pointer, end))) as LayoutMap; + } catch (cause) { + throw new AssetIntegrityError('Unable to decode libghostty-vt ABI metadata', { cause }); + } + } + #initialize(storageLimitBytes: number) { + this.#layouts = this.#readTypeLayouts(); + const terminalLayout = this.#layouts.GhosttyTerminalOptions; + if (!terminalLayout || terminalLayout.size !== 8) + throw new AssetIntegrityError('Unexpected GhosttyTerminalOptions ABI layout'); + const out = this.#opaque(), + opts = this.#alloc(terminalLayout.size), + d = this.#view(); + d.setUint16(opts, this.#viewport.columns, true); + d.setUint16(opts + 2, this.#viewport.rows, true); + d.setUint32(opts + 4, 10000, true); + if (this.#e.ghostty_terminal_new(0, out, opts) !== 0) + throw new AssetIntegrityError('ghostty_terminal_new failed'); + this.#terminal = this.#view().getUint32(out, true); + const capability = this.#alloc(1); + try { + this.#kittySupported = + this.#e.ghostty_build_info(2, capability) === 0 && this.#view().getUint8(capability) !== 0; + } finally { + this.#release(capability, 1); + } + if (!this.#kittySupported) + throw new AssetIntegrityError( + 'ghostty-vt.wasm was built without the required Kitty graphics capability', + ); + { + if (!Number.isSafeInteger(storageLimitBytes) || storageLimitBytes < 0) + throw new RangeError('graphics.storageLimitBytes must be a nonnegative safe integer'); + const limit = this.#alloc(8); + try { + this.#view().setBigUint64(limit, BigInt(storageLimitBytes), true); + if (this.#e.ghostty_terminal_set(this.#terminal, 15, limit) !== 0) + throw new AssetIntegrityError('Unable to configure Kitty image storage limit'); + this.#kittyStorageLimit = storageLimitBytes; + } finally { + this.#release(limit, 8); + } + } + if ( + this.#e.ghostty_terminal_resize( + this.#terminal, + this.#viewport.columns, + this.#viewport.rows, + 10, + 20, + ) !== 0 + ) + throw new AssetIntegrityError('Unable to initialize Ghostty cell pixel geometry'); + const fo = this.#opaque(), + o = this.#alloc(40), + v = this.#view(); + v.setUint32(o, 40, true); + v.setUint32(o + 4, 0, true); + v.setUint8(o + 8, 0); + v.setUint8(o + 9, 0); + v.setUint32(o + 12, 24, true); + v.setUint32(o + 24, 12, true); + if (this.#e.ghostty_formatter_terminal_new(0, fo, this.#terminal, o) !== 0) + throw new AssetIntegrityError('ghostty_formatter_terminal_new failed'); + this.#formatter = this.#view().getUint32(fo, true); + const renderStateOut = this.#opaque(); + if (this.#e.ghostty_render_state_new(0, renderStateOut) !== 0) + throw new AssetIntegrityError('ghostty_render_state_new failed'); + this.#renderState = this.#view().getUint32(renderStateOut, true); + this.#rowIteratorHolder = this.#opaque(); + if (this.#e.ghostty_render_state_row_iterator_new(0, this.#rowIteratorHolder) !== 0) + throw new AssetIntegrityError('ghostty_render_state_row_iterator_new failed'); + this.#rowIterator = this.#view().getUint32(this.#rowIteratorHolder, true); + this.#rowCellsHolder = this.#opaque(); + if (this.#e.ghostty_render_state_row_cells_new(0, this.#rowCellsHolder) !== 0) + throw new AssetIntegrityError('ghostty_render_state_row_cells_new failed'); + this.#rowCells = this.#view().getUint32(this.#rowCellsHolder, true); - const keyEncoderOut = this.#opaque(); - if (this.#e.ghostty_key_encoder_new(0, keyEncoderOut) !== 0) - throw new AssetIntegrityError("ghostty_key_encoder_new failed"); - this.#keyEncoder = this.#view().getUint32(keyEncoderOut, true); - const keyEventOut = this.#opaque(); - if (this.#e.ghostty_key_event_new(0, keyEventOut) !== 0) - throw new AssetIntegrityError("ghostty_key_event_new failed"); - this.#keyEvent = this.#view().getUint32(keyEventOut, true); + const keyEncoderOut = this.#opaque(); + if (this.#e.ghostty_key_encoder_new(0, keyEncoderOut) !== 0) + throw new AssetIntegrityError('ghostty_key_encoder_new failed'); + this.#keyEncoder = this.#view().getUint32(keyEncoderOut, true); + const keyEventOut = this.#opaque(); + if (this.#e.ghostty_key_event_new(0, keyEventOut) !== 0) + throw new AssetIntegrityError('ghostty_key_event_new failed'); + this.#keyEvent = this.#view().getUint32(keyEventOut, true); - const mouseEncoderOut = this.#opaque(); - if (this.#e.ghostty_mouse_encoder_new(0, mouseEncoderOut) !== 0) - throw new AssetIntegrityError("ghostty_mouse_encoder_new failed"); - this.#mouseEncoder = this.#view().getUint32(mouseEncoderOut, true); - const mouseEventOut = this.#opaque(); - if (this.#e.ghostty_mouse_event_new(0, mouseEventOut) !== 0) - throw new AssetIntegrityError("ghostty_mouse_event_new failed"); - this.#mouseEvent = this.#view().getUint32(mouseEventOut, true); - this.#configureMouseSize(); - this.#configureEffects(); - this.#lastVisual = this.now(); - } - now() { - return performance.now() - this.#started; - } - write(data: Uint8Array) { - if (!data.length) return; - const p = this.#alloc(data.length); - this.#bytes().set(data, p); - this.#e.ghostty_terminal_vt_write(this.#terminal, p, data.length); - this.#e.ghostty_wasm_free_u8_array(p, data.length); - } - resize(v: Required) { - this.#viewport = v; - if (this.#e.ghostty_terminal_resize(this.#terminal, v.columns, v.rows, 10, 20) !== 0) - throw new Error("Ghostty resize failed"); - this.#configureMouseSize(); - } - #installCallback( - option: number, - parameterCount: number, - callback: (...args: number[]) => number | void, - returnsInt = false, - ) { - const instance = new WebAssembly.Instance(callbackModule(parameterCount, returnsInt), { - env: { callback }, - }), - fn = instance.exports.callback as CallableFunction, - table = this.#e.__indirect_function_table, - index = table.length; - table.grow(1); - table.set(index, fn); - this.#callbackInstances.push(instance); - if (this.#e.ghostty_terminal_set(this.#terminal, option, index) !== 0) - throw new AssetIntegrityError(`Unable to configure Ghostty terminal effect ${option}`); - } - #configureEffects() { - this.#installCallback(1, 4, (_terminal, _userdata, data, length) => { - this.#effects.push({ type: "write-pty", data: this.#bytes().slice(data, data + length) }); - }); - this.#installCallback(2, 2, () => this.#effects.push({ type: "bell" })); - for (const [option, response] of [ - [3, "ghostwright"], - [4, "ghostwright/0.1.0 libghostty-vt/f8041e849b36"], - ] as const) { - const bytes = encoder.encode(response), - data = this.#alloc(bytes.length), - stringLayout = this.#layouts.GhosttyString; - this.#bytes().set(bytes, data); - this.#installCallback(option, 3, (output) => { - const view = this.#view(); - view.setUint32(output + stringLayout.fields.ptr.offset, data, true); - view.setUint32(output + stringLayout.fields.len.offset, bytes.length, true); - }); - } - this.#installCallback(5, 2, () => this.#effects.push({ type: "title" })); - this.#installCallback(25, 2, () => this.#effects.push({ type: "working-directory" })); - this.#installCallback( - 6, - 3, - (_terminal, _userdata, output) => { - const layout = this.#layouts.GhosttySizeReportSize, - field = (name: string) => layout.fields[name].offset, - view = this.#view(); - view.setUint16(output + field("rows"), this.#viewport.rows, true); - view.setUint16(output + field("columns"), this.#viewport.columns, true); - view.setUint32(output + field("cell_width"), 10, true); - view.setUint32(output + field("cell_height"), 20, true); - return 1; - }, - true, - ); - this.#installCallback( - 7, - 3, - (_terminal, _userdata, output) => { - this.#view().setInt32(output, 1, true); - return 1; - }, - true, - ); - this.#installCallback( - 8, - 3, - (_terminal, _userdata, output) => { - const all = this.#layouts.GhosttyDeviceAttributes, - primary = this.#layouts.GhosttyDeviceAttributesPrimary, - secondary = this.#layouts.GhosttyDeviceAttributesSecondary, - tertiary = this.#layouts.GhosttyDeviceAttributesTertiary, - p = output + all.fields.primary.offset, - s = output + all.fields.secondary.offset, - t = output + all.fields.tertiary.offset, - view = this.#view(); - view.setUint16(p + primary.fields.conformance_level.offset, 62, true); - view.setUint16(p + primary.fields.features.offset, 22, true); - view.setUint32(p + primary.fields.num_features.offset, 1, true); - view.setUint16(s + secondary.fields.device_type.offset, 1, true); - view.setUint16(s + secondary.fields.firmware_version.offset, 0, true); - view.setUint16(s + secondary.fields.rom_cartridge.offset, 0, true); - view.setUint32(t + tertiary.fields.unit_id.offset, 0, true); - return 1; - }, - true, - ); - this.#installCallback( - 26, - 3, - (_terminal, _userdata, write) => { - const writeLayout = this.#layouts.GhosttyClipboardWrite, - contentLayout = this.#layouts.GhosttyClipboardContent, - stringLayout = this.#layouts.GhosttyString, - view = this.#view(), - count = view.getUint32(write + writeLayout.fields.contents_len.offset, true), - contents = view.getUint32(write + writeLayout.fields.contents.offset, true); - this.#clipboard = ""; - if (count > 0 && contents) { - const dataString = contents + contentLayout.fields.data.offset, - pointer = view.getUint32(dataString + stringLayout.fields.ptr.offset, true), - length = view.getUint32(dataString + stringLayout.fields.len.offset, true); - this.#clipboard = decoder.decode(this.#bytes().subarray(pointer, pointer + length)); - } - this.#effects.push({ type: "clipboard-write", data: this.#clipboard }); - return 0; - }, - true, - ); - } - takeEffects() { - return this.#effects.splice(0); - } - clipboard() { - return this.#clipboard; - } - #configureMouseSize() { - if (!this.#mouseEncoder) return; - const layout = this.#layouts.GhosttyMouseEncoderSize; - if (!layout) throw new AssetIntegrityError("Missing GhosttyMouseEncoderSize ABI metadata"); - const pointer = this.#alloc(layout.size), - view = this.#view(), - field = (name: string) => layout.fields[name].offset; - view.setUint32(pointer + field("size"), layout.size, true); - view.setUint32(pointer + field("screen_width"), this.#viewport.widthPixels, true); - view.setUint32(pointer + field("screen_height"), this.#viewport.heightPixels, true); - view.setUint32(pointer + field("cell_width"), 10, true); - view.setUint32(pointer + field("cell_height"), 20, true); - for (const name of ["padding_top", "padding_bottom", "padding_right", "padding_left"]) - view.setUint32(pointer + field(name), 0, true); - this.#e.ghostty_mouse_encoder_setopt(this.#mouseEncoder, 2, pointer); - this.#release(pointer, layout.size); - } - #get(kind: number, size = 4) { - const p = this.#alloc(size); - try { - this.#e.ghostty_terminal_get(this.#terminal, kind, p); - return size === 1 - ? this.#view().getUint8(p) - : size === 2 - ? this.#view().getUint16(p, true) - : this.#view().getUint32(p, true); - } finally { - this.#release(p, size); - } - } - mode(n: number) { - const p = this.#alloc(1); - try { - return ( - this.#e.ghostty_terminal_mode_get(this.#terminal, n, p) === 0 && - this.#view().getUint8(p) !== 0 - ); - } finally { - this.#release(p, 1); - } - } - text() { - const lp = this.#alloc(4); - let p = 0, - n = 0; - try { - this.#e.ghostty_formatter_format_buf(this.#formatter, 0, 0, lp); - n = this.#view().getUint32(lp, true); - if (!n) return ""; - p = this.#alloc(n); - if (this.#e.ghostty_formatter_format_buf(this.#formatter, p, n, lp) !== 0) return ""; - return decoder.decode(this.#bytes().slice(p, p + n)); - } finally { - if (p) this.#release(p, n); - this.#release(lp, 4); - } - } - modes(): TerminalModes { - const tracking = this.mode(1003) - ? "any" - : this.mode(1002) - ? "button" - : this.mode(1000) - ? "normal" - : this.mode(9) - ? "x10" - : "none"; - const format = this.mode(1016) - ? "sgr-pixels" - : this.mode(1006) - ? "sgr" - : this.mode(1015) - ? "urxvt" - : this.mode(1005) - ? "utf8" - : "x10"; - const privateModeNumbers = [ - 1, 9, 25, 47, 66, 67, 1000, 1002, 1003, 1004, 1005, 1006, 1015, 1016, 1047, 1049, 2004, - ], - privateModes = Object.freeze( - Object.fromEntries(privateModeNumbers.map((mode) => [mode, this.mode(mode)])), - ); - return { - applicationCursorKeys: this.mode(1), - backarrowSendsBackspace: this.mode(67), - bracketedPaste: this.mode(2004), - focusReporting: this.mode(1004), - mouseTracking: tracking, - mouseFormat: format, - kittyKeyboardFlags: this.#get(8, 1), - alternateScreen: this.#get(6) === 1, - privateModes, - }; - } - #renderGet(kind: number, size = 4) { - const pointer = this.#alloc(size); - try { - if (this.#e.ghostty_render_state_get(this.#renderState, kind, pointer) !== 0) return 0; - return size === 1 - ? this.#view().getUint8(pointer) - : size === 2 - ? this.#view().getUint16(pointer, true) - : this.#view().getUint32(pointer, true); - } finally { - this.#release(pointer, size); - } - } - #styleFromPointer(stylePointer: number): CellStyle { - const styleLayout = this.#layouts.GhosttyStyle, - view = this.#view(); - return Object.freeze({ - bold: view.getUint8(stylePointer + styleLayout.fields.bold.offset) !== 0, - italic: view.getUint8(stylePointer + styleLayout.fields.italic.offset) !== 0, - faint: view.getUint8(stylePointer + styleLayout.fields.faint.offset) !== 0, - blink: view.getUint8(stylePointer + styleLayout.fields.blink.offset) !== 0, - inverse: view.getUint8(stylePointer + styleLayout.fields.inverse.offset) !== 0, - invisible: view.getUint8(stylePointer + styleLayout.fields.invisible.offset) !== 0, - strikethrough: view.getUint8(stylePointer + styleLayout.fields.strikethrough.offset) !== 0, - overline: view.getUint8(stylePointer + styleLayout.fields.overline.offset) !== 0, - underline: view.getInt32(stylePointer + styleLayout.fields.underline.offset, true), - foreground: this.#color(stylePointer, "fg_color"), - background: this.#color(stylePointer, "bg_color"), - underlineColor: this.#color(stylePointer, "underline_color"), - }); - } - #terminalString(kind: number) { - const layout = this.#layouts.GhosttyString, - pointer = this.#alloc(layout.size); - try { - if (this.#e.ghostty_terminal_get(this.#terminal, kind, pointer) !== 0) return ""; - const view = this.#view(), - data = view.getUint32(pointer + layout.fields.ptr.offset, true), - length = view.getUint32(pointer + layout.fields.len.offset, true); - return length ? decoder.decode(this.#bytes().subarray(data, data + length)) : ""; - } finally { - this.#release(pointer, layout.size); - } - } - #color(stylePointer: number, fieldName: "fg_color" | "bg_color" | "underline_color") { - const style = this.#layouts.GhosttyStyle, - color = this.#layouts.GhosttyStyleColor, - base = stylePointer + style.fields[fieldName].offset, - tag = this.#view().getInt32(base + color.fields.tag.offset, true), - value = base + color.fields.value.offset; - if (tag === 1) return { kind: "palette" as const, index: this.#view().getUint8(value) }; - if (tag === 2) - return { - kind: "rgb" as const, - red: this.#view().getUint8(value), - green: this.#view().getUint8(value + 1), - blue: this.#view().getUint8(value + 2), - }; - return { kind: "default" as const }; - } - scrollbackRows() { - return this.#get(15); - } - /** Copies a bounded oldest-based scrollback range without moving Ghostty's viewport. */ - history(start: number, count: number): readonly HistoryRow[] { - const total = this.#get(14), - available = this.#get(15), - end = Math.min(available, start + count), - pointLayout = this.#layouts.GhosttyPoint, - coordinate = this.#layouts.GhosttyPointCoordinate, - refLayout = this.#layouts.GhosttyGridRef, - styleLayout = this.#layouts.GhosttyStyle, - point = this.#alloc(pointLayout.size), - ref = this.#alloc(refLayout.size), - rawCell = this.#alloc(8), - rawRow = this.#alloc(8), - value = this.#alloc(8), - style = this.#alloc(styleLayout.size), - graphemes = this.#alloc(256), - length = this.#alloc(4), - result: HistoryRow[] = []; - // `total` is deliberately read even though `available` determines the range: - // it exercises Ghostty's documented total/scrollback coordinate contract. - void total; - try { - const view = this.#view(), - pointValue = point + pointLayout.fields.value.offset; - view.setInt32(point + pointLayout.fields.tag.offset, 3, true); // HISTORY - for (let row = start; row < end; row++) { - const cells: ScreenCell[] = []; - view.setUint32(pointValue + coordinate.fields.y.offset, row, true); - view.setUint16(pointValue + coordinate.fields.x.offset, 0, true); - view.setUint32(ref + refLayout.fields.size.offset, refLayout.size, true); - let wrapped = false, - kittyVirtualPlaceholder = false; - if (this.#e.ghostty_terminal_grid_ref(this.#terminal, point, ref) === 0) { - this.#e.ghostty_grid_ref_row(ref, rawRow); - const row = view.getBigUint64(rawRow, true); - this.#e.ghostty_row_get(row, 1, value); - wrapped = view.getUint8(value) !== 0; - this.#e.ghostty_row_get(row, 7, value); - kittyVirtualPlaceholder = view.getUint8(value) !== 0; - } - for (let column = 0; column < this.#viewport.columns; column++) { - view.setUint16(pointValue + coordinate.fields.x.offset, column, true); - view.setUint32(ref + refLayout.fields.size.offset, refLayout.size, true); - let text = "", - width: 0 | 1 | 2 = 1, - continuation = false, - cellStyle = defaultStyle, - hyperlink: string | undefined; - if (this.#e.ghostty_terminal_grid_ref(this.#terminal, point, ref) === 0) { - this.#e.ghostty_grid_ref_cell(ref, rawCell); - const cell = view.getBigUint64(rawCell, true); - this.#e.ghostty_cell_get(cell, 3, value); - const wide = view.getInt32(value, true); - continuation = wide === 2 || wide === 3; - width = continuation ? 0 : wide === 1 ? 2 : 1; - view.setUint32(length, 0, true); - if (this.#e.ghostty_grid_ref_graphemes(ref, graphemes, 64, length) === 0) { - text = Array.from({ length: view.getUint32(length, true) }, (_, index) => - String.fromCodePoint(view.getUint32(graphemes + index * 4, true)), - ).join(""); - } - view.setUint32(style + styleLayout.fields.size.offset, styleLayout.size, true); - if (this.#e.ghostty_grid_ref_style(ref, style) === 0) - cellStyle = this.#styleFromPointer(style); - view.setUint32(length, 0, true); - if (this.#e.ghostty_grid_ref_hyperlink_uri(ref, 0, 0, length) !== 0) { - const hyperlinkLength = view.getUint32(length, true); - if (hyperlinkLength) { - const buffer = this.#alloc(hyperlinkLength); - try { - if ( - this.#e.ghostty_grid_ref_hyperlink_uri(ref, buffer, hyperlinkLength, length) === - 0 - ) - hyperlink = decoder.decode( - this.#bytes().subarray(buffer, buffer + hyperlinkLength), - ); - } finally { - this.#release(buffer, hyperlinkLength); - } - } - } - } - cells.push( - Object.freeze({ - column, - text, - width, - continuation, - style: cellStyle, - selected: false, - ...(hyperlink ? { hyperlink } : {}), - }), - ); - } - const text = cells - .map((cell) => (cell.continuation ? "" : cell.style.invisible ? " " : cell.text || " ")) - .join(""); - result.push( - Object.freeze({ - line: Object.freeze({ row, cells: Object.freeze(cells), wrapped, text }), - kittyVirtualPlaceholder, - }), - ); - } - return Object.freeze(result); - } finally { - for (const [pointer, size] of [ - [point, pointLayout.size], - [ref, refLayout.size], - [rawCell, 8], - [rawRow, 8], - [value, 8], - [style, styleLayout.size], - [graphemes, 256], - [length, 4], - ] as const) - this.#release(pointer, size); - } - } - #kittyImage(graphics: number, id: number): KittyImageSnapshot | undefined { - if (!this.#kittySupported) return undefined; - const image = this.#e.ghostty_kitty_graphics_image(graphics, id); - if (!image) return undefined; - const output = this.#alloc(8); - const getU32 = (kind: number) => { - if (this.#e.ghostty_kitty_graphics_image_get(image, kind, output) !== 0) return 0; - return this.#view().getUint32(output, true); - }; - try { - const imageId = getU32(1), - number = getU32(2), - width = getU32(3), - height = getU32(4), - formatValue = getU32(5), - compression = getU32(6), - dataPointer = getU32(7), - dataLength = getU32(8); - if (compression !== 0 || dataLength > this.#kittyStorageLimit) - throw new AssetIntegrityError("Invalid Kitty decoded image storage metadata"); - this.#e.ghostty_kitty_graphics_image_get(image, 9, output); - const generation = this.#view().getBigUint64(output, true).toString(), - key = `${imageId}:${generation}`, - cached = this.#images.get(key); - if (cached) return cached; - const data = this.#bytes().slice(dataPointer, dataPointer + dataLength), - format = (["rgb", "rgba", "png", "gray-alpha", "gray"] as const)[formatValue]; - if (!format || format === "png") - throw new AssetIntegrityError("Invalid Kitty decoded image format"); - const snapshot = freeze({ - id: imageId, - number, - generation, - width, - height, - format, - compression: "none" as const, - dataLength, - sha256: createHash("sha256").update(data).digest("hex"), - }); - this.#images.set(key, snapshot); - return snapshot; - } finally { - this.#release(output, 8); - } - } - #pruneKittyImages() { - for (const key of this.#images.keys()) - if (!this.#currentImageKeys.has(key)) this.#images.delete(key); - } - graphics(): KittyGraphicsSnapshot { - if (!this.#kittySupported) - return freeze({ supported: false, generation: "0", storageLimitBytes: 0, placements: [] }); - const graphicsOutput = this.#alloc(4); - try { - if (this.#e.ghostty_terminal_get(this.#terminal, 30, graphicsOutput) !== 0) - throw new AssetIntegrityError("Kitty graphics capability disappeared from the terminal"); - const graphics = this.#view().getUint32(graphicsOutput, true), - generationOutput = this.#alloc(8); - try { - if (this.#e.ghostty_kitty_graphics_get(graphics, 2, generationOutput) !== 0) - throw new AssetIntegrityError("Unable to read Kitty graphics generation"); - const generation = this.#view().getBigUint64(generationOutput, true).toString(), - iteratorOutput = this.#alloc(4), - placements: KittyPlacementSnapshot[] = []; - try { - if (this.#e.ghostty_kitty_graphics_placement_iterator_new(0, iteratorOutput) !== 0) - throw new AssetIntegrityError("Unable to allocate Kitty placement iterator"); - const iterator = this.#view().getUint32(iteratorOutput, true), - value = this.#alloc(4), - render = this.#alloc(48); - try { - if (this.#e.ghostty_kitty_graphics_get(graphics, 1, iteratorOutput) !== 0) - throw new AssetIntegrityError("Unable to initialize Kitty placement iterator"); - while (this.#e.ghostty_kitty_graphics_placement_next(iterator)) { - const get = (kind: number, signed = false) => { - if (this.#e.ghostty_kitty_graphics_placement_get(iterator, kind, value) !== 0) - throw new AssetIntegrityError(`Unable to read Kitty placement field ${kind}`); - return signed - ? this.#view().getInt32(value, true) - : this.#view().getUint32(value, true); - }; - const imageId = get(1), - virtual = get(3) !== 0, - image = this.#kittyImage(graphics, imageId); - if (!image) continue; - const z = get(12, true), - requestedSource = { - x: get(6), - y: get(7), - width: get(8), - height: get(9), - }, - imageHandle = this.#e.ghostty_kitty_graphics_image(graphics, imageId); - let renderInfoAvailable = false; - if (!virtual && imageHandle) { - this.#view().setUint32(render, 48, true); - // Geometry can be unavailable for an individual placement (for - // example, after an unsupported protocol edge case). It is - // observational state, not an artifact-integrity failure. - renderInfoAvailable = - this.#e.ghostty_kitty_graphics_placement_render_info( - iterator, - imageHandle, - this.#terminal, - render, - ) === 0; - } - const visible = renderInfoAvailable && this.#view().getUint8(render + 28) !== 0, - placement = freeze({ - imageId, - placementId: get(2), - imageGeneration: image.generation, - image, - virtual, - z, - layer: - z < -1_073_741_824 - ? ("below-background" as const) - : z < 0 - ? ("below-text" as const) - : ("above-text" as const), - offset: { xPixels: get(4), yPixels: get(5) }, - requestedGrid: { columns: get(10), rows: get(11) }, - ...(renderInfoAvailable - ? { - renderedGrid: { - columns: this.#view().getUint32(render + 12, true), - rows: this.#view().getUint32(render + 16, true), - }, - renderedPixels: { - width: this.#view().getUint32(render + 4, true), - height: this.#view().getUint32(render + 8, true), - }, - } - : {}), - viewport: visible - ? { - column: this.#view().getInt32(render + 20, true), - row: this.#view().getInt32(render + 24, true), - visible, - } - : { visible: false }, - source: renderInfoAvailable - ? { - x: this.#view().getUint32(render + 32, true), - y: this.#view().getUint32(render + 36, true), - width: this.#view().getUint32(render + 40, true), - height: this.#view().getUint32(render + 44, true), - } - : requestedSource, - } satisfies KittyPlacementSnapshot); - placements.push(placement); - } - } finally { - this.#e.ghostty_kitty_graphics_placement_iterator_free(iterator); - this.#release(value, 4); - this.#release(render, 48); - } - } finally { - this.#release(iteratorOutput, 4); - } - const currentImageKeys = new Set( - placements.map((placement) => `${placement.imageId}:${placement.imageGeneration}`), - ); - // Placement iteration cannot enumerate unplaced images. Revalidate - // explicitly inspected IDs against the active Ghostty storage so they - // remain cache-visible until deleted or replaced. - for (const id of this.#inspectedImageIds) { - const image = this.#kittyImage(graphics, id); - if (image) currentImageKeys.add(`${image.id}:${image.generation}`); - else this.#inspectedImageIds.delete(id); - } - this.#currentImageKeys = currentImageKeys; - this.#pruneKittyImages(); - return freeze({ - supported: true, - generation, - storageLimitBytes: this.#kittyStorageLimit, - placements: Object.freeze(placements), - }); - } finally { - this.#release(generationOutput, 8); - } - } finally { - this.#release(graphicsOutput, 4); - } - } - inspectImage(id: number) { - const graphics = this.#alloc(4); - try { - if (!this.#kittySupported || this.#e.ghostty_terminal_get(this.#terminal, 30, graphics) !== 0) - return undefined; - const image = this.#kittyImage(this.#view().getUint32(graphics, true), id); - if (image) { - this.#inspectedImageIds.add(id); - this.#currentImageKeys.add(`${image.id}:${image.generation}`); - } - this.#pruneKittyImages(); - return image; - } finally { - this.#release(graphics, 4); - } - } - copyImageData(id: number) { - const graphics = this.#alloc(4); - try { - if (!this.#kittySupported || this.#e.ghostty_terminal_get(this.#terminal, 30, graphics) !== 0) - return undefined; - const image = this.inspectImage(id); - if (!image) return undefined; - const handle = this.#e.ghostty_kitty_graphics_image( - this.#view().getUint32(graphics, true), - id, - ); - if (!handle) return undefined; - const output = this.#alloc(8); - try { - if ( - this.#e.ghostty_kitty_graphics_image_get(handle, 7, output) !== 0 || - this.#e.ghostty_kitty_graphics_image_get(handle, 8, output + 4) !== 0 - ) - return undefined; - const pointer = this.#view().getUint32(output, true), - length = this.#view().getUint32(output + 4, true); - return this.#bytes().slice(pointer, pointer + length); - } finally { - this.#release(output, 8); - } - } finally { - this.#release(graphics, 4); - } - } - cachedImage(id: number) { - return [...this.#images.entries()].find( - ([key, image]) => image.id === id && this.#currentImageKeys.has(key), - )?.[1]; - } - snapshot(cause?: "pty-output" | "resize" | "reset") { - const pointLayout = this.#layouts.GhosttyPoint, - coordinateLayout = this.#layouts.GhosttyPointCoordinate, - refLayout = this.#layouts.GhosttyGridRef, - styleLayout = this.#layouts.GhosttyStyle, - bufferLayout = this.#layouts.GhosttyBuffer, - point = this.#alloc(pointLayout.size), - ref = this.#alloc(refLayout.size), - rawCell = this.#alloc(8), - rawRow = this.#alloc(8), - style = this.#alloc(styleLayout.size), - value = this.#alloc(8), - textBuffer = this.#alloc(4096), - buffer = this.#alloc(bufferLayout.size), - length = this.#alloc(4), - rowKeys = this.#alloc(16), - rowValues = this.#alloc(16), - cellKeys = this.#alloc(12), - cellValues = this.#alloc(12), - contentTagValue = this.#alloc(4), - wideValuePointer = this.#alloc(4), - hyperlinkValue = this.#alloc(4), - lines: ScreenLine[] = []; - this.#e.ghostty_render_state_update(this.#renderState, this.#terminal); - try { - const view = this.#view(), - pointValueOffset = pointLayout.fields.value.offset; - view.setInt32(point + pointLayout.fields.tag.offset, 1, true); - for (const [index, key] of [1, 2, 7, 9].entries()) - view.setInt32(rowKeys + index * 4, key, true); - for (const [index, pointer] of [rawCell, style, value, buffer].entries()) - view.setUint32(rowValues + index * 4, pointer, true); - for (const [index, key] of [2, 3, 7].entries()) - view.setInt32(cellKeys + index * 4, key, true); - for (const [index, pointer] of [contentTagValue, wideValuePointer, hyperlinkValue].entries()) - view.setUint32(cellValues + index * 4, pointer, true); - if (this.#e.ghostty_render_state_get(this.#renderState, 4, this.#rowIteratorHolder) !== 0) - throw new Error("Unable to initialize Ghostty render row iterator"); - for (let row = 0; row < this.#viewport.rows; row++) { - const cells: ScreenCell[] = []; - if (!this.#e.ghostty_render_state_row_iterator_next(this.#rowIterator)) break; - if (this.#e.ghostty_render_state_row_get(this.#rowIterator, 3, this.#rowCellsHolder) !== 0) - throw new Error(`Unable to read Ghostty render row ${row}`); - this.#e.ghostty_render_state_row_get(this.#rowIterator, 2, rawRow); - const rowValue = view.getBigUint64(rawRow, true); - this.#e.ghostty_row_get(rowValue, 1, value); - const wrapped = view.getUint8(value) !== 0; - for (let column = 0; column < this.#viewport.columns; column++) { - if (!this.#e.ghostty_render_state_row_cells_next(this.#rowCells)) break; - view.setUint32(buffer + bufferLayout.fields.ptr.offset, textBuffer, true); - view.setUint32(buffer + bufferLayout.fields.cap.offset, 4096, true); - view.setUint32(buffer + bufferLayout.fields.len.offset, 0, true); - if ( - this.#e.ghostty_render_state_row_cells_get_multi( - this.#rowCells, - 4, - rowKeys, - rowValues, - length, - ) !== 0 - ) - throw new Error(`Unable to read Ghostty render cell ${column},${row}`); - const cellValue = view.getBigUint64(rawCell, true); - if (this.#e.ghostty_cell_get_multi(cellValue, 3, cellKeys, cellValues, length) !== 0) - throw new Error(`Unable to decode Ghostty cell ${column},${row}`); - const wideValue = view.getInt32(wideValuePointer, true), - continuation = wideValue === 2 || wideValue === 3, - width: 0 | 1 | 2 = continuation ? 0 : wideValue === 1 ? 2 : 1, - textLength = view.getUint32(buffer + bufferLayout.fields.len.offset, true), - text = - textLength > 0 - ? decoder.decode(this.#bytes().subarray(textBuffer, textBuffer + textLength)) - : ""; + const mouseEncoderOut = this.#opaque(); + if (this.#e.ghostty_mouse_encoder_new(0, mouseEncoderOut) !== 0) + throw new AssetIntegrityError('ghostty_mouse_encoder_new failed'); + this.#mouseEncoder = this.#view().getUint32(mouseEncoderOut, true); + const mouseEventOut = this.#opaque(); + if (this.#e.ghostty_mouse_event_new(0, mouseEventOut) !== 0) + throw new AssetIntegrityError('ghostty_mouse_event_new failed'); + this.#mouseEvent = this.#view().getUint32(mouseEventOut, true); + this.#configureMouseSize(); + this.#configureEffects(); + this.#lastVisual = this.now(); + } + now() { + return performance.now() - this.#started; + } + write(data: Uint8Array) { + if (!data.length) return; + const p = this.#alloc(data.length); + this.#bytes().set(data, p); + this.#e.ghostty_terminal_vt_write(this.#terminal, p, data.length); + this.#e.ghostty_wasm_free_u8_array(p, data.length); + } + resize(v: Required) { + this.#viewport = v; + if (this.#e.ghostty_terminal_resize(this.#terminal, v.columns, v.rows, 10, 20) !== 0) + throw new Error('Ghostty resize failed'); + this.#configureMouseSize(); + } + #installCallback( + option: number, + parameterCount: number, + callback: (...args: number[]) => number | void, + returnsInt = false, + ) { + const instance = new WebAssembly.Instance(callbackModule(parameterCount, returnsInt), { + env: { callback }, + }), + fn = instance.exports.callback as CallableFunction, + table = this.#e.__indirect_function_table, + index = table.length; + table.grow(1); + table.set(index, fn); + this.#callbackInstances.push(instance); + if (this.#e.ghostty_terminal_set(this.#terminal, option, index) !== 0) + throw new AssetIntegrityError(`Unable to configure Ghostty terminal effect ${option}`); + } + #configureEffects() { + this.#installCallback(1, 4, (_terminal, _userdata, data, length) => { + this.#effects.push({ type: 'write-pty', data: this.#bytes().slice(data, data + length) }); + }); + this.#installCallback(2, 2, () => this.#effects.push({ type: 'bell' })); + for (const [option, response] of [ + [3, 'ghostwright'], + [4, 'ghostwright/0.1.0 libghostty-vt/f8041e849b36'], + ] as const) { + const bytes = encoder.encode(response), + data = this.#alloc(bytes.length), + stringLayout = this.#layouts.GhosttyString; + this.#bytes().set(bytes, data); + this.#installCallback(option, 3, (output) => { + const view = this.#view(); + view.setUint32(output + stringLayout.fields.ptr.offset, data, true); + view.setUint32(output + stringLayout.fields.len.offset, bytes.length, true); + }); + } + this.#installCallback(5, 2, () => this.#effects.push({ type: 'title' })); + this.#installCallback(25, 2, () => this.#effects.push({ type: 'working-directory' })); + this.#installCallback( + 6, + 3, + (_terminal, _userdata, output) => { + const layout = this.#layouts.GhosttySizeReportSize, + field = (name: string) => layout.fields[name].offset, + view = this.#view(); + view.setUint16(output + field('rows'), this.#viewport.rows, true); + view.setUint16(output + field('columns'), this.#viewport.columns, true); + view.setUint32(output + field('cell_width'), 10, true); + view.setUint32(output + field('cell_height'), 20, true); + return 1; + }, + true, + ); + this.#installCallback( + 7, + 3, + (_terminal, _userdata, output) => { + this.#view().setInt32(output, 1, true); + return 1; + }, + true, + ); + this.#installCallback( + 8, + 3, + (_terminal, _userdata, output) => { + const all = this.#layouts.GhosttyDeviceAttributes, + primary = this.#layouts.GhosttyDeviceAttributesPrimary, + secondary = this.#layouts.GhosttyDeviceAttributesSecondary, + tertiary = this.#layouts.GhosttyDeviceAttributesTertiary, + p = output + all.fields.primary.offset, + s = output + all.fields.secondary.offset, + t = output + all.fields.tertiary.offset, + view = this.#view(); + view.setUint16(p + primary.fields.conformance_level.offset, 62, true); + view.setUint16(p + primary.fields.features.offset, 22, true); + view.setUint32(p + primary.fields.num_features.offset, 1, true); + view.setUint16(s + secondary.fields.device_type.offset, 1, true); + view.setUint16(s + secondary.fields.firmware_version.offset, 0, true); + view.setUint16(s + secondary.fields.rom_cartridge.offset, 0, true); + view.setUint32(t + tertiary.fields.unit_id.offset, 0, true); + return 1; + }, + true, + ); + this.#installCallback( + 26, + 3, + (_terminal, _userdata, write) => { + const writeLayout = this.#layouts.GhosttyClipboardWrite, + contentLayout = this.#layouts.GhosttyClipboardContent, + stringLayout = this.#layouts.GhosttyString, + view = this.#view(), + count = view.getUint32(write + writeLayout.fields.contents_len.offset, true), + contents = view.getUint32(write + writeLayout.fields.contents.offset, true); + this.#clipboard = ''; + if (count > 0 && contents) { + const dataString = contents + contentLayout.fields.data.offset, + pointer = view.getUint32(dataString + stringLayout.fields.ptr.offset, true), + length = view.getUint32(dataString + stringLayout.fields.len.offset, true); + this.#clipboard = decoder.decode(this.#bytes().subarray(pointer, pointer + length)); + } + this.#effects.push({ type: 'clipboard-write', data: this.#clipboard }); + return 0; + }, + true, + ); + } + takeEffects() { + return this.#effects.splice(0); + } + clipboard() { + return this.#clipboard; + } + #configureMouseSize() { + if (!this.#mouseEncoder) return; + const layout = this.#layouts.GhosttyMouseEncoderSize; + if (!layout) throw new AssetIntegrityError('Missing GhosttyMouseEncoderSize ABI metadata'); + const pointer = this.#alloc(layout.size), + view = this.#view(), + field = (name: string) => layout.fields[name].offset; + view.setUint32(pointer + field('size'), layout.size, true); + view.setUint32(pointer + field('screen_width'), this.#viewport.widthPixels, true); + view.setUint32(pointer + field('screen_height'), this.#viewport.heightPixels, true); + view.setUint32(pointer + field('cell_width'), 10, true); + view.setUint32(pointer + field('cell_height'), 20, true); + for (const name of ['padding_top', 'padding_bottom', 'padding_right', 'padding_left']) + view.setUint32(pointer + field(name), 0, true); + this.#e.ghostty_mouse_encoder_setopt(this.#mouseEncoder, 2, pointer); + this.#release(pointer, layout.size); + } + #get(kind: number, size = 4) { + const p = this.#alloc(size); + try { + this.#e.ghostty_terminal_get(this.#terminal, kind, p); + return size === 1 + ? this.#view().getUint8(p) + : size === 2 + ? this.#view().getUint16(p, true) + : this.#view().getUint32(p, true); + } finally { + this.#release(p, size); + } + } + mode(n: number) { + const p = this.#alloc(1); + try { + return ( + this.#e.ghostty_terminal_mode_get(this.#terminal, n, p) === 0 && + this.#view().getUint8(p) !== 0 + ); + } finally { + this.#release(p, 1); + } + } + text() { + const lp = this.#alloc(4); + let p = 0, + n = 0; + try { + this.#e.ghostty_formatter_format_buf(this.#formatter, 0, 0, lp); + n = this.#view().getUint32(lp, true); + if (!n) return ''; + p = this.#alloc(n); + if (this.#e.ghostty_formatter_format_buf(this.#formatter, p, n, lp) !== 0) return ''; + return decoder.decode(this.#bytes().slice(p, p + n)); + } finally { + if (p) this.#release(p, n); + this.#release(lp, 4); + } + } + modes(): TerminalModes { + const tracking = this.mode(1003) + ? 'any' + : this.mode(1002) + ? 'button' + : this.mode(1000) + ? 'normal' + : this.mode(9) + ? 'x10' + : 'none'; + const format = this.mode(1016) + ? 'sgr-pixels' + : this.mode(1006) + ? 'sgr' + : this.mode(1015) + ? 'urxvt' + : this.mode(1005) + ? 'utf8' + : 'x10'; + const privateModeNumbers = [ + 1, 9, 25, 47, 66, 67, 1000, 1002, 1003, 1004, 1005, 1006, 1015, 1016, 1047, 1049, 2004, + ], + privateModes = Object.freeze( + Object.fromEntries(privateModeNumbers.map((mode) => [mode, this.mode(mode)])), + ); + return { + applicationCursorKeys: this.mode(1), + backarrowSendsBackspace: this.mode(67), + bracketedPaste: this.mode(2004), + focusReporting: this.mode(1004), + mouseTracking: tracking, + mouseFormat: format, + kittyKeyboardFlags: this.#get(8, 1), + alternateScreen: this.#get(6) === 1, + privateModes, + }; + } + #renderGet(kind: number, size = 4) { + const pointer = this.#alloc(size); + try { + if (this.#e.ghostty_render_state_get(this.#renderState, kind, pointer) !== 0) return 0; + return size === 1 + ? this.#view().getUint8(pointer) + : size === 2 + ? this.#view().getUint16(pointer, true) + : this.#view().getUint32(pointer, true); + } finally { + this.#release(pointer, size); + } + } + #styleFromPointer(stylePointer: number): CellStyle { + const styleLayout = this.#layouts.GhosttyStyle, + view = this.#view(); + return Object.freeze({ + bold: view.getUint8(stylePointer + styleLayout.fields.bold.offset) !== 0, + italic: view.getUint8(stylePointer + styleLayout.fields.italic.offset) !== 0, + faint: view.getUint8(stylePointer + styleLayout.fields.faint.offset) !== 0, + blink: view.getUint8(stylePointer + styleLayout.fields.blink.offset) !== 0, + inverse: view.getUint8(stylePointer + styleLayout.fields.inverse.offset) !== 0, + invisible: view.getUint8(stylePointer + styleLayout.fields.invisible.offset) !== 0, + strikethrough: view.getUint8(stylePointer + styleLayout.fields.strikethrough.offset) !== 0, + overline: view.getUint8(stylePointer + styleLayout.fields.overline.offset) !== 0, + underline: view.getInt32(stylePointer + styleLayout.fields.underline.offset, true), + foreground: this.#color(stylePointer, 'fg_color'), + background: this.#color(stylePointer, 'bg_color'), + underlineColor: this.#color(stylePointer, 'underline_color'), + }); + } + #terminalString(kind: number) { + const layout = this.#layouts.GhosttyString, + pointer = this.#alloc(layout.size); + try { + if (this.#e.ghostty_terminal_get(this.#terminal, kind, pointer) !== 0) return ''; + const view = this.#view(), + data = view.getUint32(pointer + layout.fields.ptr.offset, true), + length = view.getUint32(pointer + layout.fields.len.offset, true); + return length ? decoder.decode(this.#bytes().subarray(data, data + length)) : ''; + } finally { + this.#release(pointer, layout.size); + } + } + #color(stylePointer: number, fieldName: 'fg_color' | 'bg_color' | 'underline_color') { + const style = this.#layouts.GhosttyStyle, + color = this.#layouts.GhosttyStyleColor, + base = stylePointer + style.fields[fieldName].offset, + tag = this.#view().getInt32(base + color.fields.tag.offset, true), + value = base + color.fields.value.offset; + if (tag === 1) return { kind: 'palette' as const, index: this.#view().getUint8(value) }; + if (tag === 2) + return { + kind: 'rgb' as const, + red: this.#view().getUint8(value), + green: this.#view().getUint8(value + 1), + blue: this.#view().getUint8(value + 2), + }; + return { kind: 'default' as const }; + } + scrollbackRows() { + return this.#get(15); + } + /** Copies a bounded oldest-based scrollback range without moving Ghostty's viewport. */ + history(start: number, count: number): readonly HistoryRow[] { + const total = this.#get(14), + available = this.#get(15), + end = Math.min(available, start + count), + pointLayout = this.#layouts.GhosttyPoint, + coordinate = this.#layouts.GhosttyPointCoordinate, + refLayout = this.#layouts.GhosttyGridRef, + styleLayout = this.#layouts.GhosttyStyle, + point = this.#alloc(pointLayout.size), + ref = this.#alloc(refLayout.size), + rawCell = this.#alloc(8), + rawRow = this.#alloc(8), + value = this.#alloc(8), + style = this.#alloc(styleLayout.size), + graphemes = this.#alloc(256), + length = this.#alloc(4), + result: HistoryRow[] = []; + // `total` is deliberately read even though `available` determines the range: + // it exercises Ghostty's documented total/scrollback coordinate contract. + void total; + try { + const view = this.#view(), + pointValue = point + pointLayout.fields.value.offset; + view.setInt32(point + pointLayout.fields.tag.offset, 3, true); // HISTORY + for (let row = start; row < end; row++) { + const cells: ScreenCell[] = []; + view.setUint32(pointValue + coordinate.fields.y.offset, row, true); + view.setUint16(pointValue + coordinate.fields.x.offset, 0, true); + view.setUint32(ref + refLayout.fields.size.offset, refLayout.size, true); + let wrapped = false, + kittyVirtualPlaceholder = false; + if (this.#e.ghostty_terminal_grid_ref(this.#terminal, point, ref) === 0) { + this.#e.ghostty_grid_ref_row(ref, rawRow); + const row = view.getBigUint64(rawRow, true); + this.#e.ghostty_row_get(row, 1, value); + wrapped = view.getUint8(value) !== 0; + this.#e.ghostty_row_get(row, 7, value); + kittyVirtualPlaceholder = view.getUint8(value) !== 0; + } + for (let column = 0; column < this.#viewport.columns; column++) { + view.setUint16(pointValue + coordinate.fields.x.offset, column, true); + view.setUint32(ref + refLayout.fields.size.offset, refLayout.size, true); + let text = '', + width: 0 | 1 | 2 = 1, + continuation = false, + cellStyle = defaultStyle, + hyperlink: string | undefined; + if (this.#e.ghostty_terminal_grid_ref(this.#terminal, point, ref) === 0) { + this.#e.ghostty_grid_ref_cell(ref, rawCell); + const cell = view.getBigUint64(rawCell, true); + this.#e.ghostty_cell_get(cell, 3, value); + const wide = view.getInt32(value, true); + continuation = wide === 2 || wide === 3; + width = continuation ? 0 : wide === 1 ? 2 : 1; + view.setUint32(length, 0, true); + if (this.#e.ghostty_grid_ref_graphemes(ref, graphemes, 64, length) === 0) { + text = Array.from({ length: view.getUint32(length, true) }, (_, index) => + String.fromCodePoint(view.getUint32(graphemes + index * 4, true)), + ).join(''); + } + view.setUint32(style + styleLayout.fields.size.offset, styleLayout.size, true); + if (this.#e.ghostty_grid_ref_style(ref, style) === 0) + cellStyle = this.#styleFromPointer(style); + view.setUint32(length, 0, true); + if (this.#e.ghostty_grid_ref_hyperlink_uri(ref, 0, 0, length) !== 0) { + const hyperlinkLength = view.getUint32(length, true); + if (hyperlinkLength) { + const buffer = this.#alloc(hyperlinkLength); + try { + if ( + this.#e.ghostty_grid_ref_hyperlink_uri(ref, buffer, hyperlinkLength, length) === + 0 + ) + hyperlink = decoder.decode( + this.#bytes().subarray(buffer, buffer + hyperlinkLength), + ); + } finally { + this.#release(buffer, hyperlinkLength); + } + } + } + } + cells.push( + Object.freeze({ + column, + text, + width, + continuation, + style: cellStyle, + selected: false, + ...(hyperlink ? { hyperlink } : {}), + }), + ); + } + const text = cells + .map((cell) => (cell.continuation ? '' : cell.style.invisible ? ' ' : cell.text || ' ')) + .join(''); + result.push( + Object.freeze({ + line: Object.freeze({ row, cells: Object.freeze(cells), wrapped, text }), + kittyVirtualPlaceholder, + }), + ); + } + return Object.freeze(result); + } finally { + for (const [pointer, size] of [ + [point, pointLayout.size], + [ref, refLayout.size], + [rawCell, 8], + [rawRow, 8], + [value, 8], + [style, styleLayout.size], + [graphemes, 256], + [length, 4], + ] as const) + this.#release(pointer, size); + } + } + #kittyImage(graphics: number, id: number): KittyImageSnapshot | undefined { + if (!this.#kittySupported) return undefined; + const image = this.#e.ghostty_kitty_graphics_image(graphics, id); + if (!image) return undefined; + const output = this.#alloc(8); + const getU32 = (kind: number) => { + if (this.#e.ghostty_kitty_graphics_image_get(image, kind, output) !== 0) return 0; + return this.#view().getUint32(output, true); + }; + try { + const imageId = getU32(1), + number = getU32(2), + width = getU32(3), + height = getU32(4), + formatValue = getU32(5), + compression = getU32(6), + dataPointer = getU32(7), + dataLength = getU32(8); + if (compression !== 0 || dataLength > this.#kittyStorageLimit) + throw new AssetIntegrityError('Invalid Kitty decoded image storage metadata'); + this.#e.ghostty_kitty_graphics_image_get(image, 9, output); + const generation = this.#view().getBigUint64(output, true).toString(), + key = `${imageId}:${generation}`, + cached = this.#images.get(key); + if (cached) return cached; + const data = this.#bytes().slice(dataPointer, dataPointer + dataLength), + format = (['rgb', 'rgba', 'png', 'gray-alpha', 'gray'] as const)[formatValue]; + if (!format || format === 'png') + throw new AssetIntegrityError('Invalid Kitty decoded image format'); + const snapshot = freeze({ + id: imageId, + number, + generation, + width, + height, + format, + compression: 'none' as const, + dataLength, + sha256: createHash('sha256').update(data).digest('hex'), + }); + this.#images.set(key, snapshot); + return snapshot; + } finally { + this.#release(output, 8); + } + } + #pruneKittyImages() { + for (const key of this.#images.keys()) + if (!this.#currentImageKeys.has(key)) this.#images.delete(key); + } + graphics(): KittyGraphicsSnapshot { + if (!this.#kittySupported) + return freeze({ supported: false, generation: '0', storageLimitBytes: 0, placements: [] }); + const graphicsOutput = this.#alloc(4); + try { + if (this.#e.ghostty_terminal_get(this.#terminal, 30, graphicsOutput) !== 0) + throw new AssetIntegrityError('Kitty graphics capability disappeared from the terminal'); + const graphics = this.#view().getUint32(graphicsOutput, true), + generationOutput = this.#alloc(8); + try { + if (this.#e.ghostty_kitty_graphics_get(graphics, 2, generationOutput) !== 0) + throw new AssetIntegrityError('Unable to read Kitty graphics generation'); + const generation = this.#view().getBigUint64(generationOutput, true).toString(), + iteratorOutput = this.#alloc(4), + placements: KittyPlacementSnapshot[] = []; + try { + if (this.#e.ghostty_kitty_graphics_placement_iterator_new(0, iteratorOutput) !== 0) + throw new AssetIntegrityError('Unable to allocate Kitty placement iterator'); + const iterator = this.#view().getUint32(iteratorOutput, true), + value = this.#alloc(4), + render = this.#alloc(48); + try { + if (this.#e.ghostty_kitty_graphics_get(graphics, 1, iteratorOutput) !== 0) + throw new AssetIntegrityError('Unable to initialize Kitty placement iterator'); + while (this.#e.ghostty_kitty_graphics_placement_next(iterator)) { + const get = (kind: number, signed = false) => { + if (this.#e.ghostty_kitty_graphics_placement_get(iterator, kind, value) !== 0) + throw new AssetIntegrityError(`Unable to read Kitty placement field ${kind}`); + return signed + ? this.#view().getInt32(value, true) + : this.#view().getUint32(value, true); + }; + const imageId = get(1), + virtual = get(3) !== 0, + image = this.#kittyImage(graphics, imageId); + if (!image) continue; + const z = get(12, true), + requestedSource = { + x: get(6), + y: get(7), + width: get(8), + height: get(9), + }, + imageHandle = this.#e.ghostty_kitty_graphics_image(graphics, imageId); + let renderInfoAvailable = false; + if (!virtual && imageHandle) { + this.#view().setUint32(render, 48, true); + // Geometry can be unavailable for an individual placement (for + // example, after an unsupported protocol edge case). It is + // observational state, not an artifact-integrity failure. + renderInfoAvailable = + this.#e.ghostty_kitty_graphics_placement_render_info( + iterator, + imageHandle, + this.#terminal, + render, + ) === 0; + } + const visible = renderInfoAvailable && this.#view().getUint8(render + 28) !== 0, + placement = freeze({ + imageId, + placementId: get(2), + imageGeneration: image.generation, + image, + virtual, + z, + layer: + z < -1_073_741_824 + ? ('below-background' as const) + : z < 0 + ? ('below-text' as const) + : ('above-text' as const), + offset: { xPixels: get(4), yPixels: get(5) }, + requestedGrid: { columns: get(10), rows: get(11) }, + ...(renderInfoAvailable + ? { + renderedGrid: { + columns: this.#view().getUint32(render + 12, true), + rows: this.#view().getUint32(render + 16, true), + }, + renderedPixels: { + width: this.#view().getUint32(render + 4, true), + height: this.#view().getUint32(render + 8, true), + }, + } + : {}), + viewport: visible + ? { + column: this.#view().getInt32(render + 20, true), + row: this.#view().getInt32(render + 24, true), + visible, + } + : { visible: false }, + source: renderInfoAvailable + ? { + x: this.#view().getUint32(render + 32, true), + y: this.#view().getUint32(render + 36, true), + width: this.#view().getUint32(render + 40, true), + height: this.#view().getUint32(render + 44, true), + } + : requestedSource, + } satisfies KittyPlacementSnapshot); + placements.push(placement); + } + } finally { + this.#e.ghostty_kitty_graphics_placement_iterator_free(iterator); + this.#release(value, 4); + this.#release(render, 48); + } + } finally { + this.#release(iteratorOutput, 4); + } + const currentImageKeys = new Set( + placements.map((placement) => `${placement.imageId}:${placement.imageGeneration}`), + ); + // Placement iteration cannot enumerate unplaced images. Revalidate + // explicitly inspected IDs against the active Ghostty storage so they + // remain cache-visible until deleted or replaced. + for (const id of this.#inspectedImageIds) { + const image = this.#kittyImage(graphics, id); + if (image) currentImageKeys.add(`${image.id}:${image.generation}`); + else this.#inspectedImageIds.delete(id); + } + this.#currentImageKeys = currentImageKeys; + this.#pruneKittyImages(); + return freeze({ + supported: true, + generation, + storageLimitBytes: this.#kittyStorageLimit, + placements: Object.freeze(placements), + }); + } finally { + this.#release(generationOutput, 8); + } + } finally { + this.#release(graphicsOutput, 4); + } + } + inspectImage(id: number) { + const graphics = this.#alloc(4); + try { + if (!this.#kittySupported || this.#e.ghostty_terminal_get(this.#terminal, 30, graphics) !== 0) + return undefined; + const image = this.#kittyImage(this.#view().getUint32(graphics, true), id); + if (image) { + this.#inspectedImageIds.add(id); + this.#currentImageKeys.add(`${image.id}:${image.generation}`); + } + this.#pruneKittyImages(); + return image; + } finally { + this.#release(graphics, 4); + } + } + copyImageData(id: number) { + const graphics = this.#alloc(4); + try { + if (!this.#kittySupported || this.#e.ghostty_terminal_get(this.#terminal, 30, graphics) !== 0) + return undefined; + const image = this.inspectImage(id); + if (!image) return undefined; + const handle = this.#e.ghostty_kitty_graphics_image( + this.#view().getUint32(graphics, true), + id, + ); + if (!handle) return undefined; + const output = this.#alloc(8); + try { + if ( + this.#e.ghostty_kitty_graphics_image_get(handle, 7, output) !== 0 || + this.#e.ghostty_kitty_graphics_image_get(handle, 8, output + 4) !== 0 + ) + return undefined; + const pointer = this.#view().getUint32(output, true), + length = this.#view().getUint32(output + 4, true); + return this.#bytes().slice(pointer, pointer + length); + } finally { + this.#release(output, 8); + } + } finally { + this.#release(graphics, 4); + } + } + cachedImage(id: number) { + return [...this.#images.entries()].find( + ([key, image]) => image.id === id && this.#currentImageKeys.has(key), + )?.[1]; + } + snapshot(cause?: 'pty-output' | 'resize' | 'reset') { + const pointLayout = this.#layouts.GhosttyPoint, + coordinateLayout = this.#layouts.GhosttyPointCoordinate, + refLayout = this.#layouts.GhosttyGridRef, + styleLayout = this.#layouts.GhosttyStyle, + bufferLayout = this.#layouts.GhosttyBuffer, + point = this.#alloc(pointLayout.size), + ref = this.#alloc(refLayout.size), + rawCell = this.#alloc(8), + rawRow = this.#alloc(8), + style = this.#alloc(styleLayout.size), + value = this.#alloc(8), + textBuffer = this.#alloc(4096), + buffer = this.#alloc(bufferLayout.size), + length = this.#alloc(4), + rowKeys = this.#alloc(16), + rowValues = this.#alloc(16), + cellKeys = this.#alloc(12), + cellValues = this.#alloc(12), + contentTagValue = this.#alloc(4), + wideValuePointer = this.#alloc(4), + hyperlinkValue = this.#alloc(4), + lines: ScreenLine[] = []; + this.#e.ghostty_render_state_update(this.#renderState, this.#terminal); + try { + const view = this.#view(), + pointValueOffset = pointLayout.fields.value.offset; + view.setInt32(point + pointLayout.fields.tag.offset, 1, true); + for (const [index, key] of [1, 2, 7, 9].entries()) + view.setInt32(rowKeys + index * 4, key, true); + for (const [index, pointer] of [rawCell, style, value, buffer].entries()) + view.setUint32(rowValues + index * 4, pointer, true); + for (const [index, key] of [2, 3, 7].entries()) + view.setInt32(cellKeys + index * 4, key, true); + for (const [index, pointer] of [contentTagValue, wideValuePointer, hyperlinkValue].entries()) + view.setUint32(cellValues + index * 4, pointer, true); + if (this.#e.ghostty_render_state_get(this.#renderState, 4, this.#rowIteratorHolder) !== 0) + throw new Error('Unable to initialize Ghostty render row iterator'); + for (let row = 0; row < this.#viewport.rows; row++) { + const cells: ScreenCell[] = []; + if (!this.#e.ghostty_render_state_row_iterator_next(this.#rowIterator)) break; + if (this.#e.ghostty_render_state_row_get(this.#rowIterator, 3, this.#rowCellsHolder) !== 0) + throw new Error(`Unable to read Ghostty render row ${row}`); + this.#e.ghostty_render_state_row_get(this.#rowIterator, 2, rawRow); + const rowValue = view.getBigUint64(rawRow, true); + this.#e.ghostty_row_get(rowValue, 1, value); + const wrapped = view.getUint8(value) !== 0; + for (let column = 0; column < this.#viewport.columns; column++) { + if (!this.#e.ghostty_render_state_row_cells_next(this.#rowCells)) break; + view.setUint32(buffer + bufferLayout.fields.ptr.offset, textBuffer, true); + view.setUint32(buffer + bufferLayout.fields.cap.offset, 4096, true); + view.setUint32(buffer + bufferLayout.fields.len.offset, 0, true); + if ( + this.#e.ghostty_render_state_row_cells_get_multi( + this.#rowCells, + 4, + rowKeys, + rowValues, + length, + ) !== 0 + ) + throw new Error(`Unable to read Ghostty render cell ${column},${row}`); + const cellValue = view.getBigUint64(rawCell, true); + if (this.#e.ghostty_cell_get_multi(cellValue, 3, cellKeys, cellValues, length) !== 0) + throw new Error(`Unable to decode Ghostty cell ${column},${row}`); + const wideValue = view.getInt32(wideValuePointer, true), + continuation = wideValue === 2 || wideValue === 3, + width: 0 | 1 | 2 = continuation ? 0 : wideValue === 1 ? 2 : 1, + textLength = view.getUint32(buffer + bufferLayout.fields.len.offset, true), + text = + textLength > 0 + ? decoder.decode(this.#bytes().subarray(textBuffer, textBuffer + textLength)) + : ''; - view.setUint32(style + styleLayout.fields.size.offset, styleLayout.size, true); - let cellStyle: CellStyle = Object.freeze({ - bold: view.getUint8(style + styleLayout.fields.bold.offset) !== 0, - italic: view.getUint8(style + styleLayout.fields.italic.offset) !== 0, - faint: view.getUint8(style + styleLayout.fields.faint.offset) !== 0, - blink: view.getUint8(style + styleLayout.fields.blink.offset) !== 0, - inverse: view.getUint8(style + styleLayout.fields.inverse.offset) !== 0, - invisible: view.getUint8(style + styleLayout.fields.invisible.offset) !== 0, - strikethrough: view.getUint8(style + styleLayout.fields.strikethrough.offset) !== 0, - overline: view.getUint8(style + styleLayout.fields.overline.offset) !== 0, - underline: view.getInt32(style + styleLayout.fields.underline.offset, true), - foreground: this.#color(style, "fg_color"), - background: this.#color(style, "bg_color"), - underlineColor: this.#color(style, "underline_color"), - }); - const contentTag = view.getInt32(contentTagValue, true); - if (contentTag === 2) { - this.#e.ghostty_cell_get(cellValue, 10, value); - cellStyle = Object.freeze({ - ...cellStyle, - background: { kind: "palette" as const, index: view.getUint8(value) }, - }); - } else if (contentTag === 3) { - this.#e.ghostty_cell_get(cellValue, 11, value); - cellStyle = Object.freeze({ - ...cellStyle, - background: { - kind: "rgb" as const, - red: view.getUint8(value), - green: view.getUint8(value + 1), - blue: view.getUint8(value + 2), - }, - }); - } - const selected = view.getUint8(value) !== 0; + view.setUint32(style + styleLayout.fields.size.offset, styleLayout.size, true); + let cellStyle: CellStyle = Object.freeze({ + bold: view.getUint8(style + styleLayout.fields.bold.offset) !== 0, + italic: view.getUint8(style + styleLayout.fields.italic.offset) !== 0, + faint: view.getUint8(style + styleLayout.fields.faint.offset) !== 0, + blink: view.getUint8(style + styleLayout.fields.blink.offset) !== 0, + inverse: view.getUint8(style + styleLayout.fields.inverse.offset) !== 0, + invisible: view.getUint8(style + styleLayout.fields.invisible.offset) !== 0, + strikethrough: view.getUint8(style + styleLayout.fields.strikethrough.offset) !== 0, + overline: view.getUint8(style + styleLayout.fields.overline.offset) !== 0, + underline: view.getInt32(style + styleLayout.fields.underline.offset, true), + foreground: this.#color(style, 'fg_color'), + background: this.#color(style, 'bg_color'), + underlineColor: this.#color(style, 'underline_color'), + }); + const contentTag = view.getInt32(contentTagValue, true); + if (contentTag === 2) { + this.#e.ghostty_cell_get(cellValue, 10, value); + cellStyle = Object.freeze({ + ...cellStyle, + background: { kind: 'palette' as const, index: view.getUint8(value) }, + }); + } else if (contentTag === 3) { + this.#e.ghostty_cell_get(cellValue, 11, value); + cellStyle = Object.freeze({ + ...cellStyle, + background: { + kind: 'rgb' as const, + red: view.getUint8(value), + green: view.getUint8(value + 1), + blue: view.getUint8(value + 2), + }, + }); + } + const selected = view.getUint8(value) !== 0; - let hyperlink: string | undefined; - if (view.getUint8(hyperlinkValue) !== 0) { - view.setUint16( - point + pointValueOffset + coordinateLayout.fields.x.offset, - column, - true, - ); - view.setUint32(point + pointValueOffset + coordinateLayout.fields.y.offset, row, true); - view.setUint32(ref + refLayout.fields.size.offset, refLayout.size, true); - if (this.#e.ghostty_terminal_grid_ref(this.#terminal, point, ref) === 0) { - view.setUint32(length, 0, true); - this.#e.ghostty_grid_ref_hyperlink_uri(ref, 0, 0, length); - const hyperlinkLength = view.getUint32(length, true); - if (hyperlinkLength > 0) { - const hyperlinkBuffer = this.#alloc(hyperlinkLength); - try { - if ( - this.#e.ghostty_grid_ref_hyperlink_uri( - ref, - hyperlinkBuffer, - hyperlinkLength, - length, - ) === 0 - ) - hyperlink = decoder.decode( - this.#bytes().subarray(hyperlinkBuffer, hyperlinkBuffer + hyperlinkLength), - ); - } finally { - this.#release(hyperlinkBuffer, hyperlinkLength); - } - } - } - } - cells.push( - Object.freeze({ - column, - text, - width, - continuation, - style: cellStyle, - selected, - ...(hyperlink ? { hyperlink } : {}), - }), - ); - } - while (cells.length < this.#viewport.columns) - cells.push( - Object.freeze({ - column: cells.length, - text: "", - width: 1, - continuation: false, - style: defaultStyle, - selected: false, - }), - ); - const rowText = cells - .map((entry) => - entry.continuation ? "" : entry.style.invisible ? " " : entry.text || " ", - ) - .join(""); - lines.push(Object.freeze({ row, cells: Object.freeze(cells), wrapped, text: rowText })); - } - while (lines.length < this.#viewport.rows) { - const row = lines.length, - cells = Array.from({ length: this.#viewport.columns }, (_, column) => - Object.freeze({ - column, - text: "", - width: 1 as const, - continuation: false, - style: defaultStyle, - selected: false, - }), - ); - lines.push( - Object.freeze({ - row, - cells: Object.freeze(cells), - wrapped: false, - text: " ".repeat(this.#viewport.columns), - }), - ); - } - } finally { - for (const [pointer, size] of [ - [point, pointLayout.size], - [ref, refLayout.size], - [rawCell, 8], - [rawRow, 8], - [style, styleLayout.size], - [value, 8], - [textBuffer, 4096], - [buffer, bufferLayout.size], - [length, 4], - [rowKeys, 16], - [rowValues, 16], - [cellKeys, 12], - [cellValues, 12], - [contentTagValue, 4], - [wideValuePointer, 4], - [hyperlinkValue, 4], - ] as const) - this.#release(pointer, size); - } - const shapeValue = this.#renderGet(10), - cursor = { - column: this.#renderGet(15, 2), - row: this.#renderGet(16, 2), - visible: this.#renderGet(11, 1) !== 0 && this.#renderGet(14, 1) !== 0, - shape: - shapeValue === 0 - ? ("bar" as const) - : shapeValue === 2 - ? ("underline" as const) - : ("block" as const), - blinking: this.#renderGet(12, 1) !== 0, - }, - activeBuffer = this.#get(6) === 1 ? ("alternate" as const) : ("primary" as const), - visualState = JSON.stringify([lines, cursor, activeBuffer, this.#viewport]), - visual = visualState !== this.#previous; - if (visual) this.#lastVisual = this.now(); - if (cause && visual) this.#sequence++; - this.#previous = visualState; - const workingDirectory = this.#terminalString(13); - return freeze({ - sequence: this.#sequence, - timestamp: this.now(), - lastVisualChangeAt: this.#lastVisual, - viewport: this.#viewport, - activeBuffer, - graphics: this.graphics(), - cursor, - lines, - modes: this.modes(), - title: this.#terminalString(12), - ...(workingDirectory ? { workingDirectory } : {}), - } satisfies ScreenSnapshot); - } - encodeKey(input: KeyName | KeyPress) { - const event = typeof input === "string" ? { key: input } : input, - name = event.key, - functional: Record = { - Backspace: 53, - Enter: 58, - Tab: 64, - Delete: 68, - End: 69, - Home: 71, - PageDown: 73, - PageUp: 74, - ArrowDown: 75, - ArrowLeft: 76, - ArrowRight: 77, - ArrowUp: 78, - Escape: 120, - }; - let key = functional[name] ?? 0, - text = ""; - const functionMatch = /^F(\d+)$/.exec(name); - if (functionMatch) { - const number = Number(functionMatch[1]); - if (number >= 1 && number <= 25) key = 120 + number; - } else if ([...name].length === 1) { - const codepoint = name.codePointAt(0)!; - if (/[a-z]/i.test(name)) key = 20 + name.toLowerCase().charCodeAt(0) - 97; - else if (/\d/.test(name)) key = 6 + Number(name); - text = event.shift ? name.toUpperCase() : name; - this.#e.ghostty_key_event_set_unshifted_codepoint( - this.#keyEvent, - name.toLowerCase().codePointAt(0) ?? codepoint, - ); - } - let modifiers = 0; - if (event.shift) modifiers |= 1; - if (event.control) modifiers |= 2; - if (event.alt) modifiers |= 4; - if (event.super) modifiers |= 8; - this.#e.ghostty_key_event_set_action(this.#keyEvent, 1); - this.#e.ghostty_key_event_set_key(this.#keyEvent, key); - this.#e.ghostty_key_event_set_mods(this.#keyEvent, modifiers); - const utf8 = encoder.encode(text), - utf8Pointer = utf8.length ? this.#alloc(utf8.length) : 0; - if (utf8.length) this.#bytes().set(utf8, utf8Pointer); - this.#e.ghostty_key_event_set_utf8(this.#keyEvent, utf8Pointer, utf8.length); - this.#e.ghostty_key_encoder_setopt_from_terminal(this.#keyEncoder, this.#terminal); - const output = this.#alloc(256), - length = this.#alloc(4); - try { - if ( - this.#e.ghostty_key_encoder_encode( - this.#keyEncoder, - this.#keyEvent, - output, - 256, - length, - ) !== 0 - ) - throw new Error(`Ghostty could not encode key ${JSON.stringify(name)}`); - return this.#bytes().slice(output, output + this.#view().getUint32(length, true)); - } finally { - if (utf8.length) this.#release(utf8Pointer, utf8.length); - this.#release(output, 256); - this.#release(length, 4); - } - } - encodeMouse( - action: "move" | "down" | "up", - point: Point, - options: MouseOptions = {}, - anyButtonPressed = false, - ) { - this.#e.ghostty_mouse_encoder_setopt_from_terminal(this.#mouseEncoder, this.#terminal); - this.#configureMouseSize(); - this.#e.ghostty_mouse_event_set_action( - this.#mouseEvent, - action === "down" ? 0 : action === "up" ? 1 : 2, - ); - if (action === "move" && !anyButtonPressed) - this.#e.ghostty_mouse_event_clear_button(this.#mouseEvent); - else { - const button = - typeof options.button === "number" - ? options.button - : options.button === "middle" - ? 3 - : options.button === "right" - ? 2 - : 1; - this.#e.ghostty_mouse_event_set_button(this.#mouseEvent, button); - } - let modifiers = 0; - if (options.shift) modifiers |= 1; - if (options.control) modifiers |= 2; - if (options.alt) modifiers |= 4; - if (options.super) modifiers |= 8; - this.#e.ghostty_mouse_event_set_mods(this.#mouseEvent, modifiers); - const positionLayout = this.#layouts.GhosttyMousePosition, - position = this.#alloc(positionLayout.size), - view = this.#view(); - view.setFloat32(position + positionLayout.fields.x.offset, point.column * 10 + 5, true); - view.setFloat32(position + positionLayout.fields.y.offset, point.row * 20 + 10, true); - this.#e.ghostty_mouse_event_set_position(this.#mouseEvent, position); - const pressed = this.#alloc(1), - output = this.#alloc(256), - length = this.#alloc(4); - view.setUint8(pressed, anyButtonPressed ? 1 : 0); - this.#e.ghostty_mouse_encoder_setopt(this.#mouseEncoder, 3, pressed); - try { - if ( - this.#e.ghostty_mouse_encoder_encode( - this.#mouseEncoder, - this.#mouseEvent, - output, - 256, - length, - ) !== 0 - ) - throw new Error("Ghostty mouse encoding failed"); - return this.#bytes().slice(output, output + this.#view().getUint32(length, true)); - } finally { - this.#release(position, positionLayout.size); - this.#release(pressed, 1); - this.#release(output, 256); - this.#release(length, 4); - } - } - encodePaste(text: string) { - const data = encoder.encode(text), - p = this.#alloc(data.length || 1), - lp = this.#alloc(4); - this.#bytes().set(data, p); - const bracketed = this.mode(2004); - this.#e.ghostty_paste_encode(p, data.length, bracketed ? 1 : 0, 0, 0, lp); - const n = this.#view().getUint32(lp, true), - out = this.#alloc(n || 1); - try { - if (this.#e.ghostty_paste_encode(p, data.length, bracketed ? 1 : 0, out, n, lp) !== 0) - throw new Error("Ghostty paste encoding failed"); - return this.#bytes().slice(out, out + n); - } finally { - this.#release(p, data.length || 1); - this.#release(lp, 4); - this.#release(out, n || 1); - } - } - encodeFocus(state: "in" | "out") { - if (!this.mode(1004)) return new Uint8Array(); - const out = this.#alloc(8), - lp = this.#alloc(4); - try { - if (this.#e.ghostty_focus_encode(state === "in" ? 0 : 1, out, 8, lp) !== 0) - return new Uint8Array(); - return this.#bytes().slice(out, out + this.#view().getUint32(lp, true)); - } finally { - this.#release(out, 8); - this.#release(lp, 4); - } - } - free() { - if (this.#mouseEvent) this.#e.ghostty_mouse_event_free(this.#mouseEvent); - if (this.#mouseEncoder) this.#e.ghostty_mouse_encoder_free(this.#mouseEncoder); - if (this.#keyEvent) this.#e.ghostty_key_event_free(this.#keyEvent); - if (this.#keyEncoder) this.#e.ghostty_key_encoder_free(this.#keyEncoder); - if (this.#rowCells) this.#e.ghostty_render_state_row_cells_free(this.#rowCells); - if (this.#rowIterator) this.#e.ghostty_render_state_row_iterator_free(this.#rowIterator); - if (this.#renderState) this.#e.ghostty_render_state_free(this.#renderState); - if (this.#formatter) this.#e.ghostty_formatter_free(this.#formatter); - if (this.#terminal) this.#e.ghostty_terminal_free(this.#terminal); - for (const { pointer, size } of this.#allocations.splice(0)) - this.#e.ghostty_wasm_free_u8_array(pointer, size); - for (const pointer of this.#opaqueAllocations.splice(0)) - this.#e.ghostty_wasm_free_opaque(pointer); - this.#mouseEvent = this.#mouseEncoder = this.#keyEvent = this.#keyEncoder = 0; - this.#rowCells = this.#rowIterator = this.#renderState = this.#formatter = this.#terminal = 0; - } + let hyperlink: string | undefined; + if (view.getUint8(hyperlinkValue) !== 0) { + view.setUint16( + point + pointValueOffset + coordinateLayout.fields.x.offset, + column, + true, + ); + view.setUint32(point + pointValueOffset + coordinateLayout.fields.y.offset, row, true); + view.setUint32(ref + refLayout.fields.size.offset, refLayout.size, true); + if (this.#e.ghostty_terminal_grid_ref(this.#terminal, point, ref) === 0) { + view.setUint32(length, 0, true); + this.#e.ghostty_grid_ref_hyperlink_uri(ref, 0, 0, length); + const hyperlinkLength = view.getUint32(length, true); + if (hyperlinkLength > 0) { + const hyperlinkBuffer = this.#alloc(hyperlinkLength); + try { + if ( + this.#e.ghostty_grid_ref_hyperlink_uri( + ref, + hyperlinkBuffer, + hyperlinkLength, + length, + ) === 0 + ) + hyperlink = decoder.decode( + this.#bytes().subarray(hyperlinkBuffer, hyperlinkBuffer + hyperlinkLength), + ); + } finally { + this.#release(hyperlinkBuffer, hyperlinkLength); + } + } + } + } + cells.push( + Object.freeze({ + column, + text, + width, + continuation, + style: cellStyle, + selected, + ...(hyperlink ? { hyperlink } : {}), + }), + ); + } + while (cells.length < this.#viewport.columns) + cells.push( + Object.freeze({ + column: cells.length, + text: '', + width: 1, + continuation: false, + style: defaultStyle, + selected: false, + }), + ); + const rowText = cells + .map((entry) => + entry.continuation ? '' : entry.style.invisible ? ' ' : entry.text || ' ', + ) + .join(''); + lines.push(Object.freeze({ row, cells: Object.freeze(cells), wrapped, text: rowText })); + } + while (lines.length < this.#viewport.rows) { + const row = lines.length, + cells = Array.from({ length: this.#viewport.columns }, (_, column) => + Object.freeze({ + column, + text: '', + width: 1 as const, + continuation: false, + style: defaultStyle, + selected: false, + }), + ); + lines.push( + Object.freeze({ + row, + cells: Object.freeze(cells), + wrapped: false, + text: ' '.repeat(this.#viewport.columns), + }), + ); + } + } finally { + for (const [pointer, size] of [ + [point, pointLayout.size], + [ref, refLayout.size], + [rawCell, 8], + [rawRow, 8], + [style, styleLayout.size], + [value, 8], + [textBuffer, 4096], + [buffer, bufferLayout.size], + [length, 4], + [rowKeys, 16], + [rowValues, 16], + [cellKeys, 12], + [cellValues, 12], + [contentTagValue, 4], + [wideValuePointer, 4], + [hyperlinkValue, 4], + ] as const) + this.#release(pointer, size); + } + const shapeValue = this.#renderGet(10), + cursor = { + column: this.#renderGet(15, 2), + row: this.#renderGet(16, 2), + visible: this.#renderGet(11, 1) !== 0 && this.#renderGet(14, 1) !== 0, + shape: + shapeValue === 0 + ? ('bar' as const) + : shapeValue === 2 + ? ('underline' as const) + : ('block' as const), + blinking: this.#renderGet(12, 1) !== 0, + }, + activeBuffer = this.#get(6) === 1 ? ('alternate' as const) : ('primary' as const), + visualState = JSON.stringify([lines, cursor, activeBuffer, this.#viewport]), + visual = visualState !== this.#previous; + if (visual) this.#lastVisual = this.now(); + if (cause && visual) this.#sequence++; + this.#previous = visualState; + const workingDirectory = this.#terminalString(13); + return freeze({ + sequence: this.#sequence, + timestamp: this.now(), + lastVisualChangeAt: this.#lastVisual, + viewport: this.#viewport, + activeBuffer, + graphics: this.graphics(), + cursor, + lines, + modes: this.modes(), + title: this.#terminalString(12), + ...(workingDirectory ? { workingDirectory } : {}), + } satisfies ScreenSnapshot); + } + encodeKey(input: KeyName | KeyPress) { + const event = typeof input === 'string' ? { key: input } : input, + name = event.key, + functional: Record = { + Backspace: 53, + Enter: 58, + Tab: 64, + Delete: 68, + End: 69, + Home: 71, + PageDown: 73, + PageUp: 74, + ArrowDown: 75, + ArrowLeft: 76, + ArrowRight: 77, + ArrowUp: 78, + Escape: 120, + }; + let key = functional[name] ?? 0, + text = ''; + const functionMatch = /^F(\d+)$/.exec(name); + if (functionMatch) { + const number = Number(functionMatch[1]); + if (number >= 1 && number <= 25) key = 120 + number; + } else if ([...name].length === 1) { + const codepoint = name.codePointAt(0)!; + if (/[a-z]/i.test(name)) key = 20 + name.toLowerCase().charCodeAt(0) - 97; + else if (/\d/.test(name)) key = 6 + Number(name); + text = event.shift ? name.toUpperCase() : name; + this.#e.ghostty_key_event_set_unshifted_codepoint( + this.#keyEvent, + name.toLowerCase().codePointAt(0) ?? codepoint, + ); + } + let modifiers = 0; + if (event.shift) modifiers |= 1; + if (event.control) modifiers |= 2; + if (event.alt) modifiers |= 4; + if (event.super) modifiers |= 8; + this.#e.ghostty_key_event_set_action(this.#keyEvent, 1); + this.#e.ghostty_key_event_set_key(this.#keyEvent, key); + this.#e.ghostty_key_event_set_mods(this.#keyEvent, modifiers); + const utf8 = encoder.encode(text), + utf8Pointer = utf8.length ? this.#alloc(utf8.length) : 0; + if (utf8.length) this.#bytes().set(utf8, utf8Pointer); + this.#e.ghostty_key_event_set_utf8(this.#keyEvent, utf8Pointer, utf8.length); + this.#e.ghostty_key_encoder_setopt_from_terminal(this.#keyEncoder, this.#terminal); + const output = this.#alloc(256), + length = this.#alloc(4); + try { + if ( + this.#e.ghostty_key_encoder_encode( + this.#keyEncoder, + this.#keyEvent, + output, + 256, + length, + ) !== 0 + ) + throw new Error(`Ghostty could not encode key ${JSON.stringify(name)}`); + return this.#bytes().slice(output, output + this.#view().getUint32(length, true)); + } finally { + if (utf8.length) this.#release(utf8Pointer, utf8.length); + this.#release(output, 256); + this.#release(length, 4); + } + } + encodeMouse( + action: 'move' | 'down' | 'up', + point: Point, + options: MouseOptions = {}, + anyButtonPressed = false, + ) { + this.#e.ghostty_mouse_encoder_setopt_from_terminal(this.#mouseEncoder, this.#terminal); + this.#configureMouseSize(); + this.#e.ghostty_mouse_event_set_action( + this.#mouseEvent, + action === 'down' ? 0 : action === 'up' ? 1 : 2, + ); + if (action === 'move' && !anyButtonPressed) + this.#e.ghostty_mouse_event_clear_button(this.#mouseEvent); + else { + const button = + typeof options.button === 'number' + ? options.button + : options.button === 'middle' + ? 3 + : options.button === 'right' + ? 2 + : 1; + this.#e.ghostty_mouse_event_set_button(this.#mouseEvent, button); + } + let modifiers = 0; + if (options.shift) modifiers |= 1; + if (options.control) modifiers |= 2; + if (options.alt) modifiers |= 4; + if (options.super) modifiers |= 8; + this.#e.ghostty_mouse_event_set_mods(this.#mouseEvent, modifiers); + const positionLayout = this.#layouts.GhosttyMousePosition, + position = this.#alloc(positionLayout.size), + view = this.#view(); + view.setFloat32(position + positionLayout.fields.x.offset, point.column * 10 + 5, true); + view.setFloat32(position + positionLayout.fields.y.offset, point.row * 20 + 10, true); + this.#e.ghostty_mouse_event_set_position(this.#mouseEvent, position); + const pressed = this.#alloc(1), + output = this.#alloc(256), + length = this.#alloc(4); + view.setUint8(pressed, anyButtonPressed ? 1 : 0); + this.#e.ghostty_mouse_encoder_setopt(this.#mouseEncoder, 3, pressed); + try { + if ( + this.#e.ghostty_mouse_encoder_encode( + this.#mouseEncoder, + this.#mouseEvent, + output, + 256, + length, + ) !== 0 + ) + throw new Error('Ghostty mouse encoding failed'); + return this.#bytes().slice(output, output + this.#view().getUint32(length, true)); + } finally { + this.#release(position, positionLayout.size); + this.#release(pressed, 1); + this.#release(output, 256); + this.#release(length, 4); + } + } + encodePaste(text: string) { + const data = encoder.encode(text), + p = this.#alloc(data.length || 1), + lp = this.#alloc(4); + this.#bytes().set(data, p); + const bracketed = this.mode(2004); + this.#e.ghostty_paste_encode(p, data.length, bracketed ? 1 : 0, 0, 0, lp); + const n = this.#view().getUint32(lp, true), + out = this.#alloc(n || 1); + try { + if (this.#e.ghostty_paste_encode(p, data.length, bracketed ? 1 : 0, out, n, lp) !== 0) + throw new Error('Ghostty paste encoding failed'); + return this.#bytes().slice(out, out + n); + } finally { + this.#release(p, data.length || 1); + this.#release(lp, 4); + this.#release(out, n || 1); + } + } + encodeFocus(state: 'in' | 'out') { + if (!this.mode(1004)) return new Uint8Array(); + const out = this.#alloc(8), + lp = this.#alloc(4); + try { + if (this.#e.ghostty_focus_encode(state === 'in' ? 0 : 1, out, 8, lp) !== 0) + return new Uint8Array(); + return this.#bytes().slice(out, out + this.#view().getUint32(lp, true)); + } finally { + this.#release(out, 8); + this.#release(lp, 4); + } + } + free() { + if (this.#mouseEvent) this.#e.ghostty_mouse_event_free(this.#mouseEvent); + if (this.#mouseEncoder) this.#e.ghostty_mouse_encoder_free(this.#mouseEncoder); + if (this.#keyEvent) this.#e.ghostty_key_event_free(this.#keyEvent); + if (this.#keyEncoder) this.#e.ghostty_key_encoder_free(this.#keyEncoder); + if (this.#rowCells) this.#e.ghostty_render_state_row_cells_free(this.#rowCells); + if (this.#rowIterator) this.#e.ghostty_render_state_row_iterator_free(this.#rowIterator); + if (this.#renderState) this.#e.ghostty_render_state_free(this.#renderState); + if (this.#formatter) this.#e.ghostty_formatter_free(this.#formatter); + if (this.#terminal) this.#e.ghostty_terminal_free(this.#terminal); + for (const { pointer, size } of this.#allocations.splice(0)) + this.#e.ghostty_wasm_free_u8_array(pointer, size); + for (const pointer of this.#opaqueAllocations.splice(0)) + this.#e.ghostty_wasm_free_opaque(pointer); + this.#mouseEvent = this.#mouseEncoder = this.#keyEvent = this.#keyEncoder = 0; + this.#rowCells = this.#rowIterator = this.#renderState = this.#formatter = this.#terminal = 0; + } } diff --git a/experiments/ghostwright/src/tracing/replay.ts b/experiments/ghostwright/src/tracing/replay.ts index 28ed816..6dd124b 100644 --- a/experiments/ghostwright/src/tracing/replay.ts +++ b/experiments/ghostwright/src/tracing/replay.ts @@ -1,93 +1,93 @@ -import { readFile } from "node:fs/promises"; -import { join } from "node:path"; -import { AssetIntegrityError } from "../errors.ts"; -import type { ScreenRevision, ScreenSnapshot, Viewport } from "../types.ts"; -import { GhosttyWasmTerminal } from "../terminal/wasm.ts"; +import { readFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { AssetIntegrityError } from '../errors.ts'; +import type { ScreenRevision, ScreenSnapshot, Viewport } from '../types.ts'; +import { GhosttyWasmTerminal } from '../terminal/wasm.ts'; export interface ReplayResult { - revisions: readonly ScreenRevision[]; - finalSnapshot: ScreenSnapshot; + revisions: readonly ScreenRevision[]; + finalSnapshot: ScreenSnapshot; } function observable(snapshot: ScreenSnapshot) { - return JSON.stringify({ - viewport: snapshot.viewport, - activeBuffer: snapshot.activeBuffer, - cursor: snapshot.cursor, - lines: snapshot.lines, - modes: snapshot.modes, - title: snapshot.title, - workingDirectory: snapshot.workingDirectory, - }); + return JSON.stringify({ + viewport: snapshot.viewport, + activeBuffer: snapshot.activeBuffer, + cursor: snapshot.cursor, + lines: snapshot.lines, + modes: snapshot.modes, + title: snapshot.title, + workingDirectory: snapshot.workingDirectory, + }); } export async function replayTrace(directory: string): Promise { - const metadata = JSON.parse(await readFile(join(directory, "metadata.json"), "utf8")); - if (metadata.schemaVersion !== 1) - throw new AssetIntegrityError(`Unsupported Ghostwright trace schema ${metadata.schemaVersion}`); - const lockUrl = new URL( - import.meta.url.includes("/dist/") ? "../ghostty.lock.json" : "../../ghostty.lock.json", - import.meta.url, - ), - lock = JSON.parse(await readFile(lockUrl, "utf8")), - expectedWasm = lock.artifacts["artifacts/ghostty-vt.wasm"]?.sha256; - if (!expectedWasm || metadata.ghostty?.wasmSha256 !== expectedWasm) - throw new AssetIntegrityError("Trace Ghostty artifact is incompatible with this package"); - const viewport = metadata.profile?.viewport as Required | undefined; - if (!viewport) - throw new AssetIntegrityError("Trace metadata does not contain the initial viewport"); - const raw = new Uint8Array(await readFile(join(directory, "output.bin"))), - events = (await readFile(join(directory, "trace.jsonl"), "utf8")) - .split("\n") - .filter(Boolean) - .map((line) => JSON.parse(line)), - terminal = await GhosttyWasmTerminal.create(viewport), - revisions: ScreenRevision[] = []; - let previous = terminal.snapshot(), - sequence = 0; - try { - for (const event of events) { - let cause: "pty-output" | "resize" | undefined; - if (event.type === "output" && event.raw?.direction === "from-pty") { - terminal.write(raw.slice(event.raw.offset, event.raw.offset + event.raw.length)); - cause = "pty-output"; - } else if (event.type === "action" && event.viewport) { - terminal.resize(event.viewport); - cause = "resize"; - } - if (!cause) continue; - const snapshot = terminal.snapshot(cause); - if (observable(snapshot) === observable(previous)) continue; - sequence++; - const changedRows = snapshot.lines - .map((line, row) => - JSON.stringify(line) === JSON.stringify(previous.lines[row]) ? -1 : row, - ) - .filter((row) => row >= 0); - const visualChange = - JSON.stringify([ - snapshot.lines, - snapshot.cursor, - snapshot.activeBuffer, - snapshot.viewport, - ]) !== - JSON.stringify([previous.lines, previous.cursor, previous.activeBuffer, previous.viewport]); - const sequenced = Object.freeze({ ...snapshot, sequence }); - revisions.push( - Object.freeze({ - sequence, - timestamp: event.timestamp, - cause, - sourceFrameSequence: event.frameSequence, - changedRows: Object.freeze(changedRows), - visualChange, - snapshot: sequenced, - }), - ); - previous = sequenced; - } - return { revisions: Object.freeze(revisions), finalSnapshot: previous }; - } finally { - terminal.free(); - } + const metadata = JSON.parse(await readFile(join(directory, 'metadata.json'), 'utf8')); + if (metadata.schemaVersion !== 1) + throw new AssetIntegrityError(`Unsupported Ghostwright trace schema ${metadata.schemaVersion}`); + const lockUrl = new URL( + import.meta.url.includes('/dist/') ? '../ghostty.lock.json' : '../../ghostty.lock.json', + import.meta.url, + ), + lock = JSON.parse(await readFile(lockUrl, 'utf8')), + expectedWasm = lock.artifacts['artifacts/ghostty-vt.wasm']?.sha256; + if (!expectedWasm || metadata.ghostty?.wasmSha256 !== expectedWasm) + throw new AssetIntegrityError('Trace Ghostty artifact is incompatible with this package'); + const viewport = metadata.profile?.viewport as Required | undefined; + if (!viewport) + throw new AssetIntegrityError('Trace metadata does not contain the initial viewport'); + const raw = new Uint8Array(await readFile(join(directory, 'output.bin'))), + events = (await readFile(join(directory, 'trace.jsonl'), 'utf8')) + .split('\n') + .filter(Boolean) + .map((line) => JSON.parse(line)), + terminal = await GhosttyWasmTerminal.create(viewport), + revisions: ScreenRevision[] = []; + let previous = terminal.snapshot(), + sequence = 0; + try { + for (const event of events) { + let cause: 'pty-output' | 'resize' | undefined; + if (event.type === 'output' && event.raw?.direction === 'from-pty') { + terminal.write(raw.slice(event.raw.offset, event.raw.offset + event.raw.length)); + cause = 'pty-output'; + } else if (event.type === 'action' && event.viewport) { + terminal.resize(event.viewport); + cause = 'resize'; + } + if (!cause) continue; + const snapshot = terminal.snapshot(cause); + if (observable(snapshot) === observable(previous)) continue; + sequence++; + const changedRows = snapshot.lines + .map((line, row) => + JSON.stringify(line) === JSON.stringify(previous.lines[row]) ? -1 : row, + ) + .filter((row) => row >= 0); + const visualChange = + JSON.stringify([ + snapshot.lines, + snapshot.cursor, + snapshot.activeBuffer, + snapshot.viewport, + ]) !== + JSON.stringify([previous.lines, previous.cursor, previous.activeBuffer, previous.viewport]); + const sequenced = Object.freeze({ ...snapshot, sequence }); + revisions.push( + Object.freeze({ + sequence, + timestamp: event.timestamp, + cause, + sourceFrameSequence: event.frameSequence, + changedRows: Object.freeze(changedRows), + visualChange, + snapshot: sequenced, + }), + ); + previous = sequenced; + } + return { revisions: Object.freeze(revisions), finalSnapshot: previous }; + } finally { + terminal.free(); + } } diff --git a/experiments/ghostwright/src/tracing/trace.ts b/experiments/ghostwright/src/tracing/trace.ts index a7df498..81becdf 100644 --- a/experiments/ghostwright/src/tracing/trace.ts +++ b/experiments/ghostwright/src/tracing/trace.ts @@ -1,149 +1,149 @@ -import { mkdir, readFile, writeFile } from "node:fs/promises"; -import { resolve } from "node:path"; -import { randomBytes } from "node:crypto"; -import type { ProcessStatus, ScreenSnapshot, TerminalLaunchOptions } from "../types.ts"; -import { TraceWriteError } from "../errors.ts"; +import { mkdir, readFile, writeFile } from 'node:fs/promises'; +import { resolve } from 'node:path'; +import { randomBytes } from 'node:crypto'; +import type { ProcessStatus, ScreenSnapshot, TerminalLaunchOptions } from '../types.ts'; +import { TraceWriteError } from '../errors.ts'; export interface TraceEvent { - schemaVersion: 1; - sequence: number; - timestamp: number; - type: string; - [key: string]: unknown; + schemaVersion: 1; + sequence: number; + timestamp: number; + type: string; + [key: string]: unknown; } export class SessionTrace { - #events: TraceEvent[] = []; - #seq = 0; - readonly started = performance.now(); - readonly raw: Uint8Array[] = []; - constructor( - readonly options: TerminalLaunchOptions, - readonly policy: "off" | "retain-on-failure" | "on", - readonly directory: string, - ) { - this.add("session-start"); - } - now() { - return performance.now() - this.started; - } - events() { - return [...this.#events]; - } - add(type: string, data: Record = {}) { - if (this.policy === "off") return; - this.#events.push({ - schemaVersion: 1, - sequence: ++this.#seq, - timestamp: this.now(), - type, - ...data, - }); - if (this.#events.length > 10000) this.#events.shift(); - } - output(bytes: Uint8Array, frameSequence: number) { - if (this.policy === "off") return; - const offset = this.raw.reduce((n, b) => n + b.length, 0); - this.raw.push(bytes.slice()); - this.add("output", { - frameSequence, - raw: { offset, length: bytes.length, direction: "from-pty" }, - }); - } - input(bytes: Uint8Array, actionSequence: number, redacted = false) { - if (this.policy === "off") return; - if (redacted) { - this.add("input", { actionSequence, redacted: true, length: bytes.length }); - return; - } - const offset = this.raw.reduce((total, part) => total + part.length, 0); - this.raw.push(bytes.slice()); - this.add("input", { - actionSequence, - raw: { offset, length: bytes.length, direction: "to-pty" }, - }); - } - async persist(error: unknown, snapshot: ScreenSnapshot, status: ProcessStatus) { - if (this.policy === "off") return undefined; - try { - const name = (this.options.name ?? "session").replace(/[^a-zA-Z0-9_.-]/g, "-").slice(0, 60), - dir = resolve( - this.directory, - `${name}-${Date.now()}-${process.pid}-${randomBytes(3).toString("hex")}`, - ); - await mkdir(dir, { recursive: true, mode: 0o700 }); - const lockUrl = new URL( - import.meta.url.includes("/dist/") ? "../ghostty.lock.json" : "../../ghostty.lock.json", - import.meta.url, - ), - lock = JSON.parse(await readFile(lockUrl, "utf8")), - target = `${process.platform}-${process.arch}`, - hostArtifact = lock.artifacts[`artifacts/pty-host-${target}`], - wasmArtifact = lock.artifacts["artifacts/ghostty-vt.wasm"], - redactedIndexes = - typeof this.options.trace === "object" - ? new Set(this.options.trace.redactArgumentIndexes ?? []) - : new Set(), - deno = (globalThis as unknown as { Deno?: { version: { deno: string } } }).Deno, - bun = (globalThis as unknown as { Bun?: { version: string } }).Bun, - metadata = { - schemaVersion: 1, - sessionName: name, - startedAt: new Date().toISOString(), - runtime: { - name: deno ? "deno" : bun ? "bun" : "node", - version: deno ? deno.version.deno : bun ? bun.version : process.version, - }, - platform: { os: process.platform, arch: process.arch }, - ghostwrightVersion: "0.1.0", - ghostty: { - repository: "https://github.com/ghostty-org/ghostty", - commit: "f8041e849b36efbbb9736b6ecf0ccfcb01d94e69", - wasmSha256: wasmArtifact.sha256, - }, - ptyHost: { protocolVersion: 1, target, sha256: hostArtifact.sha256 }, - profile: { - term: "xterm-ghostty", - cellWidth: 10, - cellHeight: 20, - viewport: snapshot.viewport, - }, - command: this.options.command, - args: (this.options.args ?? []).map((argument, index) => - redactedIndexes.has(index) ? "" : argument, - ), - cwd: this.options.cwd, - explicitEnvironmentKeys: Object.keys(this.options.env ?? {}), - redactedEnvironmentKeys: Object.keys(this.options.env ?? {}).filter((k) => - /(password|passwd|secret|token|api[-_]?key|private[-_]?key|credential|auth)/i.test(k), - ), - }; - const screen = snapshot.lines.map((l) => l.text).join("\n"), - tens = Array.from({ length: snapshot.viewport.columns }, (_, column) => - column % 10 === 0 ? String(Math.floor(column / 10) % 10) : " ", - ).join(""), - ones = Array.from({ length: snapshot.viewport.columns }, (_, column) => - String(column % 10), - ).join(""), - finalScreen = `viewport: ${snapshot.viewport.columns}x${snapshot.viewport.rows}\ncursor: (${snapshot.cursor.column},${snapshot.cursor.row}) visible=${snapshot.cursor.visible}\nbuffer: ${snapshot.activeBuffer}\n ${tens}\n ${ones}\n${snapshot.lines - .map((line) => `${String(line.row).padStart(3)} |${line.text}|`) - .join("\n")}\n`; - const failure = `${error instanceof Error ? (error.stack ?? error.message) : String(error)}\n\nviewport: ${snapshot.viewport.columns}x${snapshot.viewport.rows}\ncursor: (${snapshot.cursor.column},${snapshot.cursor.row}) visible=${snapshot.cursor.visible}\nbuffer: ${snapshot.activeBuffer}\nprocess: ${JSON.stringify(status)}\nartifact path: ${dir}\n\n${screen}\n`; - await Promise.all([ - writeFile(`${dir}/metadata.json`, JSON.stringify(metadata, null, 2), { mode: 0o600 }), - writeFile( - `${dir}/trace.jsonl`, - this.#events.map((e) => JSON.stringify(e)).join("\n") + "\n", - { mode: 0o600 }, - ), - writeFile(`${dir}/output.bin`, Buffer.concat(this.raw.map((b) => Buffer.from(b))), { - mode: 0o600, - }), - writeFile(`${dir}/final-screen.txt`, finalScreen, { mode: 0o600 }), - writeFile(`${dir}/failure.txt`, failure, { mode: 0o600 }), - ]); - return dir; - } catch (cause) { - throw new TraceWriteError(`Unable to write Ghostwright trace artifacts`, { cause }); - } - } + #events: TraceEvent[] = []; + #seq = 0; + readonly started = performance.now(); + readonly raw: Uint8Array[] = []; + constructor( + readonly options: TerminalLaunchOptions, + readonly policy: 'off' | 'retain-on-failure' | 'on', + readonly directory: string, + ) { + this.add('session-start'); + } + now() { + return performance.now() - this.started; + } + events() { + return [...this.#events]; + } + add(type: string, data: Record = {}) { + if (this.policy === 'off') return; + this.#events.push({ + schemaVersion: 1, + sequence: ++this.#seq, + timestamp: this.now(), + type, + ...data, + }); + if (this.#events.length > 10000) this.#events.shift(); + } + output(bytes: Uint8Array, frameSequence: number) { + if (this.policy === 'off') return; + const offset = this.raw.reduce((n, b) => n + b.length, 0); + this.raw.push(bytes.slice()); + this.add('output', { + frameSequence, + raw: { offset, length: bytes.length, direction: 'from-pty' }, + }); + } + input(bytes: Uint8Array, actionSequence: number, redacted = false) { + if (this.policy === 'off') return; + if (redacted) { + this.add('input', { actionSequence, redacted: true, length: bytes.length }); + return; + } + const offset = this.raw.reduce((total, part) => total + part.length, 0); + this.raw.push(bytes.slice()); + this.add('input', { + actionSequence, + raw: { offset, length: bytes.length, direction: 'to-pty' }, + }); + } + async persist(error: unknown, snapshot: ScreenSnapshot, status: ProcessStatus) { + if (this.policy === 'off') return undefined; + try { + const name = (this.options.name ?? 'session').replace(/[^a-zA-Z0-9_.-]/g, '-').slice(0, 60), + dir = resolve( + this.directory, + `${name}-${Date.now()}-${process.pid}-${randomBytes(3).toString('hex')}`, + ); + await mkdir(dir, { recursive: true, mode: 0o700 }); + const lockUrl = new URL( + import.meta.url.includes('/dist/') ? '../ghostty.lock.json' : '../../ghostty.lock.json', + import.meta.url, + ), + lock = JSON.parse(await readFile(lockUrl, 'utf8')), + target = `${process.platform}-${process.arch}`, + hostArtifact = lock.artifacts[`artifacts/pty-host-${target}`], + wasmArtifact = lock.artifacts['artifacts/ghostty-vt.wasm'], + redactedIndexes = + typeof this.options.trace === 'object' + ? new Set(this.options.trace.redactArgumentIndexes ?? []) + : new Set(), + deno = (globalThis as unknown as { Deno?: { version: { deno: string } } }).Deno, + bun = (globalThis as unknown as { Bun?: { version: string } }).Bun, + metadata = { + schemaVersion: 1, + sessionName: name, + startedAt: new Date().toISOString(), + runtime: { + name: deno ? 'deno' : bun ? 'bun' : 'node', + version: deno ? deno.version.deno : bun ? bun.version : process.version, + }, + platform: { os: process.platform, arch: process.arch }, + ghostwrightVersion: '0.1.0', + ghostty: { + repository: 'https://github.com/ghostty-org/ghostty', + commit: 'f8041e849b36efbbb9736b6ecf0ccfcb01d94e69', + wasmSha256: wasmArtifact.sha256, + }, + ptyHost: { protocolVersion: 1, target, sha256: hostArtifact.sha256 }, + profile: { + term: 'xterm-ghostty', + cellWidth: 10, + cellHeight: 20, + viewport: snapshot.viewport, + }, + command: this.options.command, + args: (this.options.args ?? []).map((argument, index) => + redactedIndexes.has(index) ? '' : argument, + ), + cwd: this.options.cwd, + explicitEnvironmentKeys: Object.keys(this.options.env ?? {}), + redactedEnvironmentKeys: Object.keys(this.options.env ?? {}).filter((k) => + /(password|passwd|secret|token|api[-_]?key|private[-_]?key|credential|auth)/i.test(k), + ), + }; + const screen = snapshot.lines.map((l) => l.text).join('\n'), + tens = Array.from({ length: snapshot.viewport.columns }, (_, column) => + column % 10 === 0 ? String(Math.floor(column / 10) % 10) : ' ', + ).join(''), + ones = Array.from({ length: snapshot.viewport.columns }, (_, column) => + String(column % 10), + ).join(''), + finalScreen = `viewport: ${snapshot.viewport.columns}x${snapshot.viewport.rows}\ncursor: (${snapshot.cursor.column},${snapshot.cursor.row}) visible=${snapshot.cursor.visible}\nbuffer: ${snapshot.activeBuffer}\n ${tens}\n ${ones}\n${snapshot.lines + .map((line) => `${String(line.row).padStart(3)} |${line.text}|`) + .join('\n')}\n`; + const failure = `${error instanceof Error ? (error.stack ?? error.message) : String(error)}\n\nviewport: ${snapshot.viewport.columns}x${snapshot.viewport.rows}\ncursor: (${snapshot.cursor.column},${snapshot.cursor.row}) visible=${snapshot.cursor.visible}\nbuffer: ${snapshot.activeBuffer}\nprocess: ${JSON.stringify(status)}\nartifact path: ${dir}\n\n${screen}\n`; + await Promise.all([ + writeFile(`${dir}/metadata.json`, JSON.stringify(metadata, null, 2), { mode: 0o600 }), + writeFile( + `${dir}/trace.jsonl`, + this.#events.map((e) => JSON.stringify(e)).join('\n') + '\n', + { mode: 0o600 }, + ), + writeFile(`${dir}/output.bin`, Buffer.concat(this.raw.map((b) => Buffer.from(b))), { + mode: 0o600, + }), + writeFile(`${dir}/final-screen.txt`, finalScreen, { mode: 0o600 }), + writeFile(`${dir}/failure.txt`, failure, { mode: 0o600 }), + ]); + return dir; + } catch (cause) { + throw new TraceWriteError(`Unable to write Ghostwright trace artifacts`, { cause }); + } + } } diff --git a/experiments/ghostwright/src/types.ts b/experiments/ghostwright/src/types.ts index e626a64..c179993 100644 --- a/experiments/ghostwright/src/types.ts +++ b/experiments/ghostwright/src/types.ts @@ -1,433 +1,433 @@ -import type { Operation } from "effection"; +import type { Operation } from 'effection'; export interface Viewport { - columns: number; - rows: number; - widthPixels?: number; - heightPixels?: number; + columns: number; + rows: number; + widthPixels?: number; + heightPixels?: number; } export interface CleanupOptions { - hangupGraceMs?: number; - terminateGraceMs?: number; - postExitDrainMs?: number; + hangupGraceMs?: number; + terminateGraceMs?: number; + postExitDrainMs?: number; } export interface HistoryOptions { - maxRevisions?: number; - maxRawBytes?: number; - maxDecodedBytes?: number; + maxRevisions?: number; + maxRawBytes?: number; + maxDecodedBytes?: number; } /** Per-active-screen Kitty image storage configuration. */ export interface GraphicsOptions { - /** Decoded-image storage cap in bytes. Defaults to 64 MiB; zero disables storage. */ - storageLimitBytes?: number; + /** Decoded-image storage cap in bytes. Defaults to 64 MiB; zero disables storage. */ + storageLimitBytes?: number; } -export type TracePolicy = "off" | "retain-on-failure" | "on"; +export type TracePolicy = 'off' | 'retain-on-failure' | 'on'; export interface TraceOptions { - policy?: TracePolicy; - directory?: string; - redactArgumentIndexes?: readonly number[]; + policy?: TracePolicy; + directory?: string; + redactArgumentIndexes?: readonly number[]; } export interface TerminalLaunchOptions { - command: string; - args?: readonly string[]; - cwd?: string; - env?: Readonly>; - viewport?: Viewport; - commandTimeoutMs?: number; - assertionTimeoutMs?: number; - settleMs?: number; - cleanup?: CleanupOptions; - history?: HistoryOptions; - graphics?: GraphicsOptions; - trace?: TracePolicy | TraceOptions; - name?: string; + command: string; + args?: readonly string[]; + cwd?: string; + env?: Readonly>; + viewport?: Viewport; + commandTimeoutMs?: number; + assertionTimeoutMs?: number; + settleMs?: number; + cleanup?: CleanupOptions; + history?: HistoryOptions; + graphics?: GraphicsOptions; + trace?: TracePolicy | TraceOptions; + name?: string; } export interface Point { - column: number; - row: number; + column: number; + row: number; } export interface Rect extends Point { - width: number; - height: number; + width: number; + height: number; } export interface ActionReceipt { - actionSequence: number; - screenSequenceBefore: number; - acknowledgedAt: number; - deliveredToChild: boolean; - bytesWritten: number; + actionSequence: number; + screenSequenceBefore: number; + acknowledgedAt: number; + deliveredToChild: boolean; + bytesWritten: number; } export type KeyName = - | "Enter" - | "Tab" - | "Escape" - | "Backspace" - | "Delete" - | "ArrowUp" - | "ArrowDown" - | "ArrowLeft" - | "ArrowRight" - | "Home" - | "End" - | "PageUp" - | "PageDown" - | `F${number}` - | string; + | 'Enter' + | 'Tab' + | 'Escape' + | 'Backspace' + | 'Delete' + | 'ArrowUp' + | 'ArrowDown' + | 'ArrowLeft' + | 'ArrowRight' + | 'Home' + | 'End' + | 'PageUp' + | 'PageDown' + | `F${number}` + | string; export interface KeyPress { - key: KeyName; - shift?: boolean; - control?: boolean; - alt?: boolean; - super?: boolean; + key: KeyName; + shift?: boolean; + control?: boolean; + alt?: boolean; + super?: boolean; } export interface TraceableInputOptions { - trace?: "record" | "redact"; + trace?: 'record' | 'redact'; } export interface MouseOptions { - button?: "left" | "middle" | "right" | number; - shift?: boolean; - control?: boolean; - alt?: boolean; - super?: boolean; + button?: 'left' | 'middle' | 'right' | number; + shift?: boolean; + control?: boolean; + alt?: boolean; + super?: boolean; } export interface WheelOptions extends Point { - deltaRows: number; - deltaColumns?: number; + deltaRows: number; + deltaColumns?: number; } export interface AssertionOptions { - timeoutMs?: number; + timeoutMs?: number; } export interface StableAssertionOptions extends AssertionOptions { - settleMs?: number; + settleMs?: number; } export interface TransientAssertionOptions extends AssertionOptions { - since?: ActionReceipt | number; + since?: ActionReceipt | number; } export interface TextLocatorOptions { - exact?: boolean; + exact?: boolean; } export interface LocatorMatch { - text: string; - range: Rect; - rowText: string; + text: string; + range: Rect; + rowText: string; } export interface ProcessStatus { - state: "starting" | "running" | "exited" | "closed" | "failed"; - pid?: number; - processGroupId?: number; - exitCode?: number | null; - signal?: string | null; - ptyEof: boolean; + state: 'starting' | 'running' | 'exited' | 'closed' | 'failed'; + pid?: number; + processGroupId?: number; + exitCode?: number | null; + signal?: string | null; + ptyEof: boolean; } export type TerminalColor = - | { kind: "default" } - | { kind: "palette"; index: number } - | { kind: "rgb"; red: number; green: number; blue: number }; + | { kind: 'default' } + | { kind: 'palette'; index: number } + | { kind: 'rgb'; red: number; green: number; blue: number }; export interface CellStyle { - bold: boolean; - italic: boolean; - faint: boolean; - blink: boolean; - inverse: boolean; - invisible: boolean; - strikethrough: boolean; - overline: boolean; - underline: number; - foreground: TerminalColor; - background: TerminalColor; - underlineColor?: TerminalColor; + bold: boolean; + italic: boolean; + faint: boolean; + blink: boolean; + inverse: boolean; + invisible: boolean; + strikethrough: boolean; + overline: boolean; + underline: number; + foreground: TerminalColor; + background: TerminalColor; + underlineColor?: TerminalColor; } export interface ScreenCell { - column: number; - text: string; - width: 0 | 1 | 2; - continuation: boolean; - style: CellStyle; - selected: boolean; - hyperlink?: string; + column: number; + text: string; + width: 0 | 1 | 2; + continuation: boolean; + style: CellStyle; + selected: boolean; + hyperlink?: string; } export interface ScreenLine { - row: number; - cells: readonly ScreenCell[]; - wrapped: boolean; - text: string; + row: number; + cells: readonly ScreenCell[]; + wrapped: boolean; + text: string; } export interface CursorSnapshot extends Point { - visible: boolean; - shape: "block" | "bar" | "underline"; - blinking: boolean; + visible: boolean; + shape: 'block' | 'bar' | 'underline'; + blinking: boolean; } export interface TerminalModes { - applicationCursorKeys: boolean; - backarrowSendsBackspace: boolean; - bracketedPaste: boolean; - focusReporting: boolean; - mouseTracking: "none" | "x10" | "normal" | "button" | "any"; - mouseFormat: "x10" | "utf8" | "sgr" | "urxvt" | "sgr-pixels"; - kittyKeyboardFlags: number; - alternateScreen: boolean; - privateModes: Readonly>; + applicationCursorKeys: boolean; + backarrowSendsBackspace: boolean; + bracketedPaste: boolean; + focusReporting: boolean; + mouseTracking: 'none' | 'x10' | 'normal' | 'button' | 'any'; + mouseFormat: 'x10' | 'utf8' | 'sgr' | 'urxvt' | 'sgr-pixels'; + kittyKeyboardFlags: number; + alternateScreen: boolean; + privateModes: Readonly>; } export interface KittyImageSnapshot { - id: number; - number: number; - generation: string; - width: number; - height: number; - format: "rgb" | "rgba" | "gray" | "gray-alpha"; - compression: "none"; - dataLength: number; - sha256: string; + id: number; + number: number; + generation: string; + width: number; + height: number; + format: 'rgb' | 'rgba' | 'gray' | 'gray-alpha'; + compression: 'none'; + dataLength: number; + sha256: string; } export interface KittyPlacementSnapshot { - imageId: number; - placementId: number; - imageGeneration: string; - image?: KittyImageSnapshot; - virtual: boolean; - z: number; - layer: "below-background" | "below-text" | "above-text"; - offset: { xPixels: number; yPixels: number }; - requestedGrid: { columns: number; rows: number }; - renderedGrid?: { columns: number; rows: number }; - renderedPixels?: { width: number; height: number }; - viewport: { column?: number; row?: number; visible: boolean }; - source: { x: number; y: number; width: number; height: number }; + imageId: number; + placementId: number; + imageGeneration: string; + image?: KittyImageSnapshot; + virtual: boolean; + z: number; + layer: 'below-background' | 'below-text' | 'above-text'; + offset: { xPixels: number; yPixels: number }; + requestedGrid: { columns: number; rows: number }; + renderedGrid?: { columns: number; rows: number }; + renderedPixels?: { width: number; height: number }; + viewport: { column?: number; row?: number; visible: boolean }; + source: { x: number; y: number; width: number; height: number }; } export interface KittyGraphicsSnapshot { - supported: boolean; - generation: string; - storageLimitBytes: number; - placements: readonly KittyPlacementSnapshot[]; + supported: boolean; + generation: string; + storageLimitBytes: number; + placements: readonly KittyPlacementSnapshot[]; } export interface ScreenSnapshot { - sequence: number; - timestamp: number; - lastVisualChangeAt: number; - viewport: Required; - activeBuffer: "primary" | "alternate"; - /** Renderer-ready Kitty state for the active screen only. */ - graphics: KittyGraphicsSnapshot; - cursor: CursorSnapshot; - lines: readonly ScreenLine[]; - modes: TerminalModes; - title: string; - workingDirectory?: string; + sequence: number; + timestamp: number; + lastVisualChangeAt: number; + viewport: Required; + activeBuffer: 'primary' | 'alternate'; + /** Renderer-ready Kitty state for the active screen only. */ + graphics: KittyGraphicsSnapshot; + cursor: CursorSnapshot; + lines: readonly ScreenLine[]; + modes: TerminalModes; + title: string; + workingDirectory?: string; } export interface ScreenRevision { - sequence: number; - timestamp: number; - cause: "pty-output" | "resize" | "reset"; - sourceFrameSequence?: number; - changedRows: readonly number[]; - visualChange: boolean; - snapshot: ScreenSnapshot; + sequence: number; + timestamp: number; + cause: 'pty-output' | 'resize' | 'reset'; + sourceFrameSequence?: number; + changedRows: readonly number[]; + visualChange: boolean; + snapshot: ScreenSnapshot; } export interface RevisionRangeQuery { - /** Exclusive action/revision baseline. */ - since: ActionReceipt | ScreenRevision | number; - /** Inclusive revision endpoint. */ - until?: ScreenRevision | number; - limit?: number; + /** Exclusive action/revision baseline. */ + since: ActionReceipt | ScreenRevision | number; + /** Inclusive revision endpoint. */ + until?: ScreenRevision | number; + limit?: number; } export interface RevisionCollectionOptions { - since: ActionReceipt | ScreenRevision | number; - until(snapshot: ScreenSnapshot, revision: ScreenRevision): boolean; - timeoutMs?: number; - maxRevisions?: number; + since: ActionReceipt | ScreenRevision | number; + until(snapshot: ScreenSnapshot, revision: ScreenRevision): boolean; + timeoutMs?: number; + maxRevisions?: number; } export interface RevisionCollection { - baselineSequence: number; - startedAt: number; - completedAt: number; - revisions: readonly ScreenRevision[]; + baselineSequence: number; + startedAt: number; + completedAt: number; + revisions: readonly ScreenRevision[]; } export interface HistoryQuery { - direction?: "oldest-first" | "newest-first"; - /** Zero-based index from the oldest currently retained scrollback row. */ - start?: number; - count?: number; - expectedGeneration?: string; -} -export interface HistoryLine extends Omit { - /** Zero-based index from the oldest currently retained scrollback row. */ - index: number; - wrapContinuation: boolean; - kittyVirtualPlaceholder: boolean; + direction?: 'oldest-first' | 'newest-first'; + /** Zero-based index from the oldest currently retained scrollback row. */ + start?: number; + count?: number; + expectedGeneration?: string; +} +export interface HistoryLine extends Omit { + /** Zero-based index from the oldest currently retained scrollback row. */ + index: number; + wrapContinuation: boolean; + kittyVirtualPlaceholder: boolean; } export interface HistoryRange { - generation: string; - totalRows: number; - start: number; - direction: "oldest-first" | "newest-first"; - lines: readonly HistoryLine[]; + generation: string; + totalRows: number; + start: number; + direction: 'oldest-first' | 'newest-first'; + lines: readonly HistoryLine[]; } export interface HistorySearchOptions { - direction?: "oldest-first" | "newest-first"; - start?: number; - maxRows?: number; - limit?: number; - expectedGeneration?: string; + direction?: 'oldest-first' | 'newest-first'; + start?: number; + maxRows?: number; + limit?: number; + expectedGeneration?: string; } export interface HistoryMatch { - lineIndex: number; - text: string; - range: Rect; - line: HistoryLine; + lineIndex: number; + text: string; + range: Rect; + line: HistoryLine; } export interface CellChange { - point: Point; - before: ScreenCell; - after: ScreenCell; + point: Point; + before: ScreenCell; + after: ScreenCell; } export interface ScreenReader { - current(): ScreenSnapshot; - getCell(point: Point): ScreenCell; - getText(rect?: Rect): string; - changedCells(since: ScreenSnapshot | number): readonly CellChange[]; - rawOutput(): Uint8Array; - /** Retained revision query. `since` is exclusive and `until` is inclusive. */ - revisions(query: RevisionRangeQuery): readonly ScreenRevision[]; - /** Cache-only image lookup; it never dereferences a live WASM graphics handle. */ - getKittyImage(id: number): KittyImageSnapshot | undefined; - scrollback(): readonly ScreenLine[]; - clipboard(): string; + current(): ScreenSnapshot; + getCell(point: Point): ScreenCell; + getText(rect?: Rect): string; + changedCells(since: ScreenSnapshot | number): readonly CellChange[]; + rawOutput(): Uint8Array; + /** Retained revision query. `since` is exclusive and `until` is inclusive. */ + revisions(query: RevisionRangeQuery): readonly ScreenRevision[]; + /** Cache-only image lookup; it never dereferences a live WASM graphics handle. */ + getKittyImage(id: number): KittyImageSnapshot | undefined; + scrollback(): readonly ScreenLine[]; + clipboard(): string; } export interface AsyncRevisionObserver { - collect(options: RevisionCollectionOptions): Promise; + collect(options: RevisionCollectionOptions): Promise; } export interface OperationRevisionObserver { - collect(options: RevisionCollectionOptions): Operation; + collect(options: RevisionCollectionOptions): Operation; } export interface AsyncHistoryReader { - read(query?: HistoryQuery): Promise; - findText(text: string, options?: HistorySearchOptions): Promise; + read(query?: HistoryQuery): Promise; + findText(text: string, options?: HistorySearchOptions): Promise; } export interface OperationHistoryReader { - read(query?: HistoryQuery): Operation; - findText(text: string, options?: HistorySearchOptions): Operation; + read(query?: HistoryQuery): Operation; + findText(text: string, options?: HistorySearchOptions): Operation; } export interface AsyncGraphicsReader { - inspectImage(id: number): Promise; - copyImageData(id: number): Promise; + inspectImage(id: number): Promise; + copyImageData(id: number): Promise; } export interface OperationGraphicsReader { - inspectImage(id: number): Operation; - copyImageData(id: number): Operation; + inspectImage(id: number): Operation; + copyImageData(id: number): Operation; } export interface AsyncLocator { - nth(index: number): AsyncLocator; - region(rect: Rect): AsyncLocator; - matches(): readonly LocatorMatch[]; - click(options?: MouseOptions): Promise; + nth(index: number): AsyncLocator; + region(rect: Rect): AsyncLocator; + matches(): readonly LocatorMatch[]; + click(options?: MouseOptions): Promise; } export interface OperationLocator { - nth(index: number): OperationLocator; - region(rect: Rect): OperationLocator; - matches(): readonly LocatorMatch[]; - click(options?: MouseOptions): Operation; + nth(index: number): OperationLocator; + region(rect: Rect): OperationLocator; + matches(): readonly LocatorMatch[]; + click(options?: MouseOptions): Operation; } export interface AsyncRegion { - getByText(text: string, options?: TextLocatorOptions): AsyncLocator; - snapshot(): ScreenSnapshot; + getByText(text: string, options?: TextLocatorOptions): AsyncLocator; + snapshot(): ScreenSnapshot; } export interface OperationRegion { - getByText(text: string, options?: TextLocatorOptions): OperationLocator; - snapshot(): ScreenSnapshot; + getByText(text: string, options?: TextLocatorOptions): OperationLocator; + snapshot(): ScreenSnapshot; } export interface AsyncTerminal { - readonly keyboard: { - press(key: KeyName | KeyPress): Promise; - type(text: string, options?: TraceableInputOptions): Promise; - paste(text: string, options?: TraceableInputOptions): Promise; - focus(state: "in" | "out"): Promise; - write(data: Uint8Array): Promise; - }; - readonly mouse: { - move(point: Point, options?: MouseOptions): Promise; - down(point: Point, options?: MouseOptions): Promise; - up(point: Point, options?: MouseOptions): Promise; - click(point: Point, options?: MouseOptions): Promise; - doubleClick(point: Point, options?: MouseOptions): Promise; - drag(start: Point, end: Point, options?: MouseOptions): Promise; - wheel(options: WheelOptions): Promise; - }; - readonly process: { - status(): ProcessStatus; - signal(signal: string, target?: "child" | "process-group"): Promise; - waitForExit(options?: AssertionOptions): Promise; - }; - readonly screen: ScreenReader; - readonly revisions: AsyncRevisionObserver; - readonly history: AsyncHistoryReader; - readonly graphics: AsyncGraphicsReader; - getByText(text: string, options?: TextLocatorOptions): AsyncLocator; - region(rect: Rect): AsyncRegion; - resize(viewport: Viewport): Promise; - close(): Promise; + readonly keyboard: { + press(key: KeyName | KeyPress): Promise; + type(text: string, options?: TraceableInputOptions): Promise; + paste(text: string, options?: TraceableInputOptions): Promise; + focus(state: 'in' | 'out'): Promise; + write(data: Uint8Array): Promise; + }; + readonly mouse: { + move(point: Point, options?: MouseOptions): Promise; + down(point: Point, options?: MouseOptions): Promise; + up(point: Point, options?: MouseOptions): Promise; + click(point: Point, options?: MouseOptions): Promise; + doubleClick(point: Point, options?: MouseOptions): Promise; + drag(start: Point, end: Point, options?: MouseOptions): Promise; + wheel(options: WheelOptions): Promise; + }; + readonly process: { + status(): ProcessStatus; + signal(signal: string, target?: 'child' | 'process-group'): Promise; + waitForExit(options?: AssertionOptions): Promise; + }; + readonly screen: ScreenReader; + readonly revisions: AsyncRevisionObserver; + readonly history: AsyncHistoryReader; + readonly graphics: AsyncGraphicsReader; + getByText(text: string, options?: TextLocatorOptions): AsyncLocator; + region(rect: Rect): AsyncRegion; + resize(viewport: Viewport): Promise; + close(): Promise; } export interface OperationTerminal { - readonly keyboard: { - press(key: KeyName | KeyPress): Operation; - type(text: string, options?: TraceableInputOptions): Operation; - paste(text: string, options?: TraceableInputOptions): Operation; - focus(state: "in" | "out"): Operation; - write(data: Uint8Array): Operation; - }; - readonly mouse: { - move(point: Point, options?: MouseOptions): Operation; - down(point: Point, options?: MouseOptions): Operation; - up(point: Point, options?: MouseOptions): Operation; - click(point: Point, options?: MouseOptions): Operation; - doubleClick(point: Point, options?: MouseOptions): Operation; - drag(start: Point, end: Point, options?: MouseOptions): Operation; - wheel(options: WheelOptions): Operation; - }; - readonly process: { - status(): ProcessStatus; - signal(signal: string, target?: "child" | "process-group"): Operation; - waitForExit(options?: AssertionOptions): Operation; - }; - readonly screen: ScreenReader; - readonly revisions: OperationRevisionObserver; - readonly history: OperationHistoryReader; - readonly graphics: OperationGraphicsReader; - getByText(text: string, options?: TextLocatorOptions): OperationLocator; - region(rect: Rect): OperationRegion; - resize(viewport: Viewport): Operation; - close(): Operation; + readonly keyboard: { + press(key: KeyName | KeyPress): Operation; + type(text: string, options?: TraceableInputOptions): Operation; + paste(text: string, options?: TraceableInputOptions): Operation; + focus(state: 'in' | 'out'): Operation; + write(data: Uint8Array): Operation; + }; + readonly mouse: { + move(point: Point, options?: MouseOptions): Operation; + down(point: Point, options?: MouseOptions): Operation; + up(point: Point, options?: MouseOptions): Operation; + click(point: Point, options?: MouseOptions): Operation; + doubleClick(point: Point, options?: MouseOptions): Operation; + drag(start: Point, end: Point, options?: MouseOptions): Operation; + wheel(options: WheelOptions): Operation; + }; + readonly process: { + status(): ProcessStatus; + signal(signal: string, target?: 'child' | 'process-group'): Operation; + waitForExit(options?: AssertionOptions): Operation; + }; + readonly screen: ScreenReader; + readonly revisions: OperationRevisionObserver; + readonly history: OperationHistoryReader; + readonly graphics: OperationGraphicsReader; + getByText(text: string, options?: TextLocatorOptions): OperationLocator; + region(rect: Rect): OperationRegion; + resize(viewport: Viewport): Operation; + close(): Operation; } export interface OperationLocatorExpectation { - toBePresent(options?: AssertionOptions): Operation; - toBeAbsent(options?: StableAssertionOptions): Operation; - toBeStable(options?: StableAssertionOptions): Operation; + toBePresent(options?: AssertionOptions): Operation; + toBeAbsent(options?: StableAssertionOptions): Operation; + toBeStable(options?: StableAssertionOptions): Operation; } export interface AsyncLocatorExpectation { - toBePresent(options?: AssertionOptions): Promise; - toBeAbsent(options?: StableAssertionOptions): Promise; - toBeStable(options?: StableAssertionOptions): Promise; + toBePresent(options?: AssertionOptions): Promise; + toBeAbsent(options?: StableAssertionOptions): Promise; + toBeStable(options?: StableAssertionOptions): Promise; } export interface OperationTerminalExpectation { - toSatisfy( - predicate: (snapshot: ScreenSnapshot) => boolean, - options?: StableAssertionOptions, - ): Operation; - toHaveShown( - predicate: (snapshot: ScreenSnapshot) => boolean, - options?: TransientAssertionOptions, - ): Operation; - toHaveShownText(text: string, options?: TransientAssertionOptions): Operation; + toSatisfy( + predicate: (snapshot: ScreenSnapshot) => boolean, + options?: StableAssertionOptions, + ): Operation; + toHaveShown( + predicate: (snapshot: ScreenSnapshot) => boolean, + options?: TransientAssertionOptions, + ): Operation; + toHaveShownText(text: string, options?: TransientAssertionOptions): Operation; } export interface AsyncTerminalExpectation { - toSatisfy( - predicate: (snapshot: ScreenSnapshot) => boolean, - options?: StableAssertionOptions, - ): Promise; - toHaveShown( - predicate: (snapshot: ScreenSnapshot) => boolean, - options?: TransientAssertionOptions, - ): Promise; - toHaveShownText(text: string, options?: TransientAssertionOptions): Promise; + toSatisfy( + predicate: (snapshot: ScreenSnapshot) => boolean, + options?: StableAssertionOptions, + ): Promise; + toHaveShown( + predicate: (snapshot: ScreenSnapshot) => boolean, + options?: TransientAssertionOptions, + ): Promise; + toHaveShownText(text: string, options?: TransientAssertionOptions): Promise; } diff --git a/experiments/ghostwright/test/assertions-trace.test.ts b/experiments/ghostwright/test/assertions-trace.test.ts index fba4913..5be6b95 100644 --- a/experiments/ghostwright/test/assertions-trace.test.ts +++ b/experiments/ghostwright/test/assertions-trace.test.ts @@ -1,168 +1,168 @@ -import { mkdtemp, readFile, readdir, rm, stat } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { expect, test } from "bun:test"; +import { mkdtemp, readFile, readdir, rm, stat } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { expect, test } from 'bun:test'; import { - expectTerminal, - HistoryEvictedError, - replayTrace, - StrictLocatorError, - TerminalAssertionError, - withTerminalAsync, -} from "../src/index.ts"; + expectTerminal, + HistoryEvictedError, + replayTrace, + StrictLocatorError, + TerminalAssertionError, + withTerminalAsync, +} from '../src/index.ts'; const node = process.execPath; -test("lazy locators preserve wide-cell geometry and strictness", async () => { - await withTerminalAsync( - { - command: node, - args: ["-e", `setTimeout(() => process.stdout.write("文字 unique duplicate duplicate"), 20)`], - viewport: { columns: 50, rows: 4 }, - trace: "off", - }, - async (terminal) => { - const wide = terminal.getByText("文字"); - const match = await expectTerminal(wide).toBePresent(); - expect(match.range).toEqual({ column: 0, row: 0, width: 4, height: 1 }); - await expect(terminal.getByText("duplicate").click()).rejects.toBeInstanceOf( - StrictLocatorError, - ); - expect(terminal.getByText("duplicate").nth(1).matches()[0].range.column).toBeGreaterThan( - terminal.getByText("duplicate").nth(0).matches()[0].range.column, - ); - }, - ); +test('lazy locators preserve wide-cell geometry and strictness', async () => { + await withTerminalAsync( + { + command: node, + args: ['-e', `setTimeout(() => process.stdout.write("文字 unique duplicate duplicate"), 20)`], + viewport: { columns: 50, rows: 4 }, + trace: 'off', + }, + async (terminal) => { + const wide = terminal.getByText('文字'); + const match = await expectTerminal(wide).toBePresent(); + expect(match.range).toEqual({ column: 0, row: 0, width: 4, height: 1 }); + await expect(terminal.getByText('duplicate').click()).rejects.toBeInstanceOf( + StrictLocatorError, + ); + expect(terminal.getByText('duplicate').nth(1).matches()[0].range.column).toBeGreaterThan( + terminal.getByText('duplicate').nth(0).matches()[0].range.column, + ); + }, + ); }); -test("visual stability uses the existing visual-change timestamp", async () => { - await withTerminalAsync( - { - command: node, - args: ["-e", `process.stdout.write("stable"); setTimeout(() => {}, 250)`], - trace: "off", - }, - async (terminal) => { - await expectTerminal(terminal.getByText("stable")).toBePresent(); - await new Promise((resolve) => setTimeout(resolve, 120)); - const started = performance.now(); - await expectTerminal(terminal.getByText("stable")).toBeStable({ settleMs: 100 }); - expect(performance.now() - started).toBeLessThan(50); - }, - ); +test('visual stability uses the existing visual-change timestamp', async () => { + await withTerminalAsync( + { + command: node, + args: ['-e', `process.stdout.write("stable"); setTimeout(() => {}, 250)`], + trace: 'off', + }, + async (terminal) => { + await expectTerminal(terminal.getByText('stable')).toBePresent(); + await new Promise((resolve) => setTimeout(resolve, 120)); + const started = performance.now(); + await expectTerminal(terminal.getByText('stable')).toBeStable({ settleMs: 100 }); + expect(performance.now() - started).toBeLessThan(50); + }, + ); }); -test("history eviction is explicit", async () => { - await withTerminalAsync( - { - command: node, - args: [ - "-e", - `let i=0; const timer=setInterval(() => { process.stdout.write("\\r" + i++); if(i===8){clearInterval(timer);setTimeout(()=>process.exit(),20)} }, 15)`, - ], - history: { maxRevisions: 2 }, - trace: "off", - }, - async (terminal) => { - const receipt = await terminal.resize({ columns: 80, rows: 24 }); - await terminal.process.waitForExit(); - await expect( - expectTerminal(terminal).toHaveShownText("never", { since: receipt, timeoutMs: 10 }), - ).rejects.toBeInstanceOf(HistoryEvictedError); - }, - ); +test('history eviction is explicit', async () => { + await withTerminalAsync( + { + command: node, + args: [ + '-e', + `let i=0; const timer=setInterval(() => { process.stdout.write("\\r" + i++); if(i===8){clearInterval(timer);setTimeout(()=>process.exit(),20)} }, 15)`, + ], + history: { maxRevisions: 2 }, + trace: 'off', + }, + async (terminal) => { + const receipt = await terminal.resize({ columns: 80, rows: 24 }); + await terminal.process.waitForExit(); + await expect( + expectTerminal(terminal).toHaveShownText('never', { since: receipt, timeoutMs: 10 }), + ).rejects.toBeInstanceOf(HistoryEvictedError); + }, + ); }); -test("trace-on artifacts replay the same final terminal state", async () => { - const directory = await mkdtemp(join(tmpdir(), "ghostwright-replay-test-")); - try { - let expected = ""; - await withTerminalAsync( - { - command: node, - args: ["-e", `process.stdout.write("first\\rsecond")`], - trace: { policy: "on", directory }, - }, - async (terminal) => { - await terminal.process.waitForExit(); - expected = terminal.screen.getText(); - }, - ); - const [artifact] = await readdir(directory), - replay = await replayTrace(join(directory, artifact)); - expect(replay.finalSnapshot.lines.map((line) => line.text).join("\n")).toBe(expected); - expect(replay.revisions.length).toBeGreaterThan(0); - } finally { - await rm(directory, { recursive: true, force: true }); - } +test('trace-on artifacts replay the same final terminal state', async () => { + const directory = await mkdtemp(join(tmpdir(), 'ghostwright-replay-test-')); + try { + let expected = ''; + await withTerminalAsync( + { + command: node, + args: ['-e', `process.stdout.write("first\\rsecond")`], + trace: { policy: 'on', directory }, + }, + async (terminal) => { + await terminal.process.waitForExit(); + expected = terminal.screen.getText(); + }, + ); + const [artifact] = await readdir(directory), + replay = await replayTrace(join(directory, artifact)); + expect(replay.finalSnapshot.lines.map((line) => line.text).join('\n')).toBe(expected); + expect(replay.revisions.length).toBeGreaterThan(0); + } finally { + await rm(directory, { recursive: true, force: true }); + } }); -test("marked input is redacted from trace bytes", async () => { - const directory = await mkdtemp(join(tmpdir(), "ghostwright-redaction-test-")); - try { - await withTerminalAsync( - { - command: node, - args: [ - "-e", - `process.stdin.setRawMode(true); process.stdout.write("READY"); process.stdin.once("data", () => process.exit(0))`, - ], - trace: { policy: "on", directory }, - }, - async (terminal) => { - await expectTerminal(terminal.getByText("READY")).toBePresent(); - await terminal.keyboard.type("synthetic-input-secret", { trace: "redact" }); - await terminal.process.waitForExit(); - }, - ); - const [artifact] = await readdir(directory), - path = join(directory, artifact), - trace = await readFile(join(path, "trace.jsonl"), "utf8"), - raw = await readFile(join(path, "output.bin")); - expect(trace).toContain('"redacted":true'); - expect(trace).not.toContain("synthetic-input-secret"); - expect(raw.includes(Buffer.from("synthetic-input-secret"))).toBe(false); - } finally { - await rm(directory, { recursive: true, force: true }); - } +test('marked input is redacted from trace bytes', async () => { + const directory = await mkdtemp(join(tmpdir(), 'ghostwright-redaction-test-')); + try { + await withTerminalAsync( + { + command: node, + args: [ + '-e', + `process.stdin.setRawMode(true); process.stdout.write("READY"); process.stdin.once("data", () => process.exit(0))`, + ], + trace: { policy: 'on', directory }, + }, + async (terminal) => { + await expectTerminal(terminal.getByText('READY')).toBePresent(); + await terminal.keyboard.type('synthetic-input-secret', { trace: 'redact' }); + await terminal.process.waitForExit(); + }, + ); + const [artifact] = await readdir(directory), + path = join(directory, artifact), + trace = await readFile(join(path, 'trace.jsonl'), 'utf8'), + raw = await readFile(join(path, 'output.bin')); + expect(trace).toContain('"redacted":true'); + expect(trace).not.toContain('synthetic-input-secret'); + expect(raw.includes(Buffer.from('synthetic-input-secret'))).toBe(false); + } finally { + await rm(directory, { recursive: true, force: true }); + } }); -test("retain-on-failure writes private complete artifacts and preserves the primary error", async () => { - const directory = await mkdtemp(join(tmpdir(), "ghostwright-trace-test-")); - try { - let failure: unknown; - try { - await withTerminalAsync( - { - command: node, - args: ["-e", `process.stdout.write("actual")`], - trace: { policy: "retain-on-failure", directory, redactArgumentIndexes: [1] }, - env: { API_TOKEN: "synthetic-secret" }, - name: "trace test", - }, - async (terminal) => { - await expectTerminal(terminal.getByText("missing")).toBePresent({ timeoutMs: 30 }); - }, - ); - } catch (error) { - failure = error; - } - expect(failure).toBeInstanceOf(TerminalAssertionError); - const tracePath = (failure as TerminalAssertionError).tracePath!; - expect(tracePath).toBeTruthy(); - expect((failure as Error).message).toContain("trace artifact:"); - expect((await readdir(tracePath)).sort()).toEqual( - ["failure.txt", "final-screen.txt", "metadata.json", "output.bin", "trace.jsonl"].sort(), - ); - const metadata = await readFile(join(tracePath, "metadata.json"), "utf8"), - trace = await readFile(join(tracePath, "trace.jsonl"), "utf8"), - mode = (await stat(tracePath)).mode & 0o777; - expect(mode).toBe(0o700); - expect(metadata).not.toContain("synthetic-secret"); - expect(metadata).toContain('""'); - expect(metadata).toContain('"wasmSha256"'); - expect(trace).toContain('"type":"session-start"'); - } finally { - await rm(directory, { recursive: true, force: true }); - } +test('retain-on-failure writes private complete artifacts and preserves the primary error', async () => { + const directory = await mkdtemp(join(tmpdir(), 'ghostwright-trace-test-')); + try { + let failure: unknown; + try { + await withTerminalAsync( + { + command: node, + args: ['-e', `process.stdout.write("actual")`], + trace: { policy: 'retain-on-failure', directory, redactArgumentIndexes: [1] }, + env: { API_TOKEN: 'synthetic-secret' }, + name: 'trace test', + }, + async (terminal) => { + await expectTerminal(terminal.getByText('missing')).toBePresent({ timeoutMs: 30 }); + }, + ); + } catch (error) { + failure = error; + } + expect(failure).toBeInstanceOf(TerminalAssertionError); + const tracePath = (failure as TerminalAssertionError).tracePath!; + expect(tracePath).toBeTruthy(); + expect((failure as Error).message).toContain('trace artifact:'); + expect((await readdir(tracePath)).sort()).toEqual( + ['failure.txt', 'final-screen.txt', 'metadata.json', 'output.bin', 'trace.jsonl'].sort(), + ); + const metadata = await readFile(join(tracePath, 'metadata.json'), 'utf8'), + trace = await readFile(join(tracePath, 'trace.jsonl'), 'utf8'), + mode = (await stat(tracePath)).mode & 0o777; + expect(mode).toBe(0o700); + expect(metadata).not.toContain('synthetic-secret'); + expect(metadata).toContain('""'); + expect(metadata).toContain('"wasmSha256"'); + expect(trace).toContain('"type":"session-start"'); + } finally { + await rm(directory, { recursive: true, force: true }); + } }); diff --git a/experiments/ghostwright/test/conformance.test.ts b/experiments/ghostwright/test/conformance.test.ts index 785347c..ab95608 100644 --- a/experiments/ghostwright/test/conformance.test.ts +++ b/experiments/ghostwright/test/conformance.test.ts @@ -1,22 +1,22 @@ -import { expect, test } from "bun:test"; +import { expect, test } from 'bun:test'; import { - expectTerminal, - LaunchError, - ReservedEnvironmentError, - withTerminalAsync, -} from "../src/index.ts"; + expectTerminal, + LaunchError, + ReservedEnvironmentError, + withTerminalAsync, +} from '../src/index.ts'; const node = process.execPath; function evalArgs(source: string): string[] { - return ["-e", source]; + return ['-e', source]; } -test("profile, TTY descriptors, geometry, styles, Unicode, and clipboard use Ghostty state", async () => { - await withTerminalAsync( - { - command: node, - args: evalArgs(` +test('profile, TTY descriptors, geometry, styles, Unicode, and clipboard use Ghostty state', async () => { + await withTerminalAsync( + { + command: node, + args: evalArgs(` const fs = require("node:fs"); process.stdout.write( JSON.stringify({ tty: [0,1,2].map(fd => fs.fstatSync(fd).isCharacterDevice()), size: [process.stdout.rows, process.stdout.columns], term: process.env.TERM }) + "\\r\\n" + @@ -24,42 +24,42 @@ test("profile, TTY descriptors, geometry, styles, Unicode, and clipboard use Gho "\\x1b]52;c;c2Vzc2lvbi1jbGlwYm9hcmQ=\\x07" ); `), - viewport: { columns: 60, rows: 8 }, - trace: "off", - }, - async (terminal) => { - await terminal.process.waitForExit(); - const text = terminal.screen.getText(); - expect(text).toContain('"tty":[true,true,true]'); - expect(text).toContain('"size":[8,60]'); - expect(text).toContain('"term":"xterm-ghostty"'); - const styled = terminal.screen - .current() - .lines.flatMap((line) => line.cells) - .find((cell) => cell.text === "A")!; - expect(styled.style.foreground).toEqual({ kind: "palette", index: 42 }); - expect(styled.style.background).toEqual({ kind: "rgb", red: 1, green: 2, blue: 3 }); - expect(terminal.screen.clipboard()).toBe("session-clipboard"); - const wideLine = terminal.screen - .current() - .lines.find((line) => line.cells.some((cell) => cell.text === "文"))!, - wide = wideLine.cells.find((cell) => cell.text === "文"); - expect(wide?.width).toBe(2); - expect(wideLine.cells[wide!.column + 1].continuation).toBe(true); - const combining = terminal.screen - .current() - .lines.flatMap((line) => line.cells) - .find((cell) => cell.text === "é"); - expect(combining?.width).toBe(1); - }, - ); + viewport: { columns: 60, rows: 8 }, + trace: 'off', + }, + async (terminal) => { + await terminal.process.waitForExit(); + const text = terminal.screen.getText(); + expect(text).toContain('"tty":[true,true,true]'); + expect(text).toContain('"size":[8,60]'); + expect(text).toContain('"term":"xterm-ghostty"'); + const styled = terminal.screen + .current() + .lines.flatMap((line) => line.cells) + .find((cell) => cell.text === 'A')!; + expect(styled.style.foreground).toEqual({ kind: 'palette', index: 42 }); + expect(styled.style.background).toEqual({ kind: 'rgb', red: 1, green: 2, blue: 3 }); + expect(terminal.screen.clipboard()).toBe('session-clipboard'); + const wideLine = terminal.screen + .current() + .lines.find((line) => line.cells.some((cell) => cell.text === '文'))!, + wide = wideLine.cells.find((cell) => cell.text === '文'); + expect(wide?.width).toBe(2); + expect(wideLine.cells[wide!.column + 1].continuation).toBe(true); + const combining = terminal.screen + .current() + .lines.flatMap((line) => line.cells) + .find((cell) => cell.text === 'é'); + expect(combining?.width).toBe(1); + }, + ); }); -test("Ghostty effects answer DA, size, color-scheme, ENQ, and XTVERSION queries", async () => { - await withTerminalAsync( - { - command: node, - args: evalArgs(` +test('Ghostty effects answer DA, size, color-scheme, ENQ, and XTVERSION queries', async () => { + await withTerminalAsync( + { + command: node, + args: evalArgs(` process.stdin.setRawMode(true); const chunks = []; let timer; @@ -73,28 +73,28 @@ test("Ghostty effects answer DA, size, color-scheme, ENQ, and XTVERSION queries" }); process.stdout.write("\\x05\\x1b[c\\x1b[>q\\x1b[18t\\x1b[?996n"); `), - viewport: { columns: 80, rows: 10 }, - trace: "off", - }, - async (terminal) => { - await terminal.process.waitForExit({ timeoutMs: 2_000 }); - const text = terminal.screen.getText().replace(/\s/g, ""); - expect(text).toContain(Buffer.from("ghostwright").toString("hex")); - expect(text).toContain(Buffer.from("\x1b[?62;22c").toString("hex")); - expect(text).toContain( - Buffer.from("ghostwright/0.1.0 libghostty-vt/f8041e849b36").toString("hex"), - ); - expect(text).toContain(Buffer.from("\x1b[8;10;80t").toString("hex")); - expect(text).toContain(Buffer.from("\x1b[?997;1n").toString("hex")); - }, - ); + viewport: { columns: 80, rows: 10 }, + trace: 'off', + }, + async (terminal) => { + await terminal.process.waitForExit({ timeoutMs: 2_000 }); + const text = terminal.screen.getText().replace(/\s/g, ''); + expect(text).toContain(Buffer.from('ghostwright').toString('hex')); + expect(text).toContain(Buffer.from('\x1b[?62;22c').toString('hex')); + expect(text).toContain( + Buffer.from('ghostwright/0.1.0 libghostty-vt/f8041e849b36').toString('hex'), + ); + expect(text).toContain(Buffer.from('\x1b[8;10;80t').toString('hex')); + expect(text).toContain(Buffer.from('\x1b[?997;1n').toString('hex')); + }, + ); }); -test("mode-aware keyboard, paste, focus, mouse, and large raw input are acknowledged", async () => { - await withTerminalAsync( - { - command: node, - args: evalArgs(` +test('mode-aware keyboard, paste, focus, mouse, and large raw input are acknowledged', async () => { + await withTerminalAsync( + { + command: node, + args: evalArgs(` process.stdin.setRawMode(true); process.stdout.write("\\x1b[?1h\\x1b[?1004h\\x1b[?1003h\\x1b[?1006h\\x1b[?2004hREADY"); const chunks = []; @@ -108,57 +108,57 @@ test("mode-aware keyboard, paste, focus, mouse, and large raw input are acknowle } }); `), - viewport: { columns: 80, rows: 10 }, - trace: "off", - }, - async (terminal) => { - await expectTerminal(terminal.getByText("READY")).toBePresent(); - const receipts = [ - await terminal.keyboard.press("ArrowUp"), - await terminal.keyboard.focus("in"), - await terminal.keyboard.paste("paste"), - await terminal.mouse.move({ column: 2, row: 3 }), - await terminal.keyboard.write(new Uint8Array(70_000)), - ]; - expect(receipts.every((receipt) => receipt.deliveredToChild)).toBe(true); - expect(receipts.at(-1)?.bytesWritten).toBe(70_000); - await terminal.process.waitForExit({ timeoutMs: 2_000 }); - const expectedPrefix = Buffer.from( - "\x1bOA\x1b[I\x1b[200~paste\x1b[201~\x1b[<35;3;4M", - ).toString("hex"); - expect(terminal.screen.getText()).toContain(`PREFIX:${expectedPrefix}`); - expect(terminal.screen.getText()).toContain("TOTAL:70033"); - }, - ); + viewport: { columns: 80, rows: 10 }, + trace: 'off', + }, + async (terminal) => { + await expectTerminal(terminal.getByText('READY')).toBePresent(); + const receipts = [ + await terminal.keyboard.press('ArrowUp'), + await terminal.keyboard.focus('in'), + await terminal.keyboard.paste('paste'), + await terminal.mouse.move({ column: 2, row: 3 }), + await terminal.keyboard.write(new Uint8Array(70_000)), + ]; + expect(receipts.every((receipt) => receipt.deliveredToChild)).toBe(true); + expect(receipts.at(-1)?.bytesWritten).toBe(70_000); + await terminal.process.waitForExit({ timeoutMs: 2_000 }); + const expectedPrefix = Buffer.from( + '\x1bOA\x1b[I\x1b[200~paste\x1b[201~\x1b[<35;3;4M', + ).toString('hex'); + expect(terminal.screen.getText()).toContain(`PREFIX:${expectedPrefix}`); + expect(terminal.screen.getText()).toContain('TOTAL:70033'); + }, + ); }); -test("reserved profile environment and exec failures are typed", async () => { - await expect( - withTerminalAsync({ command: node, env: { TERM: "bad" }, trace: "off" }, async () => undefined), - ).rejects.toBeInstanceOf(ReservedEnvironmentError); - await expect( - withTerminalAsync({ command: "/definitely/missing", trace: "off" }, async () => undefined), - ).rejects.toBeInstanceOf(LaunchError); +test('reserved profile environment and exec failures are typed', async () => { + await expect( + withTerminalAsync({ command: node, env: { TERM: 'bad' }, trace: 'off' }, async () => undefined), + ).rejects.toBeInstanceOf(ReservedEnvironmentError); + await expect( + withTerminalAsync({ command: '/definitely/missing', trace: 'off' }, async () => undefined), + ).rejects.toBeInstanceOf(LaunchError); }); -test("parallel sessions own isolated WASM and PTY state", async () => { - const values = await Promise.all( - Array.from({ length: 8 }, (_, index) => - withTerminalAsync( - { - command: node, - args: evalArgs(`process.stdout.write("session-${index}")`), - trace: "off", - }, - async (terminal) => { - await terminal.process.waitForExit(); - return terminal.screen.getText(); - }, - ), - ), - ); - for (let index = 0; index < values.length; index++) { - expect(values[index]).toContain(`session-${index}`); - expect(values[index]).not.toContain(`session-${(index + 1) % values.length}`); - } +test('parallel sessions own isolated WASM and PTY state', async () => { + const values = await Promise.all( + Array.from({ length: 8 }, (_, index) => + withTerminalAsync( + { + command: node, + args: evalArgs(`process.stdout.write("session-${index}")`), + trace: 'off', + }, + async (terminal) => { + await terminal.process.waitForExit(); + return terminal.screen.getText(); + }, + ), + ), + ); + for (let index = 0; index < values.length; index++) { + expect(values[index]).toContain(`session-${index}`); + expect(values[index]).not.toContain(`session-${(index + 1) % values.length}`); + } }); diff --git a/experiments/ghostwright/test/effection.test.ts b/experiments/ghostwright/test/effection.test.ts index 882c11c..3f3f943 100644 --- a/experiments/ghostwright/test/effection.test.ts +++ b/experiments/ghostwright/test/effection.test.ts @@ -1,34 +1,34 @@ -import { expect, test } from "bun:test"; -import { run } from "effection"; -import { expectTerminal, withTerminal } from "../src"; -test("Effection facade shares scoped session behavior", async () => { - const result = await run(function* () { - return yield* withTerminal( - { command: "/bin/sh", args: ["-c", "printf generator"], trace: "off" }, - function* (terminal) { - const match = yield* expectTerminal(terminal.getByText("generator")).toBePresent(); - return match.text; - }, - ); - }); - expect(result).toBe("generator"); +import { expect, test } from 'bun:test'; +import { run } from 'effection'; +import { expectTerminal, withTerminal } from '../src'; +test('Effection facade shares scoped session behavior', async () => { + const result = await run(function* () { + return yield* withTerminal( + { command: '/bin/sh', args: ['-c', 'printf generator'], trace: 'off' }, + function* (terminal) { + const match = yield* expectTerminal(terminal.getByText('generator')).toBePresent(); + return match.text; + }, + ); + }); + expect(result).toBe('generator'); }); -test("Effection cancellation closes the PTY scope and application process", async () => { - let pid = 0, - started!: () => void; - const ready = new Promise((resolve) => (started = resolve)), - task = run(function* () { - return yield* withTerminal( - { command: "/bin/sh", args: ["-c", "while :; do sleep 1; done"], trace: "off" }, - function* (terminal) { - pid = terminal.process.status().pid!; - started(); - yield* expectTerminal(terminal.getByText("never")).toBePresent({ timeoutMs: 60_000 }); - }, - ); - }); - await ready; - await task.halt(); - expect(() => process.kill(pid, 0)).toThrow(); +test('Effection cancellation closes the PTY scope and application process', async () => { + let pid = 0, + started!: () => void; + const ready = new Promise((resolve) => (started = resolve)), + task = run(function* () { + return yield* withTerminal( + { command: '/bin/sh', args: ['-c', 'while :; do sleep 1; done'], trace: 'off' }, + function* (terminal) { + pid = terminal.process.status().pid!; + started(); + yield* expectTerminal(terminal.getByText('never')).toBePresent({ timeoutMs: 60_000 }); + }, + ); + }); + await ready; + await task.halt(); + expect(() => process.kill(pid, 0)).toThrow(); }); diff --git a/experiments/ghostwright/test/host-contract.ts b/experiments/ghostwright/test/host-contract.ts index 62b4a41..64aca1b 100644 --- a/experiments/ghostwright/test/host-contract.ts +++ b/experiments/ghostwright/test/host-contract.ts @@ -1,115 +1,115 @@ -import { resolve } from "node:path"; -import { expectTerminal, withTerminalAsync } from "../src/index.ts"; -import { usePtyHostForTesting } from "../src/profile.ts"; -import { SidecarClient } from "../src/pty/client.ts"; +import { resolve } from 'node:path'; +import { expectTerminal, withTerminalAsync } from '../src/index.ts'; +import { usePtyHostForTesting } from '../src/profile.ts'; +import { SidecarClient } from '../src/pty/client.ts'; export async function runHostContract(hostPath: string): Promise { - const absolute = resolve(hostPath), - restore = usePtyHostForTesting(absolute); - try { - await withTerminalAsync( - { - command: "/bin/sh", - args: ["-c", `test -t 0 && test -t 1 && test -t 2 && printf 'TTY READY'`], - trace: "off", - }, - async (terminal) => { - await expectTerminal(terminal.getByText("TTY READY")).toBePresent(); - const status = await terminal.process.waitForExit(); - if (status.exitCode !== 0 || !status.ptyEof) - throw new Error(`invalid basic status: ${JSON.stringify(status)}`); - }, - ); + const absolute = resolve(hostPath), + restore = usePtyHostForTesting(absolute); + try { + await withTerminalAsync( + { + command: '/bin/sh', + args: ['-c', `test -t 0 && test -t 1 && test -t 2 && printf 'TTY READY'`], + trace: 'off', + }, + async (terminal) => { + await expectTerminal(terminal.getByText('TTY READY')).toBePresent(); + const status = await terminal.process.waitForExit(); + if (status.exitCode !== 0 || !status.ptyEof) + throw new Error(`invalid basic status: ${JSON.stringify(status)}`); + }, + ); - await withTerminalAsync( - { - command: "/bin/sh", - args: ["-c", `trap 'stty size; exit' WINCH; printf READY; while :; do sleep 1; done`], - cleanup: { hangupGraceMs: 20, terminateGraceMs: 20, postExitDrainMs: 50 }, - trace: "off", - }, - async (terminal) => { - await expectTerminal(terminal.getByText("READY")).toBePresent(); - await terminal.resize({ columns: 43, rows: 12 }); - await expectTerminal(terminal.getByText("12 43")).toBePresent(); - }, - ); + await withTerminalAsync( + { + command: '/bin/sh', + args: ['-c', `trap 'stty size; exit' WINCH; printf READY; while :; do sleep 1; done`], + cleanup: { hangupGraceMs: 20, terminateGraceMs: 20, postExitDrainMs: 50 }, + trace: 'off', + }, + async (terminal) => { + await expectTerminal(terminal.getByText('READY')).toBePresent(); + await terminal.resize({ columns: 43, rows: 12 }); + await expectTerminal(terminal.getByText('12 43')).toBePresent(); + }, + ); - await withTerminalAsync( - { - command: "/bin/sh", - args: [ - "-c", - `trap 'printf INTERRUPTED; exit 0' INT; printf SIGNAL_READY; while :; do sleep 1; done`, - ], - cleanup: { hangupGraceMs: 20, terminateGraceMs: 20, postExitDrainMs: 50 }, - trace: "off", - }, - async (terminal) => { - await expectTerminal(terminal.getByText("SIGNAL_READY")).toBePresent(); - await terminal.keyboard.press({ key: "c", control: true }); - await expectTerminal(terminal.getByText("INTERRUPTED")).toBePresent(); - if ((await terminal.process.waitForExit()).exitCode !== 0) - throw new Error("canonical Control-C fixture failed"); - }, - ); + await withTerminalAsync( + { + command: '/bin/sh', + args: [ + '-c', + `trap 'printf INTERRUPTED; exit 0' INT; printf SIGNAL_READY; while :; do sleep 1; done`, + ], + cleanup: { hangupGraceMs: 20, terminateGraceMs: 20, postExitDrainMs: 50 }, + trace: 'off', + }, + async (terminal) => { + await expectTerminal(terminal.getByText('SIGNAL_READY')).toBePresent(); + await terminal.keyboard.press({ key: 'c', control: true }); + await expectTerminal(terminal.getByText('INTERRUPTED')).toBePresent(); + if ((await terminal.process.waitForExit()).exitCode !== 0) + throw new Error('canonical Control-C fixture failed'); + }, + ); - await withTerminalAsync( - { - command: process.execPath, - args: [ - "-e", - `process.stdin.setRawMode(true); process.stdout.write("RAW READY"); process.stdin.once("data", data => { process.stdout.write("\\r\\nRAW:" + data.toString("hex")); process.exit(0) })`, - ], - trace: "off", - }, - async (terminal) => { - await expectTerminal(terminal.getByText("RAW READY")).toBePresent(); - await terminal.keyboard.press({ key: "c", control: true }); - await expectTerminal(terminal.getByText("RAW:03")).toBePresent(); - if ((await terminal.process.waitForExit()).exitCode !== 0) - throw new Error("raw input fixture failed"); - }, - ); + await withTerminalAsync( + { + command: process.execPath, + args: [ + '-e', + `process.stdin.setRawMode(true); process.stdout.write("RAW READY"); process.stdin.once("data", data => { process.stdout.write("\\r\\nRAW:" + data.toString("hex")); process.exit(0) })`, + ], + trace: 'off', + }, + async (terminal) => { + await expectTerminal(terminal.getByText('RAW READY')).toBePresent(); + await terminal.keyboard.press({ key: 'c', control: true }); + await expectTerminal(terminal.getByText('RAW:03')).toBePresent(); + if ((await terminal.process.waitForExit()).exitCode !== 0) + throw new Error('raw input fixture failed'); + }, + ); - const started = performance.now(); - await withTerminalAsync( - { - command: "/bin/sh", - args: ["-c", `node -e 'setInterval(()=>{}, 1000)' & printf FINAL; exit 0`], - cleanup: { hangupGraceMs: 10, terminateGraceMs: 10, postExitDrainMs: 50 }, - trace: "off", - }, - async (terminal) => { - await expectTerminal(terminal.getByText("FINAL")).toBePresent(); - const status = await terminal.process.waitForExit({ timeoutMs: 1_000 }); - if (status.exitCode !== 0 || !status.ptyEof) - throw new Error(`descendant drain failed: ${JSON.stringify(status)}`); - }, - ); - if (performance.now() - started > 1_000) - throw new Error("descendant cleanup exceeded deadline"); + const started = performance.now(); + await withTerminalAsync( + { + command: '/bin/sh', + args: ['-c', `node -e 'setInterval(()=>{}, 1000)' & printf FINAL; exit 0`], + cleanup: { hangupGraceMs: 10, terminateGraceMs: 10, postExitDrainMs: 50 }, + trace: 'off', + }, + async (terminal) => { + await expectTerminal(terminal.getByText('FINAL')).toBePresent(); + const status = await terminal.process.waitForExit({ timeoutMs: 1_000 }); + if (status.exitCode !== 0 || !status.ptyEof) + throw new Error(`descendant drain failed: ${JSON.stringify(status)}`); + }, + ); + if (performance.now() - started > 1_000) + throw new Error('descendant cleanup exceeded deadline'); - const client = await SidecarClient.start(absolute, 1_000); - try { - let rejected = false; - try { - await client.write(new Uint8Array([1])); - } catch { - rejected = true; - } - if (!rejected) throw new Error("host accepted WRITE before SPAWN"); - } finally { - await client.close(1_000).catch(() => undefined); - } - } finally { - restore(); - } + const client = await SidecarClient.start(absolute, 1_000); + try { + let rejected = false; + try { + await client.write(new Uint8Array([1])); + } catch { + rejected = true; + } + if (!rejected) throw new Error('host accepted WRITE before SPAWN'); + } finally { + await client.close(1_000).catch(() => undefined); + } + } finally { + restore(); + } } if (import.meta.main) { - const path = process.argv[2]; - if (!path) throw new Error("usage: bun test/host-contract.ts "); - await runHostContract(path); - console.log(`host contract passed: ${path}`); + const path = process.argv[2]; + if (!path) throw new Error('usage: bun test/host-contract.ts '); + await runHostContract(path); + console.log(`host contract passed: ${path}`); } diff --git a/experiments/ghostwright/test/observability.test.ts b/experiments/ghostwright/test/observability.test.ts index 14ddf66..770d005 100644 --- a/experiments/ghostwright/test/observability.test.ts +++ b/experiments/ghostwright/test/observability.test.ts @@ -1,188 +1,188 @@ -import { createHash } from "node:crypto"; -import { expect, test } from "bun:test"; +import { createHash } from 'node:crypto'; +import { expect, test } from 'bun:test'; import { - HistoryChangedError, - TerminalAssertionError, - expectTerminal, - withTerminalAsync, -} from "../src/index.ts"; + HistoryChangedError, + TerminalAssertionError, + expectTerminal, + withTerminalAsync, +} from '../src/index.ts'; const node = process.execPath; -test("retained ranges use exclusive baselines and bounded live collection", async () => { - await withTerminalAsync( - { - command: node, - args: [ - "-e", - `let i = 0; const t = setInterval(() => { process.stdout.write("\\rREV-" + i++); if (i === 4) { clearInterval(t); setTimeout(() => process.exit(0), 20); } }, 20)`, - ], - trace: "off", - }, - async (terminal) => { - const collection = await terminal.revisions.collect({ - since: 0, - until: (snapshot) => snapshot.lines.some((line) => line.text.includes("REV-3")), - timeoutMs: 2_000, - }); - expect(collection.revisions.length).toBeGreaterThan(0); - expect(collection.revisions.every((revision) => revision.sequence > 0)).toBe(true); - expect(collection.revisions.at(-1)?.snapshot.lines[0].text).toContain("REV-3"); - expect(terminal.screen.revisions({ since: 0, until: collection.revisions.at(-1)! })).toEqual( - collection.revisions, - ); - await terminal.process.waitForExit(); - }, - ); +test('retained ranges use exclusive baselines and bounded live collection', async () => { + await withTerminalAsync( + { + command: node, + args: [ + '-e', + `let i = 0; const t = setInterval(() => { process.stdout.write("\\rREV-" + i++); if (i === 4) { clearInterval(t); setTimeout(() => process.exit(0), 20); } }, 20)`, + ], + trace: 'off', + }, + async (terminal) => { + const collection = await terminal.revisions.collect({ + since: 0, + until: (snapshot) => snapshot.lines.some((line) => line.text.includes('REV-3')), + timeoutMs: 2_000, + }); + expect(collection.revisions.length).toBeGreaterThan(0); + expect(collection.revisions.every((revision) => revision.sequence > 0)).toBe(true); + expect(collection.revisions.at(-1)?.snapshot.lines[0].text).toContain('REV-3'); + expect(terminal.screen.revisions({ since: 0, until: collection.revisions.at(-1)! })).toEqual( + collection.revisions, + ); + await terminal.process.waitForExit(); + }, + ); }); -test("history is immutable, paginated, searchable, and generation guarded", async () => { - await withTerminalAsync( - { - command: node, - args: ["-e", `for (let i = 0; i < 30; i++) console.log("HISTORY-" + i)`], - viewport: { columns: 30, rows: 5 }, - trace: "off", - }, - async (terminal) => { - await terminal.process.waitForExit(); - const oldest = await terminal.history.read({ count: 3 }); - expect(oldest.totalRows).toBeGreaterThan(20); - expect(oldest.lines.map((line) => line.text.trim())).toEqual([ - "HISTORY-0", - "HISTORY-1", - "HISTORY-2", - ]); - const newest = await terminal.history.read({ start: 0, count: 3, direction: "newest-first" }); - expect(newest.lines.map((line) => line.index)).toEqual([2, 1, 0]); - const matches = await terminal.history.findText("HISTORY-2", { limit: 20 }); - expect(matches.some((match) => match.line.text.trim() === "HISTORY-2")).toBe(true); - await expect( - terminal.history.read({ count: 1, expectedGeneration: "stale" }), - ).rejects.toBeInstanceOf(HistoryChangedError); - }, - ); +test('history is immutable, paginated, searchable, and generation guarded', async () => { + await withTerminalAsync( + { + command: node, + args: ['-e', `for (let i = 0; i < 30; i++) console.log("HISTORY-" + i)`], + viewport: { columns: 30, rows: 5 }, + trace: 'off', + }, + async (terminal) => { + await terminal.process.waitForExit(); + const oldest = await terminal.history.read({ count: 3 }); + expect(oldest.totalRows).toBeGreaterThan(20); + expect(oldest.lines.map((line) => line.text.trim())).toEqual([ + 'HISTORY-0', + 'HISTORY-1', + 'HISTORY-2', + ]); + const newest = await terminal.history.read({ start: 0, count: 3, direction: 'newest-first' }); + expect(newest.lines.map((line) => line.index)).toEqual([2, 1, 0]); + const matches = await terminal.history.findText('HISTORY-2', { limit: 20 }); + expect(matches.some((match) => match.line.text.trim() === 'HISTORY-2')).toBe(true); + await expect( + terminal.history.read({ count: 1, expectedGeneration: 'stale' }), + ).rejects.toBeInstanceOf(HistoryChangedError); + }, + ); }); -test("history page boundaries retain soft-wrap continuation metadata", async () => { - await withTerminalAsync( - { - command: node, - args: [ - "-e", - `process.stdout.write("abcdefghij\\n"); for (let i = 0; i < 20; i++) console.log("ROW-" + i)`, - ], - viewport: { columns: 5, rows: 4 }, - trace: "off", - }, - async (terminal) => { - await terminal.process.waitForExit(); - const all = await terminal.history.read({ count: 100 }); - const continuation = all.lines.find((line) => line.wrapContinuation)!; - expect(continuation).toBeTruthy(); - const page = await terminal.history.read({ start: continuation.index, count: 1 }); - expect(page.lines[0].wrapContinuation).toBe(true); - }, - ); +test('history page boundaries retain soft-wrap continuation metadata', async () => { + await withTerminalAsync( + { + command: node, + args: [ + '-e', + `process.stdout.write("abcdefghij\\n"); for (let i = 0; i < 20; i++) console.log("ROW-" + i)`, + ], + viewport: { columns: 5, rows: 4 }, + trace: 'off', + }, + async (terminal) => { + await terminal.process.waitForExit(); + const all = await terminal.history.read({ count: 100 }); + const continuation = all.lines.find((line) => line.wrapContinuation)!; + expect(continuation).toBeTruthy(); + const page = await terminal.history.read({ start: continuation.index, count: 1 }); + expect(page.lines[0].wrapContinuation).toBe(true); + }, + ); }); -test("raw Kitty graphics expose renderer-ready copied placement metadata", async () => { - const pixels = new Uint8Array([ - 255, - 0, - 0, - 255, // red - 0, - 255, - 0, - 255, // green - 0, - 0, - 255, - 255, // blue - 0, - 0, - 0, - 0, // transparent - ]); - const payload = Buffer.from(pixels).toString("base64"); - await withTerminalAsync( - { - command: node, - args: [ - "-e", - // `a=t` stores without creating the implicit default placement. - `process.stdout.write("\\x1b_Ga=t,f=32,s=2,v=2,i=42;${payload}\\x1b\\\\\\x1b_Ga=p,i=42,p=7,c=4,r=4,z=-1;\\x1b\\\\\\x1b_Ga=p,i=42,p=8,c=4,r=4,z=-2;\\x1b\\\\\\x1b_Ga=p,i=42,p=9,c=4,r=4,z=-1073741825;\\x1b\\\\"); setTimeout(() => process.exit(0), 100)`, - ], - trace: "off", - }, - async (terminal) => { - await terminal.process.waitForExit(); - const graphics = terminal.screen.current().graphics; - expect(graphics.supported).toBe(true); - const placement = graphics.placements.find((candidate) => candidate.placementId === 7)!; - expect(placement).toMatchObject({ - imageId: 42, - virtual: false, - z: -1, - layer: "below-text", - requestedGrid: { columns: 4, rows: 4 }, - renderedPixels: { width: 40, height: 80 }, - viewport: { column: 0, row: 0, visible: true }, - }); - expect(placement.image).toMatchObject({ - width: 2, - height: 2, - format: "rgba", - compression: "none", - dataLength: 16, - sha256: createHash("sha256").update(pixels).digest("hex"), - }); - expect(graphics.placements.find((candidate) => candidate.placementId === 8)?.layer).toBe( - "below-text", - ); - expect(graphics.placements.find((candidate) => candidate.placementId === 9)?.layer).toBe( - "below-background", - ); - expect(terminal.screen.getKittyImage(42)).toEqual(placement.image); - expect(await terminal.graphics.copyImageData(42)).toEqual(pixels); - expect("data" in terminal.screen.current().graphics.placements[0].image!).toBe(false); - }, - ); +test('raw Kitty graphics expose renderer-ready copied placement metadata', async () => { + const pixels = new Uint8Array([ + 255, + 0, + 0, + 255, // red + 0, + 255, + 0, + 255, // green + 0, + 0, + 255, + 255, // blue + 0, + 0, + 0, + 0, // transparent + ]); + const payload = Buffer.from(pixels).toString('base64'); + await withTerminalAsync( + { + command: node, + args: [ + '-e', + // `a=t` stores without creating the implicit default placement. + `process.stdout.write("\\x1b_Ga=t,f=32,s=2,v=2,i=42;${payload}\\x1b\\\\\\x1b_Ga=p,i=42,p=7,c=4,r=4,z=-1;\\x1b\\\\\\x1b_Ga=p,i=42,p=8,c=4,r=4,z=-2;\\x1b\\\\\\x1b_Ga=p,i=42,p=9,c=4,r=4,z=-1073741825;\\x1b\\\\"); setTimeout(() => process.exit(0), 100)`, + ], + trace: 'off', + }, + async (terminal) => { + await terminal.process.waitForExit(); + const graphics = terminal.screen.current().graphics; + expect(graphics.supported).toBe(true); + const placement = graphics.placements.find((candidate) => candidate.placementId === 7)!; + expect(placement).toMatchObject({ + imageId: 42, + virtual: false, + z: -1, + layer: 'below-text', + requestedGrid: { columns: 4, rows: 4 }, + renderedPixels: { width: 40, height: 80 }, + viewport: { column: 0, row: 0, visible: true }, + }); + expect(placement.image).toMatchObject({ + width: 2, + height: 2, + format: 'rgba', + compression: 'none', + dataLength: 16, + sha256: createHash('sha256').update(pixels).digest('hex'), + }); + expect(graphics.placements.find((candidate) => candidate.placementId === 8)?.layer).toBe( + 'below-text', + ); + expect(graphics.placements.find((candidate) => candidate.placementId === 9)?.layer).toBe( + 'below-background', + ); + expect(terminal.screen.getKittyImage(42)).toEqual(placement.image); + expect(await terminal.graphics.copyImageData(42)).toEqual(pixels); + expect('data' in terminal.screen.current().graphics.placements[0].image!).toBe(false); + }, + ); }); -test("inspected unplaced Kitty images survive later snapshots without retaining pixels", async () => { - const pixels = new Uint8Array([255, 0, 0, 255]); - const payload = Buffer.from(pixels).toString("base64"); - await withTerminalAsync( - { - command: node, - args: [ - "-e", - `process.stdout.write("\\x1b_Ga=t,f=32,s=1,v=1,i=42;${payload}\\x1b\\\\"); setTimeout(() => process.stdout.write("later"), 80); setTimeout(() => process.exit(0), 160)`, - ], - trace: "off", - }, - async (terminal) => { - await expectTerminal(terminal).toHaveShown( - (snapshot) => snapshot.graphics.generation !== "0", - ); - const inspected = await terminal.graphics.inspectImage(42); - expect(inspected?.sha256).toBe(createHash("sha256").update(pixels).digest("hex")); - await expectTerminal(terminal.getByText("later")).toBePresent(); - expect(terminal.screen.getKittyImage(42)).toEqual(inspected); - expect("data" in terminal.screen.getKittyImage(42)!).toBe(false); - }, - ); +test('inspected unplaced Kitty images survive later snapshots without retaining pixels', async () => { + const pixels = new Uint8Array([255, 0, 0, 255]); + const payload = Buffer.from(pixels).toString('base64'); + await withTerminalAsync( + { + command: node, + args: [ + '-e', + `process.stdout.write("\\x1b_Ga=t,f=32,s=1,v=1,i=42;${payload}\\x1b\\\\"); setTimeout(() => process.stdout.write("later"), 80); setTimeout(() => process.exit(0), 160)`, + ], + trace: 'off', + }, + async (terminal) => { + await expectTerminal(terminal).toHaveShown( + (snapshot) => snapshot.graphics.generation !== '0', + ); + const inspected = await terminal.graphics.inspectImage(42); + expect(inspected?.sha256).toBe(createHash('sha256').update(pixels).digest('hex')); + await expectTerminal(terminal.getByText('later')).toBePresent(); + expect(terminal.screen.getKittyImage(42)).toEqual(inspected); + expect('data' in terminal.screen.getKittyImage(42)!).toBe(false); + }, + ); }); -test("revision collection reports timeout distinctly from process exit", async () => { - await withTerminalAsync( - { command: node, args: ["-e", "setTimeout(() => process.exit(0), 200)"], trace: "off" }, - async (terminal) => { - await expect( - terminal.revisions.collect({ since: 0, until: () => false, timeoutMs: 20 }), - ).rejects.toBeInstanceOf(TerminalAssertionError); - }, - ); +test('revision collection reports timeout distinctly from process exit', async () => { + await withTerminalAsync( + { command: node, args: ['-e', 'setTimeout(() => process.exit(0), 200)'], trace: 'off' }, + async (terminal) => { + await expect( + terminal.revisions.collect({ since: 0, until: () => false, timeoutMs: 20 }), + ).rejects.toBeInstanceOf(TerminalAssertionError); + }, + ); }); diff --git a/experiments/ghostwright/test/preload-host.ts b/experiments/ghostwright/test/preload-host.ts index 6e30720..e88d440 100644 --- a/experiments/ghostwright/test/preload-host.ts +++ b/experiments/ghostwright/test/preload-host.ts @@ -1,6 +1,6 @@ -import { resolve } from "node:path"; -import { usePtyHostForTesting } from "../src/profile.ts"; +import { resolve } from 'node:path'; +import { usePtyHostForTesting } from '../src/profile.ts'; const host = process.env.GHOSTWRIGHT_CONTRACT_HOST; -if (!host) throw new Error("GHOSTWRIGHT_CONTRACT_HOST is required"); +if (!host) throw new Error('GHOSTWRIGHT_CONTRACT_HOST is required'); usePtyHostForTesting(resolve(host)); diff --git a/experiments/ghostwright/test/process.test.ts b/experiments/ghostwright/test/process.test.ts index 87ab8af..2827d20 100644 --- a/experiments/ghostwright/test/process.test.ts +++ b/experiments/ghostwright/test/process.test.ts @@ -1,62 +1,62 @@ -import { expect, test } from "bun:test"; -import { expectTerminal, withTerminalAsync } from "../src/index.ts"; +import { expect, test } from 'bun:test'; +import { expectTerminal, withTerminalAsync } from '../src/index.ts'; -test("user Control-C travels through PTY line discipline", async () => { - await withTerminalAsync( - { - command: "/bin/sh", - args: [ - "-c", - `trap 'printf INTERRUPTED; exit 0' INT; printf READY; while :; do sleep 1; done`, - ], - trace: "off", - }, - async (terminal) => { - await expectTerminal(terminal.getByText("READY")).toBePresent(); - await terminal.keyboard.press({ key: "c", control: true }); - await expectTerminal(terminal.getByText("INTERRUPTED")).toBePresent(); - expect((await terminal.process.waitForExit()).exitCode).toBe(0); - }, - ); +test('user Control-C travels through PTY line discipline', async () => { + await withTerminalAsync( + { + command: '/bin/sh', + args: [ + '-c', + `trap 'printf INTERRUPTED; exit 0' INT; printf READY; while :; do sleep 1; done`, + ], + trace: 'off', + }, + async (terminal) => { + await expectTerminal(terminal.getByText('READY')).toBePresent(); + await terminal.keyboard.press({ key: 'c', control: true }); + await expectTerminal(terminal.getByText('INTERRUPTED')).toBePresent(); + expect((await terminal.process.waitForExit()).exitCode).toBe(0); + }, + ); }); -test("raw Control-C remains input while administrative signals target the OS process", async () => { - await withTerminalAsync( - { - command: process.execPath, - args: [ - "-e", - `process.stdin.setRawMode(true); process.stdout.write("READY"); process.stdin.once("data", data => { process.stdout.write("\\r\\nRAW:" + data.toString("hex")); setInterval(()=>{}, 1000) }); process.on("SIGTERM", () => { process.stdout.write("\\r\\nTERM"); process.exit(0) })`, - ], - trace: "off", - }, - async (terminal) => { - await expectTerminal(terminal.getByText("READY")).toBePresent(); - await terminal.keyboard.press({ key: "c", control: true }); - await expectTerminal(terminal.getByText("RAW:03")).toBePresent(); - expect(terminal.process.status().state).toBe("running"); - await terminal.process.signal("SIGTERM", "child"); - await expectTerminal(terminal.getByText("TERM")).toBePresent(); - expect((await terminal.process.waitForExit()).exitCode).toBe(0); - }, - ); +test('raw Control-C remains input while administrative signals target the OS process', async () => { + await withTerminalAsync( + { + command: process.execPath, + args: [ + '-e', + `process.stdin.setRawMode(true); process.stdout.write("READY"); process.stdin.once("data", data => { process.stdout.write("\\r\\nRAW:" + data.toString("hex")); setInterval(()=>{}, 1000) }); process.on("SIGTERM", () => { process.stdout.write("\\r\\nTERM"); process.exit(0) })`, + ], + trace: 'off', + }, + async (terminal) => { + await expectTerminal(terminal.getByText('READY')).toBePresent(); + await terminal.keyboard.press({ key: 'c', control: true }); + await expectTerminal(terminal.getByText('RAW:03')).toBePresent(); + expect(terminal.process.status().state).toBe('running'); + await terminal.process.signal('SIGTERM', 'child'); + await expectTerminal(terminal.getByText('TERM')).toBePresent(); + expect((await terminal.process.waitForExit()).exitCode).toBe(0); + }, + ); }); -test("natural direct-child exit drains and then owns a PTY-holding descendant", async () => { - const started = performance.now(); - await withTerminalAsync( - { - command: "/bin/sh", - args: ["-c", `node -e 'setInterval(()=>{}, 1000)' & child=$!; printf FINAL; exit 0`], - cleanup: { hangupGraceMs: 10, terminateGraceMs: 10, postExitDrainMs: 50 }, - trace: "off", - }, - async (terminal) => { - await expectTerminal(terminal.getByText("FINAL")).toBePresent(); - const status = await terminal.process.waitForExit({ timeoutMs: 1_000 }); - expect(status.exitCode).toBe(0); - expect(status.ptyEof).toBe(true); - }, - ); - expect(performance.now() - started).toBeLessThan(1_000); +test('natural direct-child exit drains and then owns a PTY-holding descendant', async () => { + const started = performance.now(); + await withTerminalAsync( + { + command: '/bin/sh', + args: ['-c', `node -e 'setInterval(()=>{}, 1000)' & child=$!; printf FINAL; exit 0`], + cleanup: { hangupGraceMs: 10, terminateGraceMs: 10, postExitDrainMs: 50 }, + trace: 'off', + }, + async (terminal) => { + await expectTerminal(terminal.getByText('FINAL')).toBePresent(); + const status = await terminal.process.waitForExit({ timeoutMs: 1_000 }); + expect(status.exitCode).toBe(0); + expect(status.ptyEof).toBe(true); + }, + ); + expect(performance.now() - started).toBeLessThan(1_000); }); diff --git a/experiments/ghostwright/test/protocol.test.ts b/experiments/ghostwright/test/protocol.test.ts index 045f776..ef1790c 100644 --- a/experiments/ghostwright/test/protocol.test.ts +++ b/experiments/ghostwright/test/protocol.test.ts @@ -1,47 +1,47 @@ -import { describe, expect, test } from "bun:test"; -import { decodeCbor, encodeCbor, encodeFrame, FrameDecoder, FrameKind } from "../src/pty/protocol"; -describe("GWPT protocol", () => { - test("deterministic CBOR round trip", () => { - const value = { - viewport: { columns: 80, rows: 24 }, - args: ["a", "b"], - enabled: true, - none: null, - }; - expect(decodeCbor(encodeCbor(value))).toEqual(value); - expect(encodeCbor({ b: 1, a: 2 })).toEqual(encodeCbor({ a: 2, b: 1 })); - }); - test("reassembles every fragmentation", () => { - const bytes = encodeFrame({ - kind: FrameKind.OUTPUT, - sequence: 1, - correlation: 0, - payload: Uint8Array.of(0, 255, 27), - }); - for (let split = 0; split <= bytes.length; split++) { - const d = new FrameDecoder(); - expect([...d.push(bytes.slice(0, split)), ...d.push(bytes.slice(split))]).toEqual([ - { kind: FrameKind.OUTPUT, sequence: 1, correlation: 0, payload: Uint8Array.of(0, 255, 27) }, - ]); - } - }); - test("rejects regressing sequences", () => { - const d = new FrameDecoder(), - f = encodeFrame({ - kind: FrameKind.PTY_EOF, - sequence: 1, - correlation: 0, - payload: new Uint8Array(), - }); - d.push(f); - expect(() => d.push(f)).toThrow("regressing"); - }); - test("rejects non-deterministic and malformed CBOR", () => { - expect(() => decodeCbor(Uint8Array.of(0x18, 0x01))).toThrow("Non-deterministic"); - // Map keys have equal encoded length but are in descending byte order. - expect(() => decodeCbor(Uint8Array.of(0xa2, 0x61, 0x62, 0x01, 0x61, 0x61, 0x02))).toThrow( - "ordering", - ); - expect(() => decodeCbor(Uint8Array.of(0x63, 0xff, 0xff, 0xff))).toThrow(); - }); +import { describe, expect, test } from 'bun:test'; +import { decodeCbor, encodeCbor, encodeFrame, FrameDecoder, FrameKind } from '../src/pty/protocol'; +describe('GWPT protocol', () => { + test('deterministic CBOR round trip', () => { + const value = { + viewport: { columns: 80, rows: 24 }, + args: ['a', 'b'], + enabled: true, + none: null, + }; + expect(decodeCbor(encodeCbor(value))).toEqual(value); + expect(encodeCbor({ b: 1, a: 2 })).toEqual(encodeCbor({ a: 2, b: 1 })); + }); + test('reassembles every fragmentation', () => { + const bytes = encodeFrame({ + kind: FrameKind.OUTPUT, + sequence: 1, + correlation: 0, + payload: Uint8Array.of(0, 255, 27), + }); + for (let split = 0; split <= bytes.length; split++) { + const d = new FrameDecoder(); + expect([...d.push(bytes.slice(0, split)), ...d.push(bytes.slice(split))]).toEqual([ + { kind: FrameKind.OUTPUT, sequence: 1, correlation: 0, payload: Uint8Array.of(0, 255, 27) }, + ]); + } + }); + test('rejects regressing sequences', () => { + const d = new FrameDecoder(), + f = encodeFrame({ + kind: FrameKind.PTY_EOF, + sequence: 1, + correlation: 0, + payload: new Uint8Array(), + }); + d.push(f); + expect(() => d.push(f)).toThrow('regressing'); + }); + test('rejects non-deterministic and malformed CBOR', () => { + expect(() => decodeCbor(Uint8Array.of(0x18, 0x01))).toThrow('Non-deterministic'); + // Map keys have equal encoded length but are in descending byte order. + expect(() => decodeCbor(Uint8Array.of(0xa2, 0x61, 0x62, 0x01, 0x61, 0x61, 0x02))).toThrow( + 'ordering', + ); + expect(() => decodeCbor(Uint8Array.of(0x63, 0xff, 0xff, 0xff))).toThrow(); + }); }); diff --git a/experiments/ghostwright/test/runtime-smoke.mjs b/experiments/ghostwright/test/runtime-smoke.mjs index 08232da..dcc22a0 100644 --- a/experiments/ghostwright/test/runtime-smoke.mjs +++ b/experiments/ghostwright/test/runtime-smoke.mjs @@ -1,13 +1,13 @@ -import { expectTerminal, withTerminalAsync } from "../dist/index.js"; +import { expectTerminal, withTerminalAsync } from '../dist/index.js'; await withTerminalAsync( - { command: "/bin/sh", args: ["-c", "printf runtime-smoke"], trace: "off" }, - async (terminal) => { - await expectTerminal(terminal.getByText("runtime-smoke")).toBePresent(); - const status = await terminal.process.waitForExit(); - if (status.exitCode !== 0 || !status.ptyEof) { - throw new Error(`unexpected process status: ${JSON.stringify(status)}`); - } - }, + { command: '/bin/sh', args: ['-c', 'printf runtime-smoke'], trace: 'off' }, + async (terminal) => { + await expectTerminal(terminal.getByText('runtime-smoke')).toBePresent(); + const status = await terminal.process.waitForExit(); + if (status.exitCode !== 0 || !status.ptyEof) { + throw new Error(`unexpected process status: ${JSON.stringify(status)}`); + } + }, ); -console.log("Ghostwright runtime smoke passed"); +console.log('Ghostwright runtime smoke passed'); diff --git a/experiments/ghostwright/test/session.test.ts b/experiments/ghostwright/test/session.test.ts index 2564891..7792c03 100644 --- a/experiments/ghostwright/test/session.test.ts +++ b/experiments/ghostwright/test/session.test.ts @@ -1,32 +1,32 @@ -import { expect, test } from "bun:test"; -import { expectTerminal, withTerminalAsync } from "../src"; -test("launches under a real PTY and snapshots output", async () => { - await withTerminalAsync( - { - command: "/bin/sh", - args: ["-c", `test -t 0 && test -t 1 && test -t 2 && printf 'TTY READY'`], - trace: "off", - }, - async (terminal) => { - const match = await expectTerminal(terminal.getByText("TTY READY")).toBePresent(); - expect(match.range).toEqual({ column: 0, row: 0, width: 9, height: 1 }); - const status = await terminal.process.waitForExit(); - expect(status.exitCode).toBe(0); - expect(status.ptyEof).toBe(true); - }, - ); +import { expect, test } from 'bun:test'; +import { expectTerminal, withTerminalAsync } from '../src'; +test('launches under a real PTY and snapshots output', async () => { + await withTerminalAsync( + { + command: '/bin/sh', + args: ['-c', `test -t 0 && test -t 1 && test -t 2 && printf 'TTY READY'`], + trace: 'off', + }, + async (terminal) => { + const match = await expectTerminal(terminal.getByText('TTY READY')).toBePresent(); + expect(match.range).toEqual({ column: 0, row: 0, width: 9, height: 1 }); + const status = await terminal.process.waitForExit(); + expect(status.exitCode).toBe(0); + expect(status.ptyEof).toBe(true); + }, + ); }); -test("resizes the kernel PTY without synthetic input", async () => { - await withTerminalAsync( - { - command: "/bin/sh", - args: ["-c", `trap 'stty size; exit' WINCH; printf READY; while :; do sleep 1; done`], - trace: "off", - }, - async (terminal) => { - await expectTerminal(terminal.getByText("READY")).toBePresent(); - await terminal.resize({ columns: 43, rows: 12 }); - await expectTerminal(terminal.getByText("12 43")).toBePresent({ timeoutMs: 2000 }); - }, - ); +test('resizes the kernel PTY without synthetic input', async () => { + await withTerminalAsync( + { + command: '/bin/sh', + args: ['-c', `trap 'stty size; exit' WINCH; printf READY; while :; do sleep 1; done`], + trace: 'off', + }, + async (terminal) => { + await expectTerminal(terminal.getByText('READY')).toBePresent(); + await terminal.resize({ columns: 43, rows: 12 }); + await expectTerminal(terminal.getByText('12 43')).toBePresent({ timeoutMs: 2000 }); + }, + ); }); diff --git a/experiments/ghostwright/tsconfig.build.json b/experiments/ghostwright/tsconfig.build.json index e83d1e2..ae6143e 100644 --- a/experiments/ghostwright/tsconfig.build.json +++ b/experiments/ghostwright/tsconfig.build.json @@ -1,18 +1,18 @@ { - "compilerOptions": { - "target": "ES2022", - "module": "ESNext", - "moduleResolution": "Bundler", - "lib": ["ES2023", "DOM"], - "types": ["node"], - "strict": true, - "skipLibCheck": true, - "declaration": true, - "emitDeclarationOnly": true, - "allowImportingTsExtensions": true, - "rewriteRelativeImportExtensions": true, - "rootDir": "src", - "outDir": "dist/types" - }, - "include": ["src/**/*.ts"] + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "lib": ["ES2023", "DOM"], + "types": ["node"], + "strict": true, + "skipLibCheck": true, + "declaration": true, + "emitDeclarationOnly": true, + "allowImportingTsExtensions": true, + "rewriteRelativeImportExtensions": true, + "rootDir": "src", + "outDir": "dist/types" + }, + "include": ["src/**/*.ts"] } From 1906323f95f6f1cfda9740e8a5be23fdbb34ce0c Mon Sep 17 00:00:00 2001 From: Jacob Bolda Date: Sat, 18 Jul 2026 23:50:09 -0500 Subject: [PATCH 3/4] bump tools version --- package.json | 2 +- pnpm-lock.yaml | 59 +++++++++++++++++--------------------------------- 2 files changed, 21 insertions(+), 40 deletions(-) diff --git a/package.json b/package.json index da4f060..b7ba1a7 100644 --- a/package.json +++ b/package.json @@ -29,7 +29,7 @@ }, "devDependencies": { "@bomb.sh/args": "catalog:", - "@bomb.sh/tools": "^0.5.4", + "@bomb.sh/tools": "^0.5.5", "@clack/prompts": "catalog:", "@types/node": "^22" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3c2e92a..f01fc55 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -27,8 +27,8 @@ importers: specifier: 'catalog:' version: 0.3.1 '@bomb.sh/tools': - specifier: ^0.5.4 - version: 0.5.4(@types/node@22.20.1)(oxc-resolver@11.21.3)(unrun@0.2.39)(vite@8.1.0(@types/node@22.20.1)(jiti@2.7.0)(yaml@2.9.0)) + specifier: ^0.5.5 + version: 0.5.5(@types/node@22.20.1)(oxc-resolver@11.21.3)(unrun@0.2.39)(vite@8.1.0(@types/node@22.20.1)(jiti@2.7.0)(yaml@2.9.0)) '@clack/prompts': specifier: 'catalog:' version: 1.7.0 @@ -59,8 +59,8 @@ packages: '@bomb.sh/args@0.3.1': resolution: {integrity: sha512-CwxKrfgcorUPP6KfYD59aRdBYWBTsfsxT+GmoLVnKo5Tmyoqbpo0UNcjngRMyU+6tiPbd18RuIYxhgAn44wU/Q==} - '@bomb.sh/tools@0.5.4': - resolution: {integrity: sha512-OGbhambOn0o2w9LX5N4MoyMBxVLXByD1aCIFlAbQvCbLt60sMffjy1FbGMJtnpcwSr6TePWBKnGC7OaU1r29wg==} + '@bomb.sh/tools@0.5.5': + resolution: {integrity: sha512-cqOjfN+A6OtO265glgjwQ2qbYPsRKUgoq3Ff76iSq3hYfZqwC90Pe2lfq/AA3p62PhsLy/gxJ2+FzxkCWFgYjg==} hasBin: true '@bomb.sh/tty@0.7.0': @@ -126,12 +126,6 @@ packages: '@jridgewell/sourcemap-codec@1.5.5': resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} - '@napi-rs/wasm-runtime@1.1.5': - resolution: {integrity: sha512-AWPoBRJ9tsnVhor4sjO7rkni+7p+2IAEFj6cx06UgP10jkQHqay/36uRV/bFkgrh18D9vb4cr8Q0Pthskgzy+Q==} - peerDependencies: - '@emnapi/core': ^1.7.1 - '@emnapi/runtime': ^1.7.1 - '@napi-rs/wasm-runtime@1.1.6': resolution: {integrity: sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==} peerDependencies: @@ -1214,10 +1208,6 @@ packages: picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} - picomatch@4.0.4: - resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} - engines: {node: '>=12'} - picomatch@4.0.5: resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==} engines: {node: '>=12'} @@ -1501,7 +1491,7 @@ snapshots: '@bomb.sh/args@0.3.1': {} - '@bomb.sh/tools@0.5.4(@types/node@22.20.1)(oxc-resolver@11.21.3)(unrun@0.2.39)(vite@8.1.0(@types/node@22.20.1)(jiti@2.7.0)(yaml@2.9.0))': + '@bomb.sh/tools@0.5.5(@types/node@22.20.1)(oxc-resolver@11.21.3)(unrun@0.2.39)(vite@8.1.0(@types/node@22.20.1)(jiti@2.7.0)(yaml@2.9.0))': dependencies: '@bomb.sh/args': 0.3.1 '@humanfs/node': 0.16.8 @@ -1624,24 +1614,17 @@ snapshots: '@jridgewell/sourcemap-codec@1.5.5': {} - '@napi-rs/wasm-runtime@1.1.5(@emnapi/core@1.11.0)(@emnapi/runtime@1.11.0)': - dependencies: - '@emnapi/core': 1.11.0 - '@emnapi/runtime': 1.11.0 - '@tybys/wasm-util': 0.10.3 - optional: true - - '@napi-rs/wasm-runtime@1.1.5(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)': + '@napi-rs/wasm-runtime@1.1.6(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': dependencies: - '@emnapi/core': 1.11.1 - '@emnapi/runtime': 1.11.1 + '@emnapi/core': 1.10.0 + '@emnapi/runtime': 1.10.0 '@tybys/wasm-util': 0.10.3 optional: true - '@napi-rs/wasm-runtime@1.1.6(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': + '@napi-rs/wasm-runtime@1.1.6(@emnapi/core@1.11.0)(@emnapi/runtime@1.11.0)': dependencies: - '@emnapi/core': 1.10.0 - '@emnapi/runtime': 1.10.0 + '@emnapi/core': 1.11.0 + '@emnapi/runtime': 1.11.0 '@tybys/wasm-util': 0.10.3 optional: true @@ -1704,7 +1687,7 @@ snapshots: dependencies: '@emnapi/core': 1.11.1 '@emnapi/runtime': 1.11.1 - '@napi-rs/wasm-runtime': 1.1.5(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) + '@napi-rs/wasm-runtime': 1.1.6(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) optional: true '@oxc-parser/binding-win32-arm64-msvc@0.137.0': @@ -1775,7 +1758,7 @@ snapshots: dependencies: '@emnapi/core': 1.11.0 '@emnapi/runtime': 1.11.0 - '@napi-rs/wasm-runtime': 1.1.5(@emnapi/core@1.11.0)(@emnapi/runtime@1.11.0) + '@napi-rs/wasm-runtime': 1.1.6(@emnapi/core@1.11.0)(@emnapi/runtime@1.11.0) optional: true '@oxc-resolver/binding-win32-arm64-msvc@11.21.3': @@ -2213,9 +2196,9 @@ snapshots: dependencies: walk-up-path: 4.0.0 - fdir@6.5.0(picomatch@4.0.4): + fdir@6.5.0(picomatch@4.0.5): optionalDependencies: - picomatch: 4.0.4 + picomatch: 4.0.5 formatly@0.3.0: dependencies: @@ -2240,13 +2223,13 @@ snapshots: knip@6.18.0: dependencies: - fdir: 6.5.0(picomatch@4.0.4) + fdir: 6.5.0(picomatch@4.0.5) formatly: 0.3.0 get-tsconfig: 4.14.0 jiti: 2.7.0 oxc-parser: 0.137.0 oxc-resolver: 11.21.3 - picomatch: 4.0.4 + picomatch: 4.0.5 smol-toml: 1.7.0 strip-json-comments: 5.0.3 tinyglobby: 0.2.17 @@ -2412,8 +2395,6 @@ snapshots: picocolors@1.1.1: {} - picomatch@4.0.4: {} - picomatch@4.0.5: {} postcss@8.5.15: @@ -2516,8 +2497,8 @@ snapshots: tinyglobby@0.2.17: dependencies: - fdir: 6.5.0(picomatch@4.0.4) - picomatch: 4.0.4 + fdir: 6.5.0(picomatch@4.0.5) + picomatch: 4.0.5 tinypool@2.1.0: {} @@ -2601,7 +2582,7 @@ snapshots: magic-string: 0.30.21 obug: 2.1.3 pathe: 2.0.3 - picomatch: 4.0.4 + picomatch: 4.0.5 std-env: 4.1.0 tinybench: 2.9.0 tinyexec: 1.2.4 From 8ca88fed7b0b64eb48e2149cdcca89b2ab4ceae3 Mon Sep 17 00:00:00 2001 From: Jacob Bolda Date: Sun, 19 Jul 2026 00:58:39 -0500 Subject: [PATCH 4/4] resolve lint errors --- examples/demo/src/index.ts | 2 +- .../examples/async/agent-closes-vi.test.ts | 1 + .../examples/async/bash-vi-roundtrip.test.ts | 1 + .../effection/agent-closes-vi.test.ts | 1 + .../effection/bash-vi-roundtrip.test.ts | 1 + experiments/ghostwright/scripts/benchmark.ts | 16 +- .../ghostwright/scripts/build-ghostty-vt.ts | 11 +- .../ghostwright/scripts/build-host-c.ts | 7 +- .../ghostwright/scripts/build-host-rust.ts | 1 + .../ghostwright/scripts/compare-hosts.ts | 14 +- .../ghostwright/scripts/fetch-ghostty.ts | 6 +- .../ghostwright/scripts/fix-declarations.ts | 1 + .../ghostwright/scripts/update-manifest.ts | 2 +- .../ghostwright/scripts/verify-artifacts.ts | 46 ++++-- .../ghostwright/src/assertions/index.ts | 27 ++-- experiments/ghostwright/src/async.ts | 1 + .../ghostwright/src/effection/index.ts | 52 ++++-- experiments/ghostwright/src/errors.ts | 28 +++- experiments/ghostwright/src/index.ts | 4 +- experiments/ghostwright/src/profile.ts | 20 ++- experiments/ghostwright/src/pty/client.ts | 66 +++++--- experiments/ghostwright/src/pty/protocol.ts | 11 +- .../ghostwright/src/terminal/session.ts | 151 +++++++++++------- experiments/ghostwright/src/terminal/wasm.ts | 63 ++++++-- experiments/ghostwright/src/tracing/replay.ts | 4 +- experiments/ghostwright/src/tracing/trace.ts | 36 +++-- .../ghostwright/test/assertions-trace.test.ts | 5 +- experiments/ghostwright/test/host-contract.ts | 47 ++++-- experiments/ghostwright/test/preload-host.ts | 8 +- .../ghostwright/test/runtime-smoke.mjs | 7 +- 30 files changed, 453 insertions(+), 187 deletions(-) diff --git a/examples/demo/src/index.ts b/examples/demo/src/index.ts index 43a59b8..00d5a40 100644 --- a/examples/demo/src/index.ts +++ b/examples/demo/src/index.ts @@ -1,7 +1,7 @@ import { open, close, createTerm, grow, text } from '@bomb.sh/tty'; import { stdout } from 'node:process'; -async function run() { +async function run(): Promise { const term = await createTerm({ width: stdout.columns, height: stdout.rows }); const result = term.render([ open('root', { layout: { width: grow(), height: grow(), alignX: 'center', alignY: 'center' } }), diff --git a/experiments/ghostwright/examples/async/agent-closes-vi.test.ts b/experiments/ghostwright/examples/async/agent-closes-vi.test.ts index eb66473..821d1b1 100644 --- a/experiments/ghostwright/examples/async/agent-closes-vi.test.ts +++ b/experiments/ghostwright/examples/async/agent-closes-vi.test.ts @@ -1,6 +1,7 @@ import { expect, test } from 'bun:test'; import { mkdtemp, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; +// oxlint-disable-next-line no-restricted-imports -- path module needed for path resolution import { join } from 'node:path'; import { expectTerminal, withTerminalAsync } from '../../src/index.ts'; diff --git a/experiments/ghostwright/examples/async/bash-vi-roundtrip.test.ts b/experiments/ghostwright/examples/async/bash-vi-roundtrip.test.ts index 7e6d6bd..1083c63 100644 --- a/experiments/ghostwright/examples/async/bash-vi-roundtrip.test.ts +++ b/experiments/ghostwright/examples/async/bash-vi-roundtrip.test.ts @@ -1,6 +1,7 @@ import { expect, test } from 'bun:test'; import { mkdtemp, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; +// oxlint-disable-next-line no-restricted-imports -- path module needed for path resolution import { join } from 'node:path'; import { expectTerminal, withTerminalAsync } from '../../src/index.ts'; diff --git a/experiments/ghostwright/examples/effection/agent-closes-vi.test.ts b/experiments/ghostwright/examples/effection/agent-closes-vi.test.ts index d048dd3..3be1c31 100644 --- a/experiments/ghostwright/examples/effection/agent-closes-vi.test.ts +++ b/experiments/ghostwright/examples/effection/agent-closes-vi.test.ts @@ -1,6 +1,7 @@ import { expect, test } from 'bun:test'; import { mkdtemp, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; +// oxlint-disable-next-line no-restricted-imports -- path module needed for path resolution import { join } from 'node:path'; import { run } from 'effection'; import { expectTerminal, withTerminal } from '../../src/index.ts'; diff --git a/experiments/ghostwright/examples/effection/bash-vi-roundtrip.test.ts b/experiments/ghostwright/examples/effection/bash-vi-roundtrip.test.ts index 871c211..8200f08 100644 --- a/experiments/ghostwright/examples/effection/bash-vi-roundtrip.test.ts +++ b/experiments/ghostwright/examples/effection/bash-vi-roundtrip.test.ts @@ -1,6 +1,7 @@ import { expect, test } from 'bun:test'; import { mkdtemp, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; +// oxlint-disable-next-line no-restricted-imports -- path module needed for path resolution import { join } from 'node:path'; import { run } from 'effection'; import { expectTerminal, withTerminal } from '../../src/index.ts'; diff --git a/experiments/ghostwright/scripts/benchmark.ts b/experiments/ghostwright/scripts/benchmark.ts index 2c6345b..07b3ce7 100644 --- a/experiments/ghostwright/scripts/benchmark.ts +++ b/experiments/ghostwright/scripts/benchmark.ts @@ -1,17 +1,21 @@ import { TerminalSession } from '../src/terminal/session.ts'; -async function measure(name: string, operation: () => Promise, iterations: number) { +async function measure(params: { + name: string; + operation: () => Promise; + iterations: number; +}): Promise { const samples: number[] = []; - for (let index = 0; index < iterations; index++) { + for (let index = 0; index < params.iterations; index++) { const started = performance.now(); - await operation(); + await params.operation(); samples.push(performance.now() - started); } - samples.sort((a, b) => a - b); + // oxlint-disable-next-line no-console -- benchmark script console.log( JSON.stringify({ - name, - iterations, + name: params.name, + iterations: params.iterations, medianMs: samples[Math.floor(samples.length / 2)], minMs: samples[0], maxMs: samples.at(-1), diff --git a/experiments/ghostwright/scripts/build-ghostty-vt.ts b/experiments/ghostwright/scripts/build-ghostty-vt.ts index 815d1fb..943aace 100644 --- a/experiments/ghostwright/scripts/build-ghostty-vt.ts +++ b/experiments/ghostwright/scripts/build-ghostty-vt.ts @@ -1,13 +1,17 @@ import { $ } from 'bun'; import { readFile } from 'node:fs/promises'; import { createHash } from 'node:crypto'; +import { GhostwrightError } from '../src/errors.ts'; const root = new URL('..', import.meta.url).pathname, source = `${root}/.cache/ghostty`, lock = JSON.parse(await readFile(`${root}/ghostty.lock.json`, 'utf8')), zig = (await $`zig version`.text()).trim(); if (zig !== lock.zigVersion) - throw new Error(`Ghostwright artifact build requires Zig ${lock.zigVersion}, found ${zig}`); + throw new GhostwrightError({ + code: 'GW_ZIG_VERSION', + message: `Ghostwright artifact build requires Zig ${lock.zigVersion}, found ${zig}`, + }); const patch = `${root}/patches/0001-freestanding-kitty-direct-only.patch`; await $`git -C ${source} reset --hard ${lock.ghostty.commit}`; await $`git -C ${source} apply --check ${patch}`; @@ -16,6 +20,9 @@ const patchSha256 = createHash('sha256') .update(await readFile(patch)) .digest('hex'); if (lock.graphics?.freestandingPatchSha256 !== patchSha256) - throw new Error('Ghostwright freestanding Kitty patch checksum mismatch'); + throw new GhostwrightError({ + code: 'GW_PATCH_CHECKSUM', + message: 'Ghostwright freestanding Kitty patch checksum mismatch', + }); await $`cd ${source} && zig build -Demit-lib-vt -Dtarget=wasm32-freestanding -Doptimize=ReleaseSmall`; await $`cp ${source}/zig-out/bin/ghostty-vt.wasm ${root}/artifacts/ghostty-vt.wasm`; diff --git a/experiments/ghostwright/scripts/build-host-c.ts b/experiments/ghostwright/scripts/build-host-c.ts index dc64dd3..05fd8af 100644 --- a/experiments/ghostwright/scripts/build-host-c.ts +++ b/experiments/ghostwright/scripts/build-host-c.ts @@ -1,5 +1,6 @@ import { $ } from 'bun'; import { mkdir } from 'node:fs/promises'; +import { GhostwrightError } from '../src/errors.ts'; const root = new URL('..', import.meta.url).pathname, source = `${root}/native/pty-host-c`, @@ -25,7 +26,11 @@ if (process.platform === 'darwin') { await $`chmod +x ${output}`; await $`cp ${output} ${cache}/pty-host-c`; } else { - throw new Error(`unsupported C host build platform ${process.platform}-${process.arch}`); + throw new GhostwrightError({ + code: 'GW_UNSUPPORTED_PLATFORM', + message: `unsupported C host build platform ${process.platform}-${process.arch}`, + }); } +// oxlint-disable-next-line no-console -- build script console.log(`${cache}/pty-host-c`); diff --git a/experiments/ghostwright/scripts/build-host-rust.ts b/experiments/ghostwright/scripts/build-host-rust.ts index 6cf1298..895ce39 100644 --- a/experiments/ghostwright/scripts/build-host-rust.ts +++ b/experiments/ghostwright/scripts/build-host-rust.ts @@ -15,4 +15,5 @@ if (target) { await $`cp ${crate}/target/release/ghostwright-pty-host ${cache}/pty-host-rust`; } await $`chmod +x ${cache}/pty-host-rust`; +// oxlint-disable-next-line no-console -- build script console.log(`${cache}/pty-host-rust`); diff --git a/experiments/ghostwright/scripts/compare-hosts.ts b/experiments/ghostwright/scripts/compare-hosts.ts index 76d035b..9b22406 100644 --- a/experiments/ghostwright/scripts/compare-hosts.ts +++ b/experiments/ghostwright/scripts/compare-hosts.ts @@ -1,5 +1,6 @@ import { $ } from 'bun'; import { readdir, readFile, stat, writeFile } from 'node:fs/promises'; +// oxlint-disable-next-line no-restricted-imports -- path module needed for path resolution import { join } from 'node:path'; import { TerminalSession } from '../src/terminal/session.ts'; import { usePtyHostForTesting } from '../src/profile.ts'; @@ -12,7 +13,7 @@ const root = new URL('..', import.meta.url).pathname, { name: 'Rust', key: 'rust', path: `${root}/.cache/hosts/pty-host-rust` }, ]; -async function timed(operation: () => Promise) { +async function timed(operation: () => Promise): Promise { const started = performance.now(); await operation(); return performance.now() - started; @@ -25,7 +26,7 @@ const buildTimes = { for (const host of hosts) await runHostContract(host.path); -async function launchSamples(hostPath: string) { +async function launchSamples(hostPath: string): Promise { const restore = usePtyHostForTesting(hostPath), samples: number[] = []; try { @@ -43,7 +44,9 @@ async function launchSamples(hostPath: string) { return samples[Math.floor(samples.length / 2)]; } -async function transportThroughput(hostPath: string) { +async function transportThroughput( + hostPath: string, +): Promise<{ bytes: number; elapsed: number; mibPerSecond: number }> { const environment = Object.fromEntries( Object.entries(process.env).filter( (entry): entry is [string, string] => entry[1] !== undefined, @@ -82,7 +85,9 @@ async function transportThroughput(hostPath: string) { return { bytes, elapsed, mibPerSecond: bytes / (1024 * 1024) / (elapsed / 1000) }; } -async function sourceStats(directory: string) { +async function sourceStats( + directory: string, +): Promise<{ files: number; lines: number; nonblank: number; unsafe: number }> { const names = (await readdir(directory)).filter((name) => /\.(c|h|rs)$/.test(name)), sources = await Promise.all(names.map((name) => readFile(join(directory, name), 'utf8'))); return { @@ -154,4 +159,5 @@ ${table} - Zig remains a maintainer dependency only for building upstream \`ghostty-vt.wasm\`; it is absent from both PTY-host implementations. `; await writeFile(`${root}/HOST-COMPARISON.md`, document); +// oxlint-disable-next-line no-console -- comparison script console.log(document); diff --git a/experiments/ghostwright/scripts/fetch-ghostty.ts b/experiments/ghostwright/scripts/fetch-ghostty.ts index d2de82f..f121db1 100644 --- a/experiments/ghostwright/scripts/fetch-ghostty.ts +++ b/experiments/ghostwright/scripts/fetch-ghostty.ts @@ -1,6 +1,7 @@ import { $ } from 'bun'; import { existsSync } from 'node:fs'; import { mkdir, readFile } from 'node:fs/promises'; +import { GhostwrightError } from '../src/errors.ts'; const root = new URL('..', import.meta.url).pathname, cache = `${root}/.cache/ghostty`, @@ -12,4 +13,7 @@ await $`git -C ${cache} fetch --depth=1 origin ${lock.ghostty.commit}`; await $`git -C ${cache} checkout --detach ${lock.ghostty.commit}`; const actual = (await $`git -C ${cache} rev-parse HEAD`.text()).trim(); if (actual !== lock.ghostty.commit) - throw new Error(`Ghostty checkout mismatch: expected ${lock.ghostty.commit}, got ${actual}`); + throw new GhostwrightError({ + code: 'GW_CHECKOUT_MISMATCH', + message: `Ghostty checkout mismatch: expected ${lock.ghostty.commit}, got ${actual}`, + }); diff --git a/experiments/ghostwright/scripts/fix-declarations.ts b/experiments/ghostwright/scripts/fix-declarations.ts index 5dc2298..d600f18 100644 --- a/experiments/ghostwright/scripts/fix-declarations.ts +++ b/experiments/ghostwright/scripts/fix-declarations.ts @@ -1,4 +1,5 @@ import { readdir, readFile, writeFile } from 'node:fs/promises'; +// oxlint-disable-next-line no-restricted-imports -- path module needed for path resolution import { join } from 'node:path'; async function rewrite(directory: string): Promise { diff --git a/experiments/ghostwright/scripts/update-manifest.ts b/experiments/ghostwright/scripts/update-manifest.ts index 81798b6..84f00c7 100644 --- a/experiments/ghostwright/scripts/update-manifest.ts +++ b/experiments/ghostwright/scripts/update-manifest.ts @@ -7,7 +7,7 @@ const root = new URL('../artifacts/', import.meta.url), 'terminfo/78/xterm-ghostty', ]; const artifacts: Record = {}; -for (const name of files.sort()) { +for (const name of files.toSorted()) { artifacts[`artifacts/${name}`] = { sha256: createHash('sha256') .update(await readFile(new URL(name, root))) diff --git a/experiments/ghostwright/scripts/verify-artifacts.ts b/experiments/ghostwright/scripts/verify-artifacts.ts index b5d4197..c8b22c6 100644 --- a/experiments/ghostwright/scripts/verify-artifacts.ts +++ b/experiments/ghostwright/scripts/verify-artifacts.ts @@ -1,26 +1,43 @@ import { createHash } from 'node:crypto'; import { readFile } from 'node:fs/promises'; +import { GhostwrightError } from '../src/errors.ts'; const root = new URL('..', import.meta.url), lock = JSON.parse(await readFile(new URL('ghostty.lock.json', root), 'utf8')); if (lock.protocolVersion !== 1 || lock.bindingVersion !== 2) - throw new Error('protocol or binding version mismatch'); -for (const [path, entry] of Object.entries(lock.artifacts) as [string, { sha256: string }][]) { + throw new GhostwrightError({ + code: 'GW_VERSION_MISMATCH', + message: 'protocol or binding version mismatch', + }); +for (const [artifactPath, entry] of Object.entries(lock.artifacts) as [ + string, + { sha256: string }, +][]) { const actual = createHash('sha256') - .update(await readFile(new URL(path, root))) + .update(await readFile(new URL(artifactPath, root))) .digest('hex'); if (actual !== entry.sha256) - throw new Error(`${path}: checksum mismatch (expected ${entry.sha256}, got ${actual})`); + throw new GhostwrightError({ + code: 'GW_CHECKSUM_MISMATCH', + message: `${artifactPath}: checksum mismatch (expected ${entry.sha256}, got ${actual})`, + }); } -for (const path of Object.keys(lock.artifacts).filter((path) => path.includes('pty-host-'))) { - const binary = await readFile(new URL(path, root)); +for (const artifactPath of Object.keys(lock.artifacts).filter((p) => p.includes('pty-host-'))) { + const binary = await readFile(new URL(artifactPath, root)); if (!binary.includes(Buffer.from(`GWPT_PROTOCOL_VERSION=${lock.protocolVersion}`))) - throw new Error(`${path}: protocol marker mismatch`); + throw new GhostwrightError({ + code: 'GW_PROTOCOL_MARKER', + message: `${artifactPath}: protocol marker mismatch`, + }); } const wasmBytes = await readFile(new URL('artifacts/ghostty-vt.wasm', root)), wasm = await WebAssembly.compile(wasmBytes), exports = new Set(WebAssembly.Module.exports(wasm).map((x) => x.name)); for (const name of lock.requiredWasmExports) - if (!exports.has(name)) throw new Error(`ghostty-vt.wasm: missing export ${name}`); + if (!exports.has(name)) + throw new GhostwrightError({ + code: 'GW_MISSING_WASM_EXPORT', + message: `ghostty-vt.wasm: missing export ${name}`, + }); const instance = await WebAssembly.instantiate(wasm, { env: { log() {} } }), wasmExports = instance.exports as Record, pointer = wasmExports.ghostty_type_json(), @@ -30,18 +47,23 @@ while (bytes[end] !== 0) end++; const layouts = JSON.parse(new TextDecoder().decode(bytes.subarray(pointer, end))); for (const [name, size] of Object.entries(lock.abi.structSizes)) if (layouts[name]?.size !== size) - throw new Error( - `ghostty-vt.wasm: ${name} size mismatch (expected ${size}, got ${layouts[name]?.size})`, - ); + throw new GhostwrightError({ + code: 'GW_ABI_MISMATCH', + message: `ghostty-vt.wasm: ${name} size mismatch (expected ${size}, got ${layouts[name]?.size})`, + }); if (lock.graphics?.kittyGraphics) { const out = wasmExports.ghostty_wasm_alloc_u8_array(1); try { if (wasmExports.ghostty_build_info(2, out) !== 0 || bytes[out] === 0) - throw new Error('ghostty-vt.wasm: Kitty graphics capability is absent'); + throw new GhostwrightError({ + code: 'GW_KITTY_GRAPHICS', + message: 'ghostty-vt.wasm: Kitty graphics capability is absent', + }); } finally { wasmExports.ghostty_wasm_free_u8_array(out, 1); } } +// oxlint-disable-next-line no-console -- verify script console.log( `verified ${Object.keys(lock.artifacts).length} Ghostwright artifacts and ${Object.keys(lock.abi.structSizes).length} ABI layouts`, ); diff --git a/experiments/ghostwright/src/assertions/index.ts b/experiments/ghostwright/src/assertions/index.ts index d2afb1d..e980461 100644 --- a/experiments/ghostwright/src/assertions/index.ts +++ b/experiments/ghostwright/src/assertions/index.ts @@ -7,8 +7,15 @@ import type { StableAssertionOptions, TransientAssertionOptions, } from '../types.ts'; -import { Locator, TerminalSession } from '../terminal/session.ts'; -function diagnostic(session: TerminalSession, expected: string, timeout: number, settle?: number) { +import { Locator } from '../terminal/session.ts'; +import type { TerminalSession } from '../terminal/session.ts'; +// oxlint-disable-next-line max-params -- diagnostic needs all four params for failure reporting +function diagnostic( + session: TerminalSession, + expected: string, + timeout: number, + settle?: number, +): string { const s = session.screen.current(), tens = Array.from({ length: s.viewport.columns }, (_, column) => column % 10 === 0 ? String(Math.floor(column / 10) % 10) : ' ', @@ -30,12 +37,13 @@ function diagnostic(session: TerminalSession, expected: string, timeout: number, .join(', ') || 'none' }\n\n ${tens}\n ${ones}\n${rows}`; } +// oxlint-disable-next-line max-params -- wait needs all four params for polling logic async function wait( session: TerminalSession, test: () => boolean, timeout: number, message: () => string, -) { +): Promise { try { await session.waitForChange(test, timeout); } catch (cause) { @@ -45,7 +53,7 @@ async function wait( } class LocatorExpectation implements AsyncLocatorExpectation { constructor(readonly locator: Locator) {} - async toBePresent(options: AssertionOptions = {}) { + async toBePresent(options: AssertionOptions = {}): Promise { const timeout = options.timeoutMs ?? this.locator.session.options.assertionTimeoutMs ?? 5000; try { return await this.locator.unique(timeout); @@ -61,7 +69,7 @@ class LocatorExpectation implements AsyncLocatorExpectation { ); } } - async toBeStable(options: StableAssertionOptions = {}) { + async toBeStable(options: StableAssertionOptions = {}): Promise { const timeout = options.timeoutMs ?? this.locator.session.options.assertionTimeoutMs ?? 5000, settle = options.settleMs ?? this.locator.session.options.settleMs ?? 100, start = performance.now(); @@ -103,7 +111,7 @@ class LocatorExpectation implements AsyncLocatorExpectation { }); } } - async toBeAbsent(options: StableAssertionOptions = {}) { + async toBeAbsent(options: StableAssertionOptions = {}): Promise { const timeout = options.timeoutMs ?? this.locator.session.options.assertionTimeoutMs ?? 5000, settle = options.settleMs ?? this.locator.session.options.settleMs ?? 100, start = performance.now(); @@ -160,7 +168,7 @@ class TerminalExpectation implements AsyncTerminalExpectation { async toSatisfy( predicate: (snapshot: ScreenSnapshot) => boolean, options: StableAssertionOptions = {}, - ) { + ): Promise { const timeout = options.timeoutMs ?? this.session.options.assertionTimeoutMs ?? 5000, settle = options.settleMs ?? this.session.options.settleMs ?? 100, started = performance.now(); @@ -197,7 +205,7 @@ class TerminalExpectation implements AsyncTerminalExpectation { async toHaveShown( predicate: (snapshot: ScreenSnapshot) => boolean, options: TransientAssertionOptions = {}, - ) { + ): Promise { const timeout = options.timeoutMs ?? this.session.options.assertionTimeoutMs ?? 5000, baseline = typeof options.since === 'number' @@ -217,13 +225,14 @@ class TerminalExpectation implements AsyncTerminalExpectation { ); return result as ScreenRevision; } - toHaveShownText(text: string, options: TransientAssertionOptions = {}) { + toHaveShownText(text: string, options: TransientAssertionOptions = {}): Promise { return this.toHaveShown( (snapshot) => snapshot.lines.some((line) => line.text.includes(text)), options, ); } } +/** Create an async assertion expectation for a locator or terminal session. */ export function expectTerminal( target: Locator | TerminalSession, ): LocatorExpectation | TerminalExpectation { diff --git a/experiments/ghostwright/src/async.ts b/experiments/ghostwright/src/async.ts index 51f2f5b..f404c86 100644 --- a/experiments/ghostwright/src/async.ts +++ b/experiments/ghostwright/src/async.ts @@ -1,6 +1,7 @@ import { call, run } from 'effection'; import type { AsyncTerminal, TerminalLaunchOptions } from './types.ts'; import { TerminalSession } from './terminal/session.ts'; +/** Launch a terminal session, run an async body, and clean up when done. */ export async function withTerminalAsync( options: TerminalLaunchOptions, body: (terminal: AsyncTerminal) => Promise, diff --git a/experiments/ghostwright/src/effection/index.ts b/experiments/ghostwright/src/effection/index.ts index afaa69b..74c3e99 100644 --- a/experiments/ghostwright/src/effection/index.ts +++ b/experiments/ghostwright/src/effection/index.ts @@ -1,11 +1,11 @@ import { call, type Operation } from 'effection'; import type { - ActionReceipt, AssertionOptions, KeyName, HistoryQuery, HistorySearchOptions, KeyPress, + LocatorMatch, MouseOptions, RevisionCollectionOptions, OperationLocator, @@ -14,31 +14,36 @@ import type { Point, Rect, StableAssertionOptions, + ScreenRevision, ScreenSnapshot, TerminalLaunchOptions, TextLocatorOptions, TraceableInputOptions, TransientAssertionOptions, + Viewport, WheelOptions, } from '../types.ts'; import { expectTerminal as expectAsync } from '../assertions/index.ts'; -import { Locator, TerminalSession } from '../terminal/session.ts'; +import type { Locator } from '../terminal/session.ts'; +import { TerminalSession } from '../terminal/session.ts'; const op = (fn: () => Promise): Operation => call(fn); +/** Effection wrapper around an async Locator. */ export class EffectionLocator implements OperationLocator { constructor(readonly inner: Locator) {} - nth(i: number) { + nth(i: number): EffectionLocator { return new EffectionLocator(this.inner.nth(i)); } - region(r: Rect) { + region(r: Rect): EffectionLocator { return new EffectionLocator(this.inner.region(r)); } - matches() { + matches(): readonly LocatorMatch[] { return this.inner.matches(); } - click(o?: MouseOptions) { + click(o?: MouseOptions): Operation { return op(() => this.inner.click(o)); } } +/** Effection wrapper around a TerminalSession. */ export class EffectionTerminal implements OperationTerminal { constructor(readonly inner: TerminalSession) {} keyboard = { @@ -54,6 +59,7 @@ export class EffectionTerminal implements OperationTerminal { up: (p: Point, o?: MouseOptions) => op(() => this.inner.mouse.up(p, o)), click: (p: Point, o?: MouseOptions) => op(() => this.inner.mouse.click(p, o)), doubleClick: (p: Point, o?: MouseOptions) => op(() => this.inner.mouse.doubleClick(p, o)), + // oxlint-disable-next-line max-params -- wraps mouse.drag(start, end, options) API drag: (a: Point, b: Point, o?: MouseOptions) => op(() => this.inner.mouse.drag(a, b, o)), wheel: (o: WheelOptions) => op(() => this.inner.mouse.wheel(o)), }; @@ -78,7 +84,7 @@ export class EffectionTerminal implements OperationTerminal { inspectImage: (id: number) => op(() => this.inner.graphics.inspectImage(id)), copyImageData: (id: number) => op(() => this.inner.graphics.copyImageData(id)), }; - getByText(t: string, o?: TextLocatorOptions) { + getByText(t: string, o?: TextLocatorOptions): EffectionLocator { return new EffectionLocator(this.inner.getByText(t, o) as Locator); } region(r: Rect): OperationRegion { @@ -88,13 +94,14 @@ export class EffectionTerminal implements OperationTerminal { snapshot: () => x.snapshot(), }; } - resize(v: any) { + resize(v: Viewport): Operation { return op(() => this.inner.resize(v)); } - close() { + close(): Operation { return op(() => this.inner.close()); } } +/** Launch a terminal session, run an Effection operation body, and clean up when done. */ export function* withTerminal( options: TerminalLaunchOptions, body: (terminal: OperationTerminal) => Operation, @@ -129,16 +136,37 @@ export function* withTerminal( yield* call(() => session.close()); } } -export function expectOperation(target: EffectionLocator | EffectionTerminal) { +/** Effection locator assertion expectation. */ +export interface EffectionLocatorExpectation { + toBePresent(options?: AssertionOptions): Operation; + toBeAbsent(options?: StableAssertionOptions): Operation; + toBeStable(options?: StableAssertionOptions): Operation; +} +/** Effection terminal assertion expectation. */ +export interface EffectionTerminalExpectation { + toSatisfy( + predicate: (snapshot: ScreenSnapshot) => boolean, + options?: StableAssertionOptions, + ): Operation; + toHaveShown( + predicate: (snapshot: ScreenSnapshot) => boolean, + options?: TransientAssertionOptions, + ): Operation; + toHaveShownText(text: string, options?: TransientAssertionOptions): Operation; +} +/** Create an Effection operation expectation for a locator or terminal. */ +export function expectOperation( + target: EffectionLocator | EffectionTerminal, +): EffectionLocatorExpectation | EffectionTerminalExpectation { if (target instanceof EffectionLocator) { - const e = expectAsync(target.inner) as any; + const e = expectAsync(target.inner); return { toBePresent: (o?: AssertionOptions) => op(() => e.toBePresent(o)), toBeAbsent: (o?: StableAssertionOptions) => op(() => e.toBeAbsent(o)), toBeStable: (o?: StableAssertionOptions) => op(() => e.toBeStable(o)), }; } - const e = expectAsync(target.inner) as any; + const e = expectAsync(target.inner); return { toSatisfy: (predicate: (snapshot: ScreenSnapshot) => boolean, o?: StableAssertionOptions) => op(() => e.toSatisfy(predicate, o)), diff --git a/experiments/ghostwright/src/errors.ts b/experiments/ghostwright/src/errors.ts index fc85f33..dd5fbc8 100644 --- a/experiments/ghostwright/src/errors.ts +++ b/experiments/ghostwright/src/errors.ts @@ -1,49 +1,67 @@ +/** Base error class for all Ghostwright errors. */ export class GhostwrightError extends Error { readonly code: string; sessionName?: string; tracePath?: string; suppressed?: unknown[]; - constructor(code: string, message: string, options?: ErrorOptions & { sessionName?: string }) { - super(message, options); + constructor(params: { code: string; message: string } & ErrorOptions & { sessionName?: string }) { + super(params.message, params); this.name = new.target.name; - this.code = code; - this.sessionName = options?.sessionName; + this.code = params.code; + this.sessionName = params.sessionName; } } function errorType(name: T, code: string) { return class extends GhostwrightError { constructor(message: string, options?: ErrorOptions & { sessionName?: string }) { - super(code, message, options); + super({ code, message, ...options }); this.name = name; } }; } +/** Error for unsupported platform detection. */ export class UnsupportedPlatformError extends errorType( 'UnsupportedPlatformError', 'GW_UNSUPPORTED_PLATFORM', ) {} +/** Error for asset integrity check failures. */ export class AssetIntegrityError extends errorType('AssetIntegrityError', 'GW_ASSET_INTEGRITY') {} +/** Error for Deno permission denials. */ export class DenoPermissionError extends errorType('DenoPermissionError', 'GW_DENO_PERMISSION') {} +/** Error for reserved environment variable conflicts. */ export class ReservedEnvironmentError extends errorType( 'ReservedEnvironmentError', 'GW_RESERVED_ENV', ) {} +/** Error for host launch failures. */ export class LaunchError extends errorType('LaunchError', 'GW_LAUNCH') {} +/** Error for protocol violations. */ export class ProtocolError extends errorType('ProtocolError', 'GW_PROTOCOL') {} +/** Error when host command exceeds timeout. */ export class HostCommandTimeoutError extends errorType( 'HostCommandTimeoutError', 'GW_HOST_TIMEOUT', ) {} +/** Error when sidecar process exits unexpectedly. */ export class SidecarExitedError extends errorType('SidecarExitedError', 'GW_SIDECAR_EXITED') {} +/** Error when process exits unexpectedly. */ export class ProcessExitedError extends errorType('ProcessExitedError', 'GW_PROCESS_EXITED') {} +/** Error when session is already closed. */ export class SessionClosedError extends errorType('SessionClosedError', 'GW_SESSION_CLOSED') {} +/** Error for coordinate out-of-range. */ export class CoordinateRangeError extends errorType( 'CoordinateRangeError', 'GW_COORDINATE_RANGE', ) {} +/** Error for strict locator match failures. */ export class StrictLocatorError extends errorType('StrictLocatorError', 'GW_LOCATOR_STRICT') {} +/** Error for terminal assertion failures. */ export class TerminalAssertionError extends errorType('TerminalAssertionError', 'GW_ASSERTION') {} +/** Error when terminal history has been evicted. */ export class HistoryEvictedError extends errorType('HistoryEvictedError', 'GW_HISTORY_EVICTED') {} +/** Error when terminal history changes unexpectedly. */ export class HistoryChangedError extends errorType('HistoryChangedError', 'GW_HISTORY_CHANGED') {} +/** Error writing trace files. */ export class TraceWriteError extends errorType('TraceWriteError', 'GW_TRACE_WRITE') {} +/** Error during cleanup operations. */ export class CleanupError extends errorType('CleanupError', 'GW_CLEANUP') {} diff --git a/experiments/ghostwright/src/index.ts b/experiments/ghostwright/src/index.ts index cade8ab..db52f36 100644 --- a/experiments/ghostwright/src/index.ts +++ b/experiments/ghostwright/src/index.ts @@ -5,6 +5,7 @@ export { withTerminal } from './effection/index.ts'; export { replayTrace, type ReplayResult } from './tracing/replay.ts'; import { expectTerminal as expectAsync } from './assertions/index.ts'; import { EffectionLocator, EffectionTerminal, expectOperation } from './effection/index.ts'; +import { Locator, type TerminalSession } from './terminal/session.ts'; import type { AsyncLocator, AsyncLocatorExpectation, @@ -16,6 +17,7 @@ import type { OperationTerminalExpectation, } from './types.ts'; +/** Create an assertion expectation for a terminal or locator (async or effection). */ export function expectTerminal(target: OperationLocator): OperationLocatorExpectation; export function expectTerminal(target: AsyncLocator): AsyncLocatorExpectation; export function expectTerminal(target: OperationTerminal): OperationTerminalExpectation; @@ -30,7 +32,7 @@ export function expectTerminal( return ( target instanceof EffectionLocator || target instanceof EffectionTerminal ? expectOperation(target) - : expectAsync(target as any) + : expectAsync(target instanceof Locator ? target : (target as unknown as TerminalSession)) ) as | OperationLocatorExpectation | AsyncLocatorExpectation diff --git a/experiments/ghostwright/src/profile.ts b/experiments/ghostwright/src/profile.ts index df66d0d..9174bc5 100644 --- a/experiments/ghostwright/src/profile.ts +++ b/experiments/ghostwright/src/profile.ts @@ -1,5 +1,6 @@ import { createHash } from 'node:crypto'; import { readFile, realpath } from 'node:fs/promises'; +// oxlint-disable-next-line no-restricted-imports -- path module needed for path resolution import { dirname, resolve, sep } from 'node:path'; import { fileURLToPath } from 'node:url'; import { @@ -29,11 +30,12 @@ export function usePtyHostForTesting(path: string | undefined) { testPtyHostPath = previous; }; } -function versionAtLeast(actual: string, required: readonly [number, number]) { +function versionAtLeast(actual: string, required: readonly [number, number]): boolean { const [major = 0, minor = 0] = actual.replace(/^v/, '').split('.').map(Number); return major > required[0] || (major === required[0] && minor >= required[1]); } -export function assertSupportedRuntime() { +/** Assert the current runtime meets Ghostwright minimum version requirements. */ +export function assertSupportedRuntime(): void { const deno = (globalThis as unknown as { Deno?: { version: { deno: string } } }).Deno, bun = (globalThis as unknown as { Bun?: { version: string } }).Bun; if (deno && !versionAtLeast(deno.version.deno, [2, 2])) @@ -43,6 +45,7 @@ export function assertSupportedRuntime() { if (!deno && !bun && !versionAtLeast(process.versions.node, [22, 0])) throw new LaunchError(`Ghostwright requires Node 22 or newer; found ${process.versions.node}`); } +/** Normalize a partial viewport to required dimensions with defaults. */ export function normalizeViewport(input?: Viewport): Required { const columns = input?.columns ?? 80, rows = input?.rows ?? 24; @@ -54,7 +57,7 @@ export function normalizeViewport(input?: Viewport): Required { columns > 65535 || rows > 65535 ) - throw new RangeError(`Invalid viewport ${columns}x${rows}`); + throw new CoordinateRangeError(`Invalid viewport ${columns}x${rows}`); const widthPixels = input?.widthPixels ?? columns * 10, heightPixels = input?.heightPixels ?? rows * 20; if ( @@ -65,9 +68,10 @@ export function normalizeViewport(input?: Viewport): Required { widthPixels > 65535 || heightPixels > 65535 ) - throw new RangeError(`Invalid pixel viewport ${widthPixels}x${heightPixels}`); + throw new CoordinateRangeError(`Invalid pixel viewport ${widthPixels}x${heightPixels}`); return { columns, rows, widthPixels, heightPixels }; } +/** Build the environment object with ghostwright terminal profile variables. */ export function profileEnvironment( explicit: Readonly> | undefined, terminfo: string, @@ -87,7 +91,8 @@ export function profileEnvironment( TERM_PROGRAM_VERSION: PACKAGE_VERSION, } as Record; } -export function target(os = process.platform, arch = process.arch) { +/** Return the platform key for the current or specified OS/arch. */ +export function target(os = process.platform, arch = process.arch): string { const key = `${os}-${arch}`; const supported = ['darwin-arm64', 'darwin-x64', 'linux-arm64', 'linux-x64']; if (!supported.includes(key)) @@ -96,7 +101,10 @@ export function target(os = process.platform, arch = process.arch) { ); return key; } -export async function resolveAssets(options: TerminalLaunchOptions) { +/** Resolve and validate all required runtime assets. */ +export async function resolveAssets( + _options: TerminalLaunchOptions, +): Promise<{ root: string; host: string; terminfo: string; wasm: string }> { const root = resolve(dirname(fileURLToPath(import.meta.url)), '../artifacts'), bundledHost = resolve(root, `pty-host-${target()}`), host = testPtyHostPath ?? bundledHost, diff --git a/experiments/ghostwright/src/pty/client.ts b/experiments/ghostwright/src/pty/client.ts index 5b0f8f8..bd17ce0 100644 --- a/experiments/ghostwright/src/pty/client.ts +++ b/experiments/ghostwright/src/pty/client.ts @@ -1,4 +1,5 @@ import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process'; +// oxlint-disable-next-line no-restricted-imports -- path module needed for path resolution import { dirname } from 'node:path'; import { DenoPermissionError, @@ -13,7 +14,7 @@ import { FrameDecoder, FrameKind, type Frame, -} from './protocol'; +} from './protocol.ts'; export interface SidecarEvents { output: (data: Uint8Array, sequence: number) => void; @@ -22,6 +23,9 @@ export interface SidecarEvents { fatal: (error: Error) => void; } type Key = keyof SidecarEvents; +type SpawnResponse = { pid?: number; processGroupId?: number }; +type AckResponse = { bytesWritten?: number }; +/** IPC client that communicates with a PTY host sidecar process. */ export class SidecarClient { #child: ChildProcessWithoutNullStreams; #sequence = 1; @@ -29,7 +33,7 @@ export class SidecarClient { number, { kind: FrameKind; - resolve: (v: any) => void; + resolve: (v: unknown) => void; reject: (e: unknown) => void; timer: ReturnType; interim?: Record; @@ -76,31 +80,34 @@ export class SidecarClient { ), ); } - static async start(path: string, timeoutMs = 5000) { + static async start(path: string, timeoutMs = 5000): Promise { const child = spawn(path, [], { stdio: ['pipe', 'pipe', 'pipe'] }); const c = new SidecarClient(child, timeoutMs); await c.request(FrameKind.HELLO, { minVersion: 1, maxVersion: 1, clientVersion: '0.1.0' }); return c; } - on(key: K, listener: SidecarEvents[K]) { + on(key: K, listener: SidecarEvents[K]): () => boolean { this.#listeners[key].add(listener); return () => this.#listeners[key].delete(listener); } - #emit(key: K, ...args: Parameters) { + #emit(key: K, ...args: Parameters): void { for (const f of this.#listeners[key]) try { - (f as (...a: any[]) => void)(...args); + (f as (...a: readonly unknown[]) => void)(...args); } catch (e) { this.#fail(e as Error); } } - #frame(f: Frame) { + #frame(f: Frame): void { if (f.kind === FrameKind.OUTPUT) { this.#emit('output', f.payload, f.sequence); return; } if (f.kind === FrameKind.PROCESS_EXIT) { - this.#emit('exit', decodeCbor(f.payload) as any); + this.#emit( + 'exit', + decodeCbor(f.payload) as { exitCode: number | null; signal: string | null }, + ); return; } if (f.kind === FrameKind.PTY_EOF) { @@ -108,7 +115,7 @@ export class SidecarClient { return; } if (f.kind === FrameKind.ERROR && f.correlation === 0) { - const d = decodeCbor(f.payload) as any; + const d = decodeCbor(f.payload) as { code: string; message: string }; this.#fail(new ProtocolError(`${d.code}: ${d.message}`)); return; } @@ -124,7 +131,7 @@ export class SidecarClient { clearTimeout(p.timer); this.#pending.delete(f.correlation); if (f.kind === FrameKind.ERROR) { - const d = decodeCbor(f.payload) as any; + const d = decodeCbor(f.payload) as { code: string; message: string }; p.reject(new ProtocolError(`${d.code}: ${d.message}`)); } else { const response = f.payload.length ? decodeCbor(f.payload) : {}; @@ -133,7 +140,7 @@ export class SidecarClient { ); } } - #fail(error: Error) { + #fail(error: Error): void { if (this.#closed) return; this.#closed = true; for (const p of this.#pending.values()) { @@ -144,7 +151,13 @@ export class SidecarClient { this.#emit('fatal', error); this.#child.kill('SIGKILL'); } - request(kind: FrameKind, value?: unknown, raw = false, timeout = this.commandTimeoutMs) { + // oxlint-disable-next-line max-params -- request needs kind, value, raw flag, and timeout + request( + kind: FrameKind, + value?: unknown, + raw = false, + timeout = this.commandTimeoutMs, + ): Promise { if (this.#closed) return Promise.reject(new SidecarExitedError('PTY host is closed')); if (this.#sequence === 0xffffffff) return Promise.reject(new ProtocolError('Client sequence exhausted')); @@ -155,7 +168,7 @@ export class SidecarClient { ? new Uint8Array() : encodeCbor(value); const data = encodeFrame({ kind, sequence, correlation: 0, payload }); - return new Promise((resolve, reject) => { + return new Promise((resolve, reject) => { const timer = setTimeout(() => { this.#pending.delete(sequence); let fallback = 'not attempted: no trusted application process group'; @@ -178,7 +191,12 @@ export class SidecarClient { reject(error); this.#fail(error); }, timeout); - this.#pending.set(sequence, { kind, resolve, reject, timer }); + this.#pending.set(sequence, { + kind, + resolve: resolve as (v: unknown) => void, + reject, + timer, + }); this.#child.stdin.write(data, (e) => { if (e) { clearTimeout(timer); @@ -188,22 +206,22 @@ export class SidecarClient { }); }); } - async spawn(value: unknown) { - const result = await this.request(FrameKind.SPAWN, value); + async spawn(value: unknown): Promise { + const result = await this.request(FrameKind.SPAWN, value); this.#applicationPid = result.pid; this.#applicationPgid = result.processGroupId; return result; } - write(data: Uint8Array) { - return this.request(FrameKind.WRITE, data, true); + write(data: Uint8Array): Promise { + return this.request(FrameKind.WRITE, data, true); } - resize(value: unknown) { - return this.request(FrameKind.RESIZE, value); + resize(value: unknown): Promise { + return this.request(FrameKind.RESIZE, value); } - signal(value: unknown) { - return this.request(FrameKind.SIGNAL, value); + signal(value: unknown): Promise { + return this.request(FrameKind.SIGNAL, value); } - async close(timeout?: number) { + async close(timeout?: number): Promise { if (this.#closed) return; try { await this.request(FrameKind.CLOSE, undefined, false, timeout); @@ -212,7 +230,7 @@ export class SidecarClient { this.#child.stdin.end(); } } - forceKill() { + forceKill(): void { this.#closed = true; this.#child.kill('SIGKILL'); } diff --git a/experiments/ghostwright/src/pty/protocol.ts b/experiments/ghostwright/src/pty/protocol.ts index 4f53944..b61d9bd 100644 --- a/experiments/ghostwright/src/pty/protocol.ts +++ b/experiments/ghostwright/src/pty/protocol.ts @@ -25,7 +25,7 @@ export interface Frame { payload: Uint8Array; } -const concat = (parts: readonly Uint8Array[]) => { +const concat = (parts: readonly Uint8Array[]): Uint8Array => { const n = parts.reduce((s, p) => s + p.length, 0), out = new Uint8Array(n); let o = 0; @@ -45,6 +45,7 @@ function head(major: number, n: number): Uint8Array { new DataView(b.buffer).setUint32(1, n); return b; } +/** Encode a JavaScript value to CBOR binary format. */ export function encodeCbor(value: unknown): Uint8Array { if (value === null) return Uint8Array.of(0xf6); if (value === false) return Uint8Array.of(0xf4); @@ -63,15 +64,17 @@ export function encodeCbor(value: unknown): Uint8Array { const entries = Object.entries(value as Record) .filter(([, v]) => v !== undefined) .map(([k, v]) => [encodeCbor(k), encodeCbor(v)] as const) - .sort((a, b) => a[0].length - b[0].length || compare(a[0], b[0])); + .toSorted((a, b) => a[0].length - b[0].length || compare(a[0], b[0])); return concat([head(5, entries.length), ...entries.flat()]); } throw new ProtocolError(`Unsupported CBOR value: ${typeof value}`); } -function compare(a: Uint8Array, b: Uint8Array) { +function compare(a: Uint8Array, b: Uint8Array): number { for (let i = 0; i < Math.min(a.length, b.length); i++) if (a[i] !== b[i]) return a[i] - b[i]; return a.length - b.length; } +const bad = (): ProtocolError => new ProtocolError('Truncated CBOR payload'); +/** Decode a CBOR binary payload to a JavaScript value. */ export function decodeCbor(bytes: Uint8Array): unknown { let p = 0; const readLen = (ai: number) => { @@ -98,7 +101,6 @@ export function decodeCbor(bytes: Uint8Array): unknown { } throw new ProtocolError('Unsupported or indefinite CBOR length'); }; - const bad = () => new ProtocolError('Truncated CBOR payload'); const one = (depth = 0): unknown => { if (depth > 64) throw new ProtocolError('CBOR nesting limit exceeded'); if (p >= bytes.length) throw bad(); @@ -155,6 +157,7 @@ export function decodeCbor(bytes: Uint8Array): unknown { if (p !== bytes.length) throw new ProtocolError('Trailing CBOR bytes'); return result; } +/** Encode a frame to the GWPT wire format. */ export function encodeFrame(frame: Frame): Uint8Array { const raw = frame.kind === FrameKind.WRITE || frame.kind === FrameKind.OUTPUT; const max = raw ? 65536 : 1024 * 1024; diff --git a/experiments/ghostwright/src/terminal/session.ts b/experiments/ghostwright/src/terminal/session.ts index 72a9e4b..1b28ac2 100644 --- a/experiments/ghostwright/src/terminal/session.ts +++ b/experiments/ghostwright/src/terminal/session.ts @@ -1,7 +1,9 @@ +// oxlint-disable-next-line no-restricted-imports -- path module needed for path resolution import { resolve } from 'node:path'; import { CoordinateRangeError, DenoPermissionError, + GhostwrightError, HistoryChangedError, HistoryEvictedError, LaunchError, @@ -109,22 +111,29 @@ export class TerminalSession implements AsyncTerminal { typeof options.trace === 'object' ? (options.trace.directory ?? '.ghostwright') : '.ghostwright'; - this.#trace = new SessionTrace(options, t, dir); + this.#trace = new SessionTrace({ options, policy: t, directory: dir }); this.#exitPromise = new Promise((r) => (this.#exitResolve = r)); } static async launch(options: TerminalLaunchOptions) { assertSupportedRuntime(); if (!options.command || options.command.includes('\0')) - throw new TypeError('command must be a nonempty NUL-free string'); + throw new GhostwrightError({ + code: 'GW_INVALID_ARGUMENT', + message: 'command must be a nonempty NUL-free string', + }); for (const [label, value] of [ - ...(options.args ?? []).map((value, index) => [`argument ${index}`, value] as const), - ...Object.entries(options.env ?? {}).flatMap(([key, value]) => [ + ...(options.args ?? []).map((arg, index) => [`argument ${index}`, arg] as const), + ...Object.entries(options.env ?? {}).flatMap(([key, envValue]) => [ [`environment key ${key}`, key] as const, - [`environment value ${key}`, value] as const, + [`environment value ${key}`, envValue] as const, ]), ...(options.cwd ? [['cwd', options.cwd] as const] : []), ]) - if (value.includes('\0')) throw new TypeError(`${label} must not contain NUL`); + if (value.includes('\0')) + throw new GhostwrightError({ + code: 'GW_INVALID_ARGUMENT', + message: `${label} must not contain NUL`, + }); for (const [label, value] of [ ['commandTimeoutMs', options.commandTimeoutMs], ['assertionTimeoutMs', options.assertionTimeoutMs], @@ -138,7 +147,7 @@ export class TerminalSession implements AsyncTerminal { ['graphics.storageLimitBytes', options.graphics?.storageLimitBytes], ] as const) if (value !== undefined && (!Number.isSafeInteger(value) || value < 0)) - throw new RangeError(`${label} must be a nonnegative safe integer`); + throw new CoordinateRangeError(`${label} must be a nonnegative safe integer`); const self = new TerminalSession(options), assets = await resolveAssets(options), cwd = resolve(options.cwd ?? process.cwd()); @@ -197,7 +206,7 @@ export class TerminalSession implements AsyncTerminal { self.#notify(); self.#exitResolve(self.#status); }); - let spawned: any; + let spawned: { pid?: number; processGroupId?: number }; try { spawned = await self.#host.spawn({ command: options.command, @@ -248,7 +257,7 @@ export class TerminalSession implements AsyncTerminal { return this.#engine.now(); } #notify() { - for (const f of [...this.#listeners]) f(); + for (const f of this.#listeners) f(); } subscribe(f: () => void) { this.#listeners.add(f); @@ -342,20 +351,27 @@ export class TerminalSession implements AsyncTerminal { }); this.#notify(); } - #ensure(op: string) { + #ensure(op: string): void { if (this.#closed) throw new SessionClosedError(`Cannot ${op}: terminal session is closed`); } - async #send(kind: FrameKind, value: unknown, raw = false, delivered = true) { + // oxlint-disable-next-line max-params -- internal method + async #send( + kind: FrameKind, + value: unknown, + raw = false, + delivered = true, + ): Promise { this.#ensure('perform action'); const before = this.#revision, sequence = ++this.#action; - let ack: any; + let ack: { bytesWritten?: number }; if (kind === FrameKind.WRITE) ack = await this.#command(() => this.#host.write(value as Uint8Array)); else if (kind === FrameKind.RESIZE) ack = await this.#command(() => this.#host.resize(value)); else if (kind === FrameKind.SIGNAL) ack = await this.#command(() => this.#host.signal(value)); - else throw new Error('Unsupported action'); - const receipt: Object = Object.freeze({ + else + throw new GhostwrightError({ code: 'GW_UNSUPPORTED_ACTION', message: 'Unsupported action' }); + const receipt: Readonly = Object.freeze({ actionSequence: sequence, screenSequenceBefore: before, acknowledgedAt: this.#engine.now(), @@ -371,6 +387,7 @@ export class TerminalSession implements AsyncTerminal { }); return receipt as ActionReceipt; } + // oxlint-disable-next-line max-params -- internal method async #write( data: Uint8Array, delivered = data.length > 0, @@ -430,7 +447,8 @@ export class TerminalSession implements AsyncTerminal { `Coordinate (${p.column},${p.row}) is outside ${this.#viewport.columns}x${this.#viewport.rows}`, ); } - #mouse(action: 'move' | 'down' | 'up', p: Point, o: MouseOptions = {}) { + // oxlint-disable-next-line max-params -- internal method + #mouse(action: 'move' | 'down' | 'up', p: Point, o: MouseOptions = {}): Promise { this.#point(p); const wasDown = this.#mouseDown; if (action === 'down') this.#mouseDown = true; @@ -455,6 +473,7 @@ export class TerminalSession implements AsyncTerminal { await this.mouse.click(p, o); return this.mouse.click(p, o); }, + // oxlint-disable-next-line max-params -- wraps mouse API drag: async (a: Point, b: Point, o?: MouseOptions) => { await this.#mouse('down', a, o); await this.#mouse('move', b, o); @@ -463,7 +482,7 @@ export class TerminalSession implements AsyncTerminal { wheel: (o: WheelOptions) => { this.#point(o); if (!Number.isInteger(o.deltaRows) || !Number.isInteger(o.deltaColumns ?? 0)) - throw new RangeError('Wheel deltas must be integers'); + throw new CoordinateRangeError('Wheel deltas must be integers'); const parts: Uint8Array[] = []; for (let index = 0; index < Math.abs(o.deltaRows); index++) parts.push(this.#engine.encodeMouse('down', o, { button: o.deltaRows < 0 ? 4 : 5 }, false)); @@ -574,12 +593,12 @@ export class TerminalSession implements AsyncTerminal { } async waitForChange(test: () => boolean, timeout: number) { if (test()) return; - await new Promise((resolve, reject) => { + await new Promise((resolvePromise, reject) => { const off = this.subscribe(() => { if (test()) { clearTimeout(timer); off(); - resolve(); + resolvePromise(); } else if (this.#status.state === 'closed' || this.#status.state === 'failed') { clearTimeout(timer); off(); @@ -592,11 +611,12 @@ export class TerminalSession implements AsyncTerminal { }), timer = setTimeout(() => { off(); - reject(new Error('timeout')); + reject(new TerminalAssertionError('timeout')); }, timeout); }); } - async #timeout(p: Promise, ms: number, error: () => Error) { + // oxlint-disable-next-line max-params -- internal method + async #timeout(p: Promise, ms: number, error: () => Error): Promise { return new Promise((r, j) => { const t = setTimeout(() => j(error()), ms); p.then( @@ -611,15 +631,15 @@ export class TerminalSession implements AsyncTerminal { ); }); } - #baselineSequence(value: ActionReceipt | ScreenRevision | number) { + #baselineSequence(value: ActionReceipt | ScreenRevision | number): number { if (typeof value === 'number') { if (!Number.isSafeInteger(value) || value < 0) - throw new RangeError('revision sequence must be a nonnegative safe integer'); + throw new CoordinateRangeError('revision sequence must be a nonnegative safe integer'); return value; } return 'screenSequenceBefore' in value ? value.screenSequenceBefore : value.sequence; } - revisionsSince(sequence: number) { + revisionsSince(sequence: number): ScreenRevision[] { const earliest = this.#history[0]?.sequence ?? this.#revision, latest = this.#history.at(-1)?.sequence ?? this.#revision; if (sequence < earliest - 1) @@ -628,19 +648,21 @@ export class TerminalSession implements AsyncTerminal { ); return this.#history.filter((revision) => revision.sequence > sequence); } - revisionRange(query: RevisionRangeQuery) { + revisionRange(query: RevisionRangeQuery): readonly ScreenRevision[] { const since = this.#baselineSequence(query.since), until = query.until === undefined ? undefined : this.#baselineSequence(query.until), max = this.options.history?.maxRevisions ?? 1000, limit = query.limit ?? max; if (!Number.isSafeInteger(limit) || limit <= 0) - throw new RangeError('revision limit must be positive'); + throw new CoordinateRangeError('revision limit must be positive'); if (limit > max) - throw new RangeError( + throw new CoordinateRangeError( `revision limit ${limit} exceeds the configured retained maximum ${max}`, ); if (until !== undefined && until < since) - throw new RangeError('revision until sequence must not precede its exclusive baseline'); + throw new CoordinateRangeError( + 'revision until sequence must not precede its exclusive baseline', + ); const revisions = this.revisionsSince(since); if (until !== undefined) { const latest = this.#history.at(-1)?.sequence ?? this.#revision; @@ -662,9 +684,11 @@ export class TerminalSession implements AsyncTerminal { timeout = options.timeoutMs ?? this.options.assertionTimeoutMs ?? 5000, startedAt = this.now(); if (!Number.isSafeInteger(max) || max <= 0 || max > configuredMax) - throw new RangeError(`maxRevisions must be positive and no greater than ${configuredMax}`); + throw new CoordinateRangeError( + `maxRevisions must be positive and no greater than ${configuredMax}`, + ); if (!Number.isSafeInteger(timeout) || timeout < 0) - throw new RangeError('timeoutMs must be nonnegative'); + throw new CoordinateRangeError('timeoutMs must be nonnegative'); const samples = [...this.revisionsSince(baseline)].slice(0, max); const complete = () => samples.some((revision) => options.until(revision.snapshot, revision)); if (!complete() && samples.length === max) @@ -672,12 +696,12 @@ export class TerminalSession implements AsyncTerminal { `Revision collection reached its ${max} sample limit before its predicate matched`, ); if (!complete()) - await new Promise((resolve, reject) => { - let timer: ReturnType | undefined; - const finish = (error?: Error) => { - if (timer) clearTimeout(timer); + await new Promise((resolvePromise, reject) => { + const finish = (timer: ReturnType, error?: Error) => { + clearTimeout(timer); off(); - error ? reject(error) : resolve(); + if (error) reject(error); + else resolvePromise(); }; const off = this.subscribe(() => { try { @@ -687,22 +711,30 @@ export class TerminalSession implements AsyncTerminal { samples.push(revision); if (samples.length > max) return finish( + timer, new HistoryEvictedError( `Revision collection exceeded its ${max} sample limit before its predicate matched`, ), ); - if (complete()) return finish(); + if (complete()) return finish(timer); if (this.#status.state === 'closed' || this.#status.state === 'failed') - return finish(new SessionClosedError('Session closed during revision collection')); + return finish( + timer, + new SessionClosedError('Session closed during revision collection'), + ); if (this.#status.ptyEof) - return finish(new ProcessExitedError('Process exited during revision collection')); + return finish( + timer, + new ProcessExitedError('Process exited during revision collection'), + ); } catch (error) { - finish(error instanceof Error ? error : new Error(String(error))); + finish(timer, error instanceof Error ? error : new Error(String(error))); } }); - timer = setTimeout( + const timer = setTimeout( () => finish( + timer, new TerminalAssertionError(`Timed out collecting revisions after ${timeout} ms`), ), timeout, @@ -720,9 +752,9 @@ export class TerminalSession implements AsyncTerminal { count = query.count ?? 200, start = query.start ?? 0; if (!Number.isSafeInteger(start) || start < 0) - throw new RangeError('history start must be nonnegative'); + throw new CoordinateRangeError('history start must be nonnegative'); if (!Number.isSafeInteger(count) || count <= 0 || count > 1000) - throw new RangeError('history count must be positive and no greater than 1000'); + throw new CoordinateRangeError('history count must be positive and no greater than 1000'); await this.#outputPump; const generation = String(this.#terminalHistoryGeneration); if (query.expectedGeneration !== undefined && query.expectedGeneration !== generation) @@ -731,7 +763,7 @@ export class TerminalSession implements AsyncTerminal { ); const predecessor = start > 0 ? this.#engine.history(start - 1, 1)[0] : undefined; const rows = this.#engine.history(start, count); - const lines = rows.map((entry, offset) => { + let lines = rows.map((entry, offset) => { const line = entry.line; return Object.freeze({ index: start + offset, @@ -742,7 +774,7 @@ export class TerminalSession implements AsyncTerminal { cells: line.cells, } satisfies HistoryLine); }); - if (direction === 'newest-first') lines.reverse(); + if (direction === 'newest-first') lines = lines.toReversed(); return Object.freeze({ generation, totalRows: this.#engine.scrollbackRows(), @@ -755,17 +787,19 @@ export class TerminalSession implements AsyncTerminal { text: string, options: HistorySearchOptions = {}, ): Promise { - if (!text.length) throw new RangeError('history search text must be nonempty'); + if (!text.length) throw new CoordinateRangeError('history search text must be nonempty'); const maxRows = options.maxRows ?? 1000, limit = options.limit ?? 20; if (!Number.isSafeInteger(maxRows) || maxRows <= 0 || maxRows > 10_000) - throw new RangeError('history maxRows must be positive and no greater than 10000'); + throw new CoordinateRangeError('history maxRows must be positive and no greater than 10000'); if (!Number.isSafeInteger(limit) || limit <= 0 || limit > 1000) - throw new RangeError('history result limit must be positive and no greater than 1000'); + throw new CoordinateRangeError( + 'history result limit must be positive and no greater than 1000', + ); const start = options.start ?? 0; if (!Number.isSafeInteger(start) || start < 0) - throw new RangeError('history start must be nonnegative'); - const lines: HistoryLine[] = []; + throw new CoordinateRangeError('history start must be nonnegative'); + let lines: HistoryLine[] = []; let generation = options.expectedGeneration; for (let offset = start; offset < start + maxRows; offset += 1000) { const page = await this.readHistory({ @@ -778,22 +812,23 @@ export class TerminalSession implements AsyncTerminal { lines.push(...page.lines); if (page.lines.length < 1000) break; } - if (options.direction === 'newest-first') lines.reverse(); + if (options.direction === 'newest-first') lines = lines.toReversed(); const results: HistoryMatch[] = []; for (const line of lines) { let textOffset = 0; const segments = line.cells .filter((cell) => !cell.continuation) .map((cell) => { - const start = textOffset; + const segmentStart = textOffset; textOffset += (cell.style.invisible ? ' ' : cell.text || ' ').length; - return { cell, start, end: textOffset }; + return { cell, start: segmentStart, end: textOffset }; }); let matchAt = 0; while (results.length < limit && (matchAt = line.text.indexOf(text, matchAt)) >= 0) { const first = segments.find((segment) => matchAt < segment.end) ?? segments.at(-1); const last = - [...segments].reverse().find((segment) => matchAt + text.length > segment.start) ?? first; + [...segments].toReversed().find((segment) => matchAt + text.length > segment.start) ?? + first; const column = first?.cell.column ?? 0, endColumn = last ? last.cell.column + Math.max(1, last.cell.width) : column + 1; results.push( @@ -867,7 +902,9 @@ export class TerminalSession implements AsyncTerminal { clipboard: () => this.#engine.clipboard(), }; } +/** Text locator that finds matching strings in the terminal viewport. */ export class Locator implements AsyncLocator { + // oxlint-disable-next-line max-params -- public API constructor( readonly session: TerminalSession, readonly query: string, @@ -875,16 +912,16 @@ export class Locator implements AsyncLocator { readonly index?: number, readonly bounds?: Rect, ) {} - nth(index: number) { + nth(index: number): Locator { if (!Number.isInteger(index) || index < 0) - throw new RangeError('nth index must be nonnegative'); + throw new CoordinateRangeError('nth index must be nonnegative'); return new Locator(this.session, this.query, this.options, index, this.bounds); } - region(rect: Rect) { + region(rect: Rect): Locator { this.session.validateRegion(rect); return new Locator(this.session, this.query, this.options, this.index, rect); } - matches() { + matches(): readonly LocatorMatch[] { const s = this.session.screen.current(), out: LocatorMatch[] = []; for (const line of s.lines) { @@ -906,7 +943,7 @@ export class Locator implements AsyncLocator { } const rangeFor = (from: number, to: number): Rect => { const first = segments.find((segment) => from < segment.end) ?? segments.at(-1), - last = [...segments].reverse().find((segment) => to > segment.start) ?? first, + last = [...segments].toReversed().find((segment) => to > segment.start) ?? first, column = first?.cell.column ?? start, lastEnd = last ? last.cell.column + Math.max(1, last.cell.width) : column + 1; return { column, row: line.row, width: Math.max(1, lastEnd - column), height: 1 }; diff --git a/experiments/ghostwright/src/terminal/wasm.ts b/experiments/ghostwright/src/terminal/wasm.ts index 5301000..1a0760d 100644 --- a/experiments/ghostwright/src/terminal/wasm.ts +++ b/experiments/ghostwright/src/terminal/wasm.ts @@ -15,7 +15,7 @@ import type { MouseOptions, Point, } from '../types.ts'; -import { AssetIntegrityError } from '../errors.ts'; +import { AssetIntegrityError, GhostwrightError } from '../errors.ts'; type Fn = (...args: any[]) => number; type Exports = Record & { @@ -229,7 +229,10 @@ export class GhosttyWasmTerminal { ); { if (!Number.isSafeInteger(storageLimitBytes) || storageLimitBytes < 0) - throw new RangeError('graphics.storageLimitBytes must be a nonnegative safe integer'); + throw new GhostwrightError({ + code: 'GW_INVALID_STORAGE_LIMIT', + message: 'graphics.storageLimitBytes must be a nonnegative safe integer', + }); const limit = this.#alloc(8); try { this.#view().setBigUint64(limit, BigInt(storageLimitBytes), true); @@ -309,9 +312,13 @@ export class GhosttyWasmTerminal { resize(v: Required) { this.#viewport = v; if (this.#e.ghostty_terminal_resize(this.#terminal, v.columns, v.rows, 10, 20) !== 0) - throw new Error('Ghostty resize failed'); + throw new GhostwrightError({ + code: 'GW_WASM_RESIZE_FAILED', + message: 'Ghostty resize failed', + }); this.#configureMouseSize(); } + // oxlint-disable-next-line max-params -- installCallback wraps ghostty_terminal_set callback API #installCallback( option: number, parameterCount: number, @@ -331,6 +338,7 @@ export class GhosttyWasmTerminal { throw new AssetIntegrityError(`Unable to configure Ghostty terminal effect ${option}`); } #configureEffects() { + // oxlint-disable-next-line max-params -- ghostty write-pty callback API this.#installCallback(1, 4, (_terminal, _userdata, data, length) => { this.#effects.push({ type: 'write-pty', data: this.#bytes().slice(data, data + length) }); }); @@ -354,6 +362,7 @@ export class GhosttyWasmTerminal { this.#installCallback( 6, 3, + // oxlint-disable-next-line max-params -- ghostty size report callback API (_terminal, _userdata, output) => { const layout = this.#layouts.GhosttySizeReportSize, field = (name: string) => layout.fields[name].offset, @@ -369,8 +378,8 @@ export class GhosttyWasmTerminal { this.#installCallback( 7, 3, - (_terminal, _userdata, output) => { - this.#view().setInt32(output, 1, true); + // oxlint-disable-next-line max-params -- ghostty terminal mode query callback API + (_terminal, _userdata, _output) => { return 1; }, true, @@ -378,6 +387,7 @@ export class GhosttyWasmTerminal { this.#installCallback( 8, 3, + // oxlint-disable-next-line max-params -- ghostty device attributes callback API (_terminal, _userdata, output) => { const all = this.#layouts.GhosttyDeviceAttributes, primary = this.#layouts.GhosttyDeviceAttributesPrimary, @@ -401,6 +411,7 @@ export class GhosttyWasmTerminal { this.#installCallback( 26, 3, + // oxlint-disable-next-line max-params -- ghostty clipboard write callback API (_terminal, _userdata, write) => { const writeLayout = this.#layouts.GhosttyClipboardWrite, contentLayout = this.#layouts.GhosttyClipboardContent, @@ -618,10 +629,10 @@ export class GhosttyWasmTerminal { kittyVirtualPlaceholder = false; if (this.#e.ghostty_terminal_grid_ref(this.#terminal, point, ref) === 0) { this.#e.ghostty_grid_ref_row(ref, rawRow); - const row = view.getBigUint64(rawRow, true); - this.#e.ghostty_row_get(row, 1, value); + const rowValue = view.getBigUint64(rawRow, true); + this.#e.ghostty_row_get(rowValue, 1, value); wrapped = view.getUint8(value) !== 0; - this.#e.ghostty_row_get(row, 7, value); + this.#e.ghostty_row_get(rowValue, 7, value); kittyVirtualPlaceholder = view.getUint8(value) !== 0; } for (let column = 0; column < this.#viewport.columns; column++) { @@ -979,12 +990,18 @@ export class GhosttyWasmTerminal { for (const [index, pointer] of [contentTagValue, wideValuePointer, hyperlinkValue].entries()) view.setUint32(cellValues + index * 4, pointer, true); if (this.#e.ghostty_render_state_get(this.#renderState, 4, this.#rowIteratorHolder) !== 0) - throw new Error('Unable to initialize Ghostty render row iterator'); + throw new GhostwrightError({ + code: 'GW_RENDER_INIT_FAILED', + message: 'Unable to initialize Ghostty render row iterator', + }); for (let row = 0; row < this.#viewport.rows; row++) { const cells: ScreenCell[] = []; if (!this.#e.ghostty_render_state_row_iterator_next(this.#rowIterator)) break; if (this.#e.ghostty_render_state_row_get(this.#rowIterator, 3, this.#rowCellsHolder) !== 0) - throw new Error(`Unable to read Ghostty render row ${row}`); + throw new GhostwrightError({ + code: 'GW_RENDER_ROW_FAILED', + message: `Unable to read Ghostty render row ${row}`, + }); this.#e.ghostty_render_state_row_get(this.#rowIterator, 2, rawRow); const rowValue = view.getBigUint64(rawRow, true); this.#e.ghostty_row_get(rowValue, 1, value); @@ -1003,10 +1020,16 @@ export class GhosttyWasmTerminal { length, ) !== 0 ) - throw new Error(`Unable to read Ghostty render cell ${column},${row}`); + throw new GhostwrightError({ + code: 'GW_RENDER_CELL_FAILED', + message: `Unable to read Ghostty render cell ${column},${row}`, + }); const cellValue = view.getBigUint64(rawCell, true); if (this.#e.ghostty_cell_get_multi(cellValue, 3, cellKeys, cellValues, length) !== 0) - throw new Error(`Unable to decode Ghostty cell ${column},${row}`); + throw new GhostwrightError({ + code: 'GW_RENDER_DECODE_FAILED', + message: `Unable to decode Ghostty cell ${column},${row}`, + }); const wideValue = view.getInt32(wideValuePointer, true), continuation = wideValue === 2 || wideValue === 3, width: 0 | 1 | 2 = continuation ? 0 : wideValue === 1 ? 2 : 1, @@ -1250,7 +1273,10 @@ export class GhosttyWasmTerminal { length, ) !== 0 ) - throw new Error(`Ghostty could not encode key ${JSON.stringify(name)}`); + throw new GhostwrightError({ + code: 'GW_KEY_ENCODE_FAILED', + message: `Ghostty could not encode key ${JSON.stringify(name)}`, + }); return this.#bytes().slice(output, output + this.#view().getUint32(length, true)); } finally { if (utf8.length) this.#release(utf8Pointer, utf8.length); @@ -1258,6 +1284,7 @@ export class GhosttyWasmTerminal { this.#release(length, 4); } } + // oxlint-disable-next-line max-params -- encodeMouse wraps ghostty mouse encoder API encodeMouse( action: 'move' | 'down' | 'up', point: Point, @@ -1310,7 +1337,10 @@ export class GhosttyWasmTerminal { length, ) !== 0 ) - throw new Error('Ghostty mouse encoding failed'); + throw new GhostwrightError({ + code: 'GW_MOUSE_ENCODE_FAILED', + message: 'Ghostty mouse encoding failed', + }); return this.#bytes().slice(output, output + this.#view().getUint32(length, true)); } finally { this.#release(position, positionLayout.size); @@ -1330,7 +1360,10 @@ export class GhosttyWasmTerminal { out = this.#alloc(n || 1); try { if (this.#e.ghostty_paste_encode(p, data.length, bracketed ? 1 : 0, out, n, lp) !== 0) - throw new Error('Ghostty paste encoding failed'); + throw new GhostwrightError({ + code: 'GW_PASTE_ENCODE_FAILED', + message: 'Ghostty paste encoding failed', + }); return this.#bytes().slice(out, out + n); } finally { this.#release(p, data.length || 1); diff --git a/experiments/ghostwright/src/tracing/replay.ts b/experiments/ghostwright/src/tracing/replay.ts index 6dd124b..207e428 100644 --- a/experiments/ghostwright/src/tracing/replay.ts +++ b/experiments/ghostwright/src/tracing/replay.ts @@ -1,4 +1,5 @@ import { readFile } from 'node:fs/promises'; +// oxlint-disable-next-line no-restricted-imports -- path module needed for path resolution import { join } from 'node:path'; import { AssetIntegrityError } from '../errors.ts'; import type { ScreenRevision, ScreenSnapshot, Viewport } from '../types.ts'; @@ -9,7 +10,7 @@ export interface ReplayResult { finalSnapshot: ScreenSnapshot; } -function observable(snapshot: ScreenSnapshot) { +function observable(snapshot: ScreenSnapshot): string { return JSON.stringify({ viewport: snapshot.viewport, activeBuffer: snapshot.activeBuffer, @@ -21,6 +22,7 @@ function observable(snapshot: ScreenSnapshot) { }); } +/** Replay a trace directory and return the screen revisions and final snapshot. */ export async function replayTrace(directory: string): Promise { const metadata = JSON.parse(await readFile(join(directory, 'metadata.json'), 'utf8')); if (metadata.schemaVersion !== 1) diff --git a/experiments/ghostwright/src/tracing/trace.ts b/experiments/ghostwright/src/tracing/trace.ts index 81becdf..cd04314 100644 --- a/experiments/ghostwright/src/tracing/trace.ts +++ b/experiments/ghostwright/src/tracing/trace.ts @@ -1,4 +1,5 @@ import { mkdir, readFile, writeFile } from 'node:fs/promises'; +// oxlint-disable-next-line no-restricted-imports -- path module needed for path resolution import { resolve } from 'node:path'; import { randomBytes } from 'node:crypto'; import type { ProcessStatus, ScreenSnapshot, TerminalLaunchOptions } from '../types.ts'; @@ -11,25 +12,32 @@ export interface TraceEvent { type: string; [key: string]: unknown; } +/** Records terminal events and optionally persists trace artifacts on failure. */ export class SessionTrace { #events: TraceEvent[] = []; #seq = 0; readonly started = performance.now(); readonly raw: Uint8Array[] = []; - constructor( - readonly options: TerminalLaunchOptions, - readonly policy: 'off' | 'retain-on-failure' | 'on', - readonly directory: string, - ) { + readonly options: TerminalLaunchOptions; + readonly policy: 'off' | 'retain-on-failure' | 'on'; + readonly directory: string; + constructor(params: { + options: TerminalLaunchOptions; + policy: 'off' | 'retain-on-failure' | 'on'; + directory: string; + }) { + this.options = params.options; + this.policy = params.policy; + this.directory = params.directory; this.add('session-start'); } - now() { + now(): number { return performance.now() - this.started; } - events() { + events(): TraceEvent[] { return [...this.#events]; } - add(type: string, data: Record = {}) { + add(type: string, data: Record = {}): void { if (this.policy === 'off') return; this.#events.push({ schemaVersion: 1, @@ -40,7 +48,7 @@ export class SessionTrace { }); if (this.#events.length > 10000) this.#events.shift(); } - output(bytes: Uint8Array, frameSequence: number) { + output(bytes: Uint8Array, frameSequence: number): void { if (this.policy === 'off') return; const offset = this.raw.reduce((n, b) => n + b.length, 0); this.raw.push(bytes.slice()); @@ -49,7 +57,8 @@ export class SessionTrace { raw: { offset, length: bytes.length, direction: 'from-pty' }, }); } - input(bytes: Uint8Array, actionSequence: number, redacted = false) { + // oxlint-disable-next-line max-params -- input needs bytes, action sequence, and redaction flag + input(bytes: Uint8Array, actionSequence: number, redacted = false): void { if (this.policy === 'off') return; if (redacted) { this.add('input', { actionSequence, redacted: true, length: bytes.length }); @@ -62,7 +71,12 @@ export class SessionTrace { raw: { offset, length: bytes.length, direction: 'to-pty' }, }); } - async persist(error: unknown, snapshot: ScreenSnapshot, status: ProcessStatus) { + // oxlint-disable-next-line max-params -- persist needs error, snapshot, and status for artifact writing + async persist( + error: unknown, + snapshot: ScreenSnapshot, + status: ProcessStatus, + ): Promise { if (this.policy === 'off') return undefined; try { const name = (this.options.name ?? 'session').replace(/[^a-zA-Z0-9_.-]/g, '-').slice(0, 60), diff --git a/experiments/ghostwright/test/assertions-trace.test.ts b/experiments/ghostwright/test/assertions-trace.test.ts index 5be6b95..0b80340 100644 --- a/experiments/ghostwright/test/assertions-trace.test.ts +++ b/experiments/ghostwright/test/assertions-trace.test.ts @@ -1,5 +1,6 @@ import { mkdtemp, readFile, readdir, rm, stat } from 'node:fs/promises'; import { tmpdir } from 'node:os'; +// oxlint-disable-next-line no-restricted-imports -- path module needed for path resolution import { join } from 'node:path'; import { expect, test } from 'bun:test'; import { @@ -151,8 +152,8 @@ test('retain-on-failure writes private complete artifacts and preserves the prim const tracePath = (failure as TerminalAssertionError).tracePath!; expect(tracePath).toBeTruthy(); expect((failure as Error).message).toContain('trace artifact:'); - expect((await readdir(tracePath)).sort()).toEqual( - ['failure.txt', 'final-screen.txt', 'metadata.json', 'output.bin', 'trace.jsonl'].sort(), + expect((await readdir(tracePath)).toSorted()).toEqual( + ['failure.txt', 'final-screen.txt', 'metadata.json', 'output.bin', 'trace.jsonl'].toSorted(), ); const metadata = await readFile(join(tracePath, 'metadata.json'), 'utf8'), trace = await readFile(join(tracePath, 'trace.jsonl'), 'utf8'), diff --git a/experiments/ghostwright/test/host-contract.ts b/experiments/ghostwright/test/host-contract.ts index 64aca1b..0f828b1 100644 --- a/experiments/ghostwright/test/host-contract.ts +++ b/experiments/ghostwright/test/host-contract.ts @@ -1,8 +1,11 @@ +// oxlint-disable-next-line no-restricted-imports -- path module needed for path resolution import { resolve } from 'node:path'; import { expectTerminal, withTerminalAsync } from '../src/index.ts'; import { usePtyHostForTesting } from '../src/profile.ts'; import { SidecarClient } from '../src/pty/client.ts'; +import { GhostwrightError } from '../src/errors.ts'; +/** Run the host contract test suite against a PTY host binary. */ export async function runHostContract(hostPath: string): Promise { const absolute = resolve(hostPath), restore = usePtyHostForTesting(absolute); @@ -17,7 +20,10 @@ export async function runHostContract(hostPath: string): Promise { await expectTerminal(terminal.getByText('TTY READY')).toBePresent(); const status = await terminal.process.waitForExit(); if (status.exitCode !== 0 || !status.ptyEof) - throw new Error(`invalid basic status: ${JSON.stringify(status)}`); + throw new GhostwrightError({ + code: 'GW_CONTRACT_BASIC', + message: `invalid basic status: ${JSON.stringify(status)}`, + }); }, ); @@ -50,7 +56,10 @@ export async function runHostContract(hostPath: string): Promise { await terminal.keyboard.press({ key: 'c', control: true }); await expectTerminal(terminal.getByText('INTERRUPTED')).toBePresent(); if ((await terminal.process.waitForExit()).exitCode !== 0) - throw new Error('canonical Control-C fixture failed'); + throw new GhostwrightError({ + code: 'GW_CONTRACT_SIGNAL', + message: 'canonical Control-C fixture failed', + }); }, ); @@ -68,7 +77,10 @@ export async function runHostContract(hostPath: string): Promise { await terminal.keyboard.press({ key: 'c', control: true }); await expectTerminal(terminal.getByText('RAW:03')).toBePresent(); if ((await terminal.process.waitForExit()).exitCode !== 0) - throw new Error('raw input fixture failed'); + throw new GhostwrightError({ + code: 'GW_CONTRACT_RAW', + message: 'raw input fixture failed', + }); }, ); @@ -84,11 +96,17 @@ export async function runHostContract(hostPath: string): Promise { await expectTerminal(terminal.getByText('FINAL')).toBePresent(); const status = await terminal.process.waitForExit({ timeoutMs: 1_000 }); if (status.exitCode !== 0 || !status.ptyEof) - throw new Error(`descendant drain failed: ${JSON.stringify(status)}`); + throw new GhostwrightError({ + code: 'GW_CONTRACT_DRAIN', + message: `descendant drain failed: ${JSON.stringify(status)}`, + }); }, ); if (performance.now() - started > 1_000) - throw new Error('descendant cleanup exceeded deadline'); + throw new GhostwrightError({ + code: 'GW_CONTRACT_CLEANUP', + message: 'descendant cleanup exceeded deadline', + }); const client = await SidecarClient.start(absolute, 1_000); try { @@ -98,7 +116,11 @@ export async function runHostContract(hostPath: string): Promise { } catch { rejected = true; } - if (!rejected) throw new Error('host accepted WRITE before SPAWN'); + if (!rejected) + throw new GhostwrightError({ + code: 'GW_CONTRACT_ORDERING', + message: 'host accepted WRITE before SPAWN', + }); } finally { await client.close(1_000).catch(() => undefined); } @@ -108,8 +130,13 @@ export async function runHostContract(hostPath: string): Promise { } if (import.meta.main) { - const path = process.argv[2]; - if (!path) throw new Error('usage: bun test/host-contract.ts '); - await runHostContract(path); - console.log(`host contract passed: ${path}`); + const contractPath = process.argv[2]; + if (!contractPath) + throw new GhostwrightError({ + code: 'GW_MISSING_ARGS', + message: 'usage: bun test/host-contract.ts ', + }); + await runHostContract(contractPath); + // oxlint-disable-next-line no-console -- test script + console.log(`host contract passed: ${contractPath}`); } diff --git a/experiments/ghostwright/test/preload-host.ts b/experiments/ghostwright/test/preload-host.ts index e88d440..a7fd963 100644 --- a/experiments/ghostwright/test/preload-host.ts +++ b/experiments/ghostwright/test/preload-host.ts @@ -1,6 +1,12 @@ +// oxlint-disable-next-line no-restricted-imports -- path module needed for path resolution import { resolve } from 'node:path'; import { usePtyHostForTesting } from '../src/profile.ts'; +import { GhostwrightError } from '../src/errors.ts'; const host = process.env.GHOSTWRIGHT_CONTRACT_HOST; -if (!host) throw new Error('GHOSTWRIGHT_CONTRACT_HOST is required'); +if (!host) + throw new GhostwrightError({ + code: 'GW_MISSING_ENV', + message: 'GHOSTWRIGHT_CONTRACT_HOST is required', + }); usePtyHostForTesting(resolve(host)); diff --git a/experiments/ghostwright/test/runtime-smoke.mjs b/experiments/ghostwright/test/runtime-smoke.mjs index dcc22a0..cdf7fd0 100644 --- a/experiments/ghostwright/test/runtime-smoke.mjs +++ b/experiments/ghostwright/test/runtime-smoke.mjs @@ -1,4 +1,5 @@ import { expectTerminal, withTerminalAsync } from '../dist/index.js'; +import { GhostwrightError } from '../src/errors.ts'; await withTerminalAsync( { command: '/bin/sh', args: ['-c', 'printf runtime-smoke'], trace: 'off' }, @@ -6,8 +7,12 @@ await withTerminalAsync( await expectTerminal(terminal.getByText('runtime-smoke')).toBePresent(); const status = await terminal.process.waitForExit(); if (status.exitCode !== 0 || !status.ptyEof) { - throw new Error(`unexpected process status: ${JSON.stringify(status)}`); + throw new GhostwrightError({ + code: 'GW_SMOKE_FAILED', + message: `unexpected process status: ${JSON.stringify(status)}`, + }); } }, ); +// oxlint-disable-next-line no-console -- test script console.log('Ghostwright runtime smoke passed');