Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,9 @@ build/Release
node_modules/
jspm_packages/

# Terminal test failure artifacts
.ghostwright/

# TypeScript cache
*.tsbuildinfo

Expand Down
18 changes: 14 additions & 4 deletions experiments/ghostwright/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,21 +10,31 @@ Test the application from the outside. Launch its real command under a PTY, inte

1. Identify the repository's existing test runner and conventions.
2. Identify the direct executable, argument array, cwd, and any nonreserved environment values.
3. Prefer `withTerminalAsync` unless the surrounding code already uses Effection.
3. Use `await using` with `launchTerminal`, or the `withTerminal` callback helper. Effection uses `ghostwright/effection`.
4. Wait for a visible readiness condition before acting.
5. Await every action. Save the receipt when a transient assertion follows.
6. Use a stable assertion for final UI state and revision history for fleeting state.
7. Verify process exit when exit behavior matters.
8. Run the focused test. On failure, inspect the attached `.ghostwright` artifacts before changing timeouts.

## Canonical async template
## Public query and assertion model

Use `terminal.screen.getBy/queryBy/findBy` with a locator recipe, or their `ByText` and adapter-owned `BySelector` conveniences. The `All` forms return arrays. `get` and `query` inspect now; `find` waits. A result is frozen evidence, not a live element. Query again inside `terminal.waitFor` when checking a changing value.

Jest and Vitest can use `ghostwright/jest` and `ghostwright/vitest` to install immediate terminal matchers on their own `expect`. Keep keyboard and mouse input outside retried assertions. Capture observations when intermediate states matter. The README and pizza examples show this style.

`launchTerminal` supports `await using`; the `withTerminal` callback form also records body failures. Bare disposal cannot detect a test assertion failure. Use the Vitest launch fixture, the callback form, or `trace: 'on'` when artifacts are required.

The lower-level `expectTerminal` helpers below remain available for existing revision/history workflows.

## Lower-level async template

```ts
import { expect, test } from 'bun:test';
import { expectTerminal, withTerminalAsync } from 'ghostwright';
import { expectTerminal, withTerminal } from 'ghostwright';

test('interactive happy path', async () => {
await withTerminalAsync(
await withTerminal(
{
command: 'bun',
args: ['src/cli.ts'],
Expand Down
4 changes: 3 additions & 1 deletion experiments/ghostwright/HOST-COMPARISON.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
# PTY Host C vs. Rust Comparison
# Historical PTY Host C vs. Rust Comparison

This report predates the scoped-execution rewrite. Rust is now the sole packaged host. The C implementation and comparison script were removed. These measurements were not rerun and do not describe the current queues or cancellation behavior.

Generated on 2026-07-15T09:03:59.623Z by `bun run compare:hosts` on darwin-arm64.

Expand Down
154 changes: 115 additions & 39 deletions experiments/ghostwright/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ Install Ghostwright, then tell your agent:

Inside this repository:

> Read `packages/ghostwright/AGENTS.md`, then blackbox test `<target command>` using Ghostwright's public API.
> Read `experiments/ghostwright/AGENTS.md`, then blackbox test `<target command>` using Ghostwright's public API.

Start with the [agent quickstart](docs/agent-quickstart.md) or prove your agent can [exit vi](examples/async/agent-closes-vi.test.ts).

Expand All @@ -27,40 +27,116 @@ 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';
import { expect, test } from 'vitest';
import 'ghostwright/vitest';
import { launchTerminal } 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);
},
);
await using terminal = await launchTerminal({
command: 'node',
args: ['src/cli.js'],
viewport: { columns: 80, rows: 24 },
});
const { screen, keyboard } = terminal;

expect(await screen.findByText('Ready')).toBeVisible();
await keyboard.press('Enter');
expect(await screen.findByText('Complete')).toBeVisible();
expect((await terminal.process.waitForExit()).exitCode).toBe(0);
});
```

The callback owns the terminal. Normal return, throw, assertion failure, and cancellation close the PTY, sidecar, application process group, and WASM resources before the outer operation completes.
`launchTerminal` returns an owned execution scope. `await using` or `close()` cancels owned work and awaits PTY, sidecar, process-group, and WASM cleanup. Disposal is idempotent. Query results and locators are not disposable. TypeScript users need explicit-resource-management support in their compiler/runtime toolchain.

Effection users get the same operations and lifecycle through `withTerminal`; see the [Effection examples](examples/effection/).
The callback form uses the same lifecycle:

## Synchronization model
```ts
import { withTerminal } from 'ghostwright';

await withTerminal(options, async ({ screen, keyboard }) => {
await screen.findByText('Ready');
await keyboard.press('Enter');
});
```

**Disposal cannot observe a test-body exception.** Bare `await using` guarantees cleanup, but does not automatically retain failure-only traces. Use `trace: 'on'`, `withTerminal`, or a runner fixture for that. A runner can also call `terminal.recordFailure(error)` explicitly.

With Vitest, the exported fixture owns launches and reports test failures before cleanup:

```ts
import { expect } from 'vitest';
import { test } from 'ghostwright/vitest';

test('CLI starts', async ({ launchTerminal }) => {
const { screen } = await launchTerminal(options);
expect(await screen.findByText('Ready')).toBeVisible();
});
```

For Jest, import `ghostwright/jest` to install the same immediate matchers. Use the callback helper for automatic failure artifacts. `ghostwright/matchers` exports `terminalMatchers` and `createRunnerMatchers(definitions)` for local `expect.extend(...)` integrations. Core imports do not load a test runner.

Effection users import `withTerminal` from `ghostwright/effection`; see the [Effection examples](examples/effection/).

## Scoped capture and semantic addressing

The new region API separates immutable locator queries, paired observations, terminal-evidence matchers, and scope-owned execution. Start with [Scoped observations and assertions](docs/scoped-execution.md). The pizza and pizza-preact tests demonstrate this API through real PTYs.

Descriptions provide identity and geometry, not proof of focus or value. Typed matcher extensions stay local. Async and Effection capture share one execution core.

The older text-locator and screen-history API below remains available during this experiment.

## Queries and waiting

A locator is a reusable recipe. A query returns frozen `RegionInspection` evidence from one observation. Later output does not change a previous query result.

Ghostwright assertions are revision-driven rather than polling-based:
| Query | No matches | Multiple matches | Waits |
| ------------ | ---------- | -------------------- | ----- |
| `getBy` | Throws | Throws | No |
| `queryBy` | `null` | Throws | No |
| `findBy` | Retries | Retries until unique | Yes |
| `getAllBy` | Throws | Returns all | No |
| `queryAllBy` | `[]` | Returns all | No |
| `findAllBy` | Retries | Returns all | Yes |

Each method accepts a locator recipe. The `ByText` forms construct a cell-aware text recipe and use the same engine. Text matching is literal and case-sensitive within one physical row. By default it finds substrings; `{ exact: true }` matches the whole row after trailing padding is removed. `BySelector` forms require an adapter-owned `selector` function in the launch options; core does not interpret CSS.

```ts
import { launchTerminal } from 'ghostwright';
import { clackTtyExtension, locator } from '@ghostwright/clack-tty';

await using terminal = await launchTerminal({
...options,
env: { ...options.env, CLACK_UI_SEMANTIC: '1' },
extensions: [clackTtyExtension()],
selector: locator,
});
const { screen, mouse, waitFor } = terminal;
const save = locator('button[label="save"]');

await screen.findBy(save); // Same behavior as findBySelector('button[label="save"]').
await mouse.click(save); // Resolve again before input; never reuse old query coordinates.
await waitFor(() => {
expect(screen.getBySelector('text[label="status"]')).toContainText('Saved');
});
```

Runner matchers inspect immediately. `waitFor` starts an attempt without waiting for an interval, retries thrown/rejected assertions, and returns any successful value—including `false`. It awaits an async callback without overlapping attempts. Terminal observations prompt early retries; an interval covers changes that produce no terminal output. Process exit alone does not end a generic wait. Scope disposal or an explicit abort signal cancels it.

`waitFor` and `findBy` accept `{ timeoutMs, intervalMs, signal }`. The default timeout is the session's `assertionTimeoutMs` or 4000 ms. The default interval is 50 ms. Durations must be finite and between 0 and 2147483647 ms, the supported timer range. Text finders accept text and wait options in the same options object. Timeout errors retain the last assertion as their cause and include the current screen. Put input outside retry callbacks. Combine related assertions in one callback when they must describe a coherent state.

Described queries require a valid description paired with the current screen. An unavailable or invalid description throws—even for `queryBy` and `queryAllBy`. Unknown structure is not proof of absence. `findBy` and `waitFor` can wait for the next valid observation.

A successful query proves a match, not visibility, focus, or enabled state. Use the corresponding evidence matcher. `toBeVisible` requires viewport overlap and at least one cell without the invisible style; it does not assert terminal-window visibility or graphical occlusion. `null` is accepted by `.not.toBeVisible()`.

## Input targets

`mouse.move`, `hover`, `down`, `up`, `click`, and `doubleClick` accept coordinates or locator recipes. A locator action waits for one on-screen target and fails immediately on ambiguity. This differs deliberately from `findBy`, which retries ambiguity.

`mouse.drag(start, destination)` accepts a locator or point as its start, and either a point or `{ by: { columns, rows } }` as its destination. The start resolves once. The gesture does not chase a moving target, retry input, or enable application mouse reporting. Invalid destinations fail before button-down. Modifier options support Shift, Alt, and Control; standard mouse reports cannot encode Super/Command. `keyboard` and `mouse` send terminal input, not application events.

## Lower-level assertion helpers

The `expectTerminal` helpers below remain revision-driven:

| Intent | API |
| ------------------------------------- | ------------------- |
Expand Down Expand Up @@ -157,15 +233,15 @@ The deterministic profile uses `TERM=xterm-ghostty`, package-local terminfo, tru

## Fidelity boundary

A sidecar output frame is one OS PTY read, not a pixel-rendered frame. The kernel may combine application writes. Ghostwright never splits a read into artificial per-byte revisions and never coalesces separate host frames, but it cannot recover a state overwritten within one kernel-coalesced read.
A sidecar output frame is one OS PTY read, not a pixel-rendered frame. The kernel may combine application writes. Ghostwright does not create per-byte revisions. Registered OSC boundaries can split one read into coherent description/screen observations. Without such boundaries, it cannot recover a state overwritten within one kernel-coalesced read.

Ghostwright validates terminal-grid and PTY behavior. It does not validate fonts, shaping, rasterization, GPU output, or graphical occlusion.

## Security

Ghostwright is **not a sandbox**. Commands run directly, without an implicit shell, using the caller's filesystem, network, process, and credential permissions. Launch a shell explicitly only when shell syntax is intended.

Failure tracing defaults to `retain-on-failure`. Common secret-like environment keys are redacted, and typed/pasted input can use `{ trace: "redact" }`, but application output and unmarked values may still contain secrets. Use `trace: "off"` for sensitive sessions.
Failure tracing defaults to `retain-on-failure` when the callback helper or runner fixture reports a failure. Bare async disposal cannot detect the test outcome. Common secret-like environment keys are redacted, and typed/pasted input can use `{ trace: "redact" }`, but application output and unmarked values may still contain secrets. Use `trace: "off"` for sensitive sessions.

## Maintainer artifacts

Expand All @@ -177,28 +253,28 @@ Working on Ghostwright itself (as opposed to consuming it) requires building tho
bun run setup
```

That fetches the pinned Ghostty source, builds `ghostty-vt.wasm` and the native PTY host, compiles terminfo, refreshes checksums, and verifies the result. It needs the exact Zig version recorded in `ghostty.lock.json` (currently 0.15.2) on `PATH`; nothing else is required. The command is idempotent and safe to re-run.
That fetches the pinned Ghostty source, builds `ghostty-vt.wasm` and the native PTY host, compiles terminfo, refreshes checksums, and verifies the result. It needs the exact Zig version recorded in `ghostty.lock.json` (currently 0.15.2) on `PATH`; Rust/Cargo and the platform linker are also required for the native host. Consumers do not need these tools. The command is idempotent and safe to re-run.

Then run the tests:

```sh
bun test examples
pnpm test
```

`ghostty.lock.json` is the source of truth for the build contract and is edited by hand. `bun run update:manifest` only refreshes the `artifacts` checksum map, and only for targets built on the current machine; entries for targets built elsewhere (for example the Linux hosts when building on macOS) are preserved. `bun run verify:artifacts` skips and reports artifacts that are absent locally, and fails hard on any artifact that is present but does not match.
This runs the Bun suite and real Jest/Vitest matcher and failure-artifact contracts. `bun test examples` runs only the application examples.

The PTY host has two side-by-side implementations:
`ghostty.lock.json` is the source of truth for the build contract and is edited by hand. `bun run update:manifest` only refreshes the `artifacts` checksum map, and only for targets built on the current machine; entries for targets built elsewhere (for example the Linux hosts when building on macOS) are preserved. `bun run verify:artifacts` skips and reports artifacts that are absent locally, and fails hard on any artifact that is present but does not match.

- `native/pty-host-c`: packaged pure-C default, compiled with Apple Clang or native `musl-gcc`
- `native/pty-host-rust`: synchronous Rust candidate using `nix`, `minicbor`, and `thiserror`
The sole PTY host is `native/pty-host-rust`. It uses `nix`, `minicbor`, and `thiserror`, without Tokio. The host owns only POSIX processes, PTYs, byte queues, and control messages.

```sh
bun run build:host:c
bun run build:host:rust
bun run test:hosts
bun run compare:hosts
bun run test:host
bun run typecheck
```

See [`HOST-COMPARISON.md`](HOST-COMPARISON.md). Zig remains pinned only because upstream Ghostty uses it to build `ghostty-vt.wasm`; the PTY host has no Zig wrapper or `zig cc` dependency.
Set `GHOSTWRIGHT_RUST_TARGET` to select a Rust target. Release builds need the matching linker and standard library. This rewrite has been built and tested locally only on macOS arm64; Linux and macOS x64 artifacts still need release-runner validation.

[`HOST-COMPARISON.md`](HOST-COMPARISON.md) is a historical report. Zig remains pinned for upstream Ghostty WASM.

Release jobs build native targets on matching runners, compile tracked terminfo, generate package output, and record checksums. `bun run verify:artifacts` independently checks hashes, protocol markers, WASM exports, and ABI layouts without rebuilding.
19 changes: 8 additions & 11 deletions experiments/ghostwright/docs/agent-quickstart.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,10 +63,10 @@ Do not combine an executable and arguments into an implicit shell string.

```ts
import { expect, test } from 'bun:test';
import { expectTerminal, withTerminalAsync } from 'ghostwright';
import { expectTerminal, withTerminal } from 'ghostwright';

test('CLI starts and accepts input', async () => {
await withTerminalAsync(
await withTerminal(
{
command: 'bun',
args: ['src/cli.ts'],
Expand Down Expand Up @@ -102,17 +102,14 @@ 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';
import { expectTerminal, withTerminal } 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);
},
);
await withTerminal({ 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);
});
});
```

Expand Down
Loading