Skip to content
Open
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
51 changes: 51 additions & 0 deletions .github/workflows/check.yml
Original file line number Diff line number Diff line change
@@ -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
13 changes: 0 additions & 13 deletions .github/workflows/format.yml

This file was deleted.

2 changes: 1 addition & 1 deletion examples/demo/src/index.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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' } }),
Expand Down
84 changes: 42 additions & 42 deletions experiments/ghostwright/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,46 +20,46 @@ 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);
},
);
});
```

Replace the command, arguments, and visible strings with values from the target application. Ghostwright does not insert an implicit shell. Launch `/bin/sh`, `bash`, or another shell explicitly only when shell syntax is part of the intended test.

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

Expand All @@ -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")`.
Expand All @@ -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.
Expand Down
6 changes: 3 additions & 3 deletions experiments/ghostwright/HOST-COMPARISON.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
12 changes: 6 additions & 6 deletions experiments/ghostwright/PERFORMANCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
70 changes: 35 additions & 35 deletions experiments/ghostwright/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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);
},
);
});
```

Expand All @@ -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.

Expand All @@ -82,16 +82,16 @@ 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')),
});
```

Terminal scrollback is a serialized, bounded observation of Ghostty history; it does not search application-owned virtual history. Pages default to 200 rows (maximum 1,000), and generation guards prevent mixed pagination after output or reflow:

```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.
Expand Down
Loading