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
53 changes: 52 additions & 1 deletion experiments/ghostwright/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,11 +67,46 @@ Ghostwright assertions are revision-driven rather than polling-based:
| First visible appearance / readiness | `toBePresent()` |
| Final visually settled state | `toBeStable()` |
| Stable disappearance | `toBeAbsent()` |
| Text is drawn with a given style | `toHaveStyle()` |
| The cursor sits on the match | `toContainCursor()` |
| 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.
Text locators are lazy, current-visible-viewport only, grapheme-aware, and strict. Zero matches wait; multiple matches fail with candidate geometry. Use `.nth()`, `.region()`, or a `style` filter to disambiguate deliberately.

Assertions default to `DEFAULT_ASSERTION_TIMEOUT_MS` (4000 ms), deliberately below the 5000 ms default of Bun, Jest, and Vitest. If they were equal the runner's own timeout would win the race and report a bare "timed out" instead of Ghostwright's screen diagnostic. Raise it per assertion with `{ timeoutMs }`, or for a session with `assertionTimeoutMs`.

## Inspecting styles, cursor, and cells

Focus, selection, and error states in a TUI are usually expressed visually rather than as text. Locators can filter and assert on style:

```ts
// Assert how something is drawn.
await expectTerminal(terminal.getByText('Save')).toHaveStyle({ foreground: '#ffffff' });

// Disambiguate identical text by appearance.
const active = terminal.getByText('Save', { style: { inverse: true } });

// Assert where the caret is.
await expectTerminal(terminal.getByText('Name')).toContainCursor();
```

Colours accept `'#rrggbb'`, `'rgb(r,g,b)'`, `'default'`, `'palette:N'`, or the structured `TerminalColor`. Any omitted `StyleQuery` field is ignored.

For geometry and raw cells, `matches()` returns each hit's `range` and backing `cells`, and `screen.getCells(rect)` returns a rectangle:

```ts
const [match] = terminal.getByText('Name').matches();
match.range; // { column, row, width, height }
match.cells; // ScreenCell[], each with .style

const border = terminal.screen.getCells({ column: 4, row: 7, width: 40, height: 3 });
import { cellsMatchStyle } from 'ghostwright';
cellsMatchStyle(border, { foreground: '#ffffff' });
```

`screen.snapshot()` is an alias of `screen.current()`, matching `AsyncRegion.snapshot()`.

See [Choosing locators and assertions](docs/choosing-assertions.md).

Expand Down Expand Up @@ -136,6 +171,22 @@ Failure tracing defaults to `retain-on-failure`. Common secret-like environment

Generated `dist/`, `artifacts/`, Rust `target/`, and candidate host binaries are Git-ignored and assembled before packaging.

Working on Ghostwright itself (as opposed to consuming it) requires building those artifacts once from a clean clone:

```sh
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.

Then run the tests:

```sh
bun test examples
```

`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.

The PTY host has two side-by-side implementations:

- `native/pty-host-c`: packaged pure-C default, compiled with Apple Clang or native `musl-gcc`
Expand Down
16 changes: 16 additions & 0 deletions experiments/ghostwright/docs/agent-quickstart.md
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,22 @@ The vi examples prove Ghostwright is exercising raw input, alternate-screen rest

The second example prints `hello world` in interactive Bash, enters vi's alternate screen, exits vi, and verifies Bash's primary screen still contains the original output.

## Inspect what is on screen

When an assertion is not enough and you need the underlying data, locators expose geometry and cells, and the screen exposes rectangles:

```ts
const [match] = terminal.getByText('Name').matches();
match.range; // { column, row, width, height }
match.cells; // ScreenCell[], each with .style

terminal.screen.snapshot(); // whole screen (alias of screen.current())
terminal.screen.getCells({ column: 0, row: 7, width: 40, height: 3 });
terminal.screen.getText({ column: 0, row: 7, width: 40, height: 3 });
```

Prefer an assertion when one exists: `toHaveStyle()` and `toContainCursor()` wait for convergence, whereas `matches()` and `snapshot()` read the current instant and will not wait.

## Next references

- Choose synchronization correctly: [`choosing-assertions.md`](choosing-assertions.md)
Expand Down
30 changes: 30 additions & 0 deletions experiments/ghostwright/docs/choosing-assertions.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,16 @@ Ghostwright separates first appearance, visual convergence, stable absence, and
| Has this text appeared yet? | `toBePresent()` |
| Has the final visible UI settled? | `toBeStable()` |
| Has this text remained gone? | `toBeAbsent()` |
| Is this text drawn with a given style? | `toHaveStyle()` |
| Is the cursor on this text? | `toContainCursor()` |
| 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.

The default timeout is `DEFAULT_ASSERTION_TIMEOUT_MS` (4000 ms), chosen to stay below the 5000 ms default of Bun, Jest, and Vitest so that a failure reports Ghostwright's screen diagnostic rather than the runner's bare timeout.

## `toBePresent`: readiness and first appearance

```ts
Expand Down Expand Up @@ -72,8 +76,34 @@ await expectTerminal(terminal).toSatisfy(

The predicate is evaluated against immutable `ScreenSnapshot` values and must remain true through visual settlement.

Predicates run against **every** revision, including the blank frames before the application has painted anything. A predicate that throws is treated as "not satisfied" rather than aborting the assertion, so reading a not yet rendered layout is safe. If the assertion never converges, the most recent thrown error is included in the diagnostic:

```
expected: screen predicate to converge
predicate threw (treated as unsatisfied): Cannot read properties of undefined
```

Prefer multiple locators when the conditions are independently meaningful. Use `toSatisfy` when their atomic relationship is the behavior under test.

## `toHaveStyle` and `toContainCursor`: visual state

TUIs express focus, selection, and severity through styling rather than text. Assert it directly instead of scraping cells:

```ts
await expectTerminal(terminal.getByText('Submit')).toHaveStyle({ inverse: true });
await expectTerminal(terminal.getByText('Error')).toHaveStyle({ foreground: '#ff0000' });
await expectTerminal(terminal.getByText('Name')).toContainCursor();
```

`toHaveStyle` requires every cell of the match to satisfy the query, and reports the actual style on failure. Omitted fields are ignored, so `{ bold: true }` says nothing about colour.

A `style` filter on the locator itself disambiguates repeated text:

```ts
// Two "Save" labels, one highlighted.
terminal.getByText('Save', { style: { inverse: true } });
```

## `toHaveShown`: transient revision history

Terminal applications can paint a state and replace it before the test resumes:
Expand Down
12 changes: 12 additions & 0 deletions experiments/ghostwright/docs/interaction-recipes.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,18 @@ await terminal.keyboard.press({ key: 'Tab', shift: true });
await terminal.keyboard.press({ key: 'x', alt: true });
```

The equivalent string form is also accepted:

```ts
await terminal.keyboard.press('Ctrl+c');
await terminal.keyboard.press('Shift+Tab');
await terminal.keyboard.press('Alt+x');
```

Recognized modifier prefixes are `Shift+`, `Ctrl+`/`Control+`, `Alt+`/`Option+`, and `Cmd+`/`Command+`/`Super+`/`Meta+`, in any case. The key itself keeps its case, since case is significant for characters.

Unknown key names throw `InvalidKeyError` immediately rather than encoding nothing and surfacing later as an assertion timeout. Valid keys are the functional names (`Enter`, `Tab`, `Escape`, `Backspace`, `Delete`, `Home`, `End`, `PageUp`, `PageDown`, the four arrows), `F1`-`F25`, or any single character. Note that `KeyName` widens to `string`, so TypeScript cannot catch a typo for you.

Keyboard encoding uses current Ghostty terminal modes, including application cursor keys, backarrow mode, and Kitty keyboard flags.

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:
Expand Down
2 changes: 2 additions & 0 deletions experiments/ghostwright/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
}
},
"scripts": {
"setup": "bun run build:artifacts && bun run verify:artifacts",
"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",
Expand All @@ -43,6 +44,7 @@
"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",
"update:manifest": "bun scripts/update-manifest.ts",
"verify:artifacts": "bun scripts/verify-artifacts.ts",
"test": "bun test",
"test:examples": "bun test examples"
Expand Down
18 changes: 17 additions & 1 deletion experiments/ghostwright/scripts/build-ghostty-vt.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,26 @@
import { $ } from 'bun';
import { readFile } from 'node:fs/promises';
import { existsSync } from 'node:fs';
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')),
lock = JSON.parse(await readFile(`${root}/ghostty.lock.json`, 'utf8'));
if (!existsSync(source))
throw new GhostwrightError({
code: 'GW_GHOSTTY_SOURCE',
message: `Ghostty source checkout missing at ${source}; run \`bun run fetch:ghostty\` first (or \`bun run setup\` to do everything)`,
});
let zig: string;
try {
zig = (await $`zig version`.text()).trim();
} catch {
throw new GhostwrightError({
code: 'GW_ZIG_MISSING',
message: `Ghostwright artifact build requires Zig ${lock.zigVersion} on PATH, but \`zig\` was not found`,
});
}
if (zig !== lock.zigVersion)
throw new GhostwrightError({
code: 'GW_ZIG_VERSION',
Expand All @@ -25,4 +39,6 @@ if (lock.graphics?.freestandingPatchSha256 !== patchSha256)
message: 'Ghostwright freestanding Kitty patch checksum mismatch',
});
await $`cd ${source} && zig build -Demit-lib-vt -Dtarget=wasm32-freestanding -Doptimize=ReleaseSmall`;
// artifacts/ is gitignored, so it does not exist in a clean clone.
await $`mkdir -p ${root}/artifacts`;
await $`cp ${source}/zig-out/bin/ghostty-vt.wasm ${root}/artifacts/ghostty-vt.wasm`;
Loading
Loading